feat: DevPod remote development environment

Adds one-click remote dev on Kapsule with Claude Code, persistent
storage, and autoscale-to-zero. GP1-L (16 vCPU, 64GB) dev pool,
50Gi PVC for home directory, devcontainer image on SCW registry.

- .devcontainer/ (Dockerfile, devcontainer.json, pod-template, init-home)
- Terraform gpu-dev pool (GP1-L, 0→1 autoscaling)
- CI job to build devcontainer image (Kaniko → SCW registry)
- scripts/devpod-setup.sh for one-time provider config
- "Open in DevPod" link: https://devpod.sh/open#ssh://git@100.90.76.85:2222/root/foxhunt.git

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-02-25 21:23:25 +01:00
10 changed files with 350 additions and 0 deletions

105
.devcontainer/Dockerfile Normal file
View File

@@ -0,0 +1,105 @@
# Foxhunt devcontainer — extends CI builder with interactive dev tooling
# Built by CI, pushed to GitLab registry as devcontainer:latest
# See: docs/plans/2026-02-25-devcontainer-design.md
ARG CI_BUILDER_IMAGE=rg.fr-par.scw.cloud/foxhunt-ci/ci-builder:latest
FROM ${CI_BUILDER_IMAGE}
ENV DEBIAN_FRONTEND=noninteractive
# Dev tooling: Node.js 22 (Claude Code), Python 3, shell tools
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 \
python3-pip \
python3-venv \
zsh \
sudo \
openssh-server \
git-lfs \
jq \
ripgrep \
fd-find \
htop \
less \
vim \
&& rm -rf /var/lib/apt/lists/*
# Node.js 22 LTS (Claude Code runtime)
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/*
# Claude Code CLI
RUN npm install -g @anthropic-ai/claude-code
# kubectl
RUN curl -fsSL "https://dl.k8s.io/release/$(curl -fsSL https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" \
-o /usr/local/bin/kubectl \
&& chmod +x /usr/local/bin/kubectl
# helm
RUN curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
# scw CLI (Scaleway)
RUN curl -fsSL https://github.com/scaleway/scaleway-cli/releases/latest/download/scaleway-cli_linux_amd64 \
-o /usr/local/bin/scw \
&& chmod +x /usr/local/bin/scw
# glab (GitLab CLI)
RUN curl -fsSL "https://gitlab.com/gitlab-org/cli/-/releases/permalink/latest/downloads/glab_linux_amd64.deb" \
-o /tmp/glab.deb \
&& dpkg -i /tmp/glab.deb \
&& rm /tmp/glab.deb
# terraform
RUN curl -fsSL https://releases.hashicorp.com/terraform/1.11.2/terraform_1.11.2_linux_amd64.zip \
-o /tmp/terraform.zip \
&& unzip -o /tmp/terraform.zip -d /usr/local/bin \
&& rm /tmp/terraform.zip
# terragrunt
RUN curl -fsSL https://github.com/gruntwork-io/terragrunt/releases/download/v0.72.6/terragrunt_linux_amd64 \
-o /usr/local/bin/terragrunt \
&& chmod +x /usr/local/bin/terragrunt
# yq (YAML processor)
RUN curl -fsSL https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 \
-o /usr/local/bin/yq \
&& chmod +x /usr/local/bin/yq
# Non-root dev user with sudo
RUN groupadd -g 1000 dev \
&& useradd -m -u 1000 -g dev -s /bin/zsh dev \
&& echo "dev ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers.d/dev
# Install oh-my-zsh for dev user
RUN su - dev -c 'sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" "" --unattended'
# Move Rust toolchain to shared location so dev user can access it
# (CI builder installs to /root/.cargo and /root/.rustup)
RUN cp -a /root/.cargo /opt/cargo \
&& cp -a /root/.rustup /opt/rustup \
&& chown -R dev:dev /opt/cargo /opt/rustup
# Environment for dev user
ENV CARGO_HOME=/home/dev/.cargo
ENV RUSTUP_HOME=/opt/rustup
ENV PATH="/home/dev/.cargo/bin:/opt/rustup/bin:/opt/cargo/bin:${PATH}"
ENV SQLX_OFFLINE=true
ENV CUDA_COMPUTE_CAP=90
# sccache local disk cache (PVC-backed home dir)
ENV SCCACHE_DIR=/home/dev/.cache/sccache
ENV RUSTC_WRAPPER=/usr/local/bin/sccache
# First-run init script: copies cargo bins to home if not already present
COPY .devcontainer/init-home.sh /usr/local/bin/init-home.sh
RUN chmod +x /usr/local/bin/init-home.sh
USER dev
WORKDIR /workspace
# Verify key tools
RUN rustc --version && cargo --version && node --version && claude --version \
&& kubectl version --client 2>/dev/null && helm version --short \
&& terraform --version && terragrunt --version && scw version && glab --version

View File

@@ -0,0 +1,23 @@
{
"name": "foxhunt",
"build": {
"dockerfile": "Dockerfile",
"context": "..",
"args": {
"CI_BUILDER_IMAGE": "rg.fr-par.scw.cloud/foxhunt-ci/ci-builder:latest"
}
},
"remoteUser": "dev",
"containerEnv": {
"SQLX_OFFLINE": "true",
"CUDA_COMPUTE_CAP": "90",
"EDITOR": "vim"
},
"postCreateCommand": "/usr/local/bin/init-home.sh",
"postStartCommand": "claude --version && cargo --version && kubectl cluster-info 2>/dev/null || true",
"customizations": {
"devpod": {
"provider": "kubernetes"
}
}
}

25
.devcontainer/init-home.sh Executable file
View File

@@ -0,0 +1,25 @@
#!/bin/bash
set -euo pipefail
# Bootstrap cargo into PVC-backed home if not already present
if [ ! -d "$HOME/.cargo/bin" ]; then
echo "[init-home] First run: linking cargo toolchain..."
mkdir -p "$HOME/.cargo/bin"
# Copy cargo config and link binaries from shared rustup
for bin in /opt/cargo/bin/*; do
ln -sf "$bin" "$HOME/.cargo/bin/$(basename "$bin")"
done
echo "[init-home] Cargo toolchain linked."
fi
# Ensure sccache cache dir exists
mkdir -p "${SCCACHE_DIR:-$HOME/.cache/sccache}"
# Default .zshrc if none exists (oh-my-zsh is in the image, config in PVC)
if [ ! -f "$HOME/.zshrc" ]; then
cp /home/dev/.oh-my-zsh/templates/zshrc.zsh-template "$HOME/.zshrc" 2>/dev/null || true
echo 'export PATH="$HOME/.cargo/bin:$PATH"' >> "$HOME/.zshrc"
echo 'export SQLX_OFFLINE=true' >> "$HOME/.zshrc"
fi
echo "[init-home] Ready."

View File

@@ -0,0 +1,41 @@
# DevPod pod template — merged with DevPod's generated pod spec
# Ref: https://github.com/loft-sh/devpod-provider-kubernetes
apiVersion: v1
kind: Pod
metadata:
labels:
app.kubernetes.io/name: devpod
app.kubernetes.io/part-of: foxhunt
app.kubernetes.io/component: dev-environment
spec:
nodeSelector:
k8s.scaleway.com/pool-name: gpu-dev
containers:
- name: devpod
resources:
requests:
cpu: "4000m"
memory: "16Gi"
limits:
cpu: "14000m"
memory: "56Gi"
volumeMounts:
- name: dev-home
mountPath: /home/dev
- name: training-data
mountPath: /data/training
readOnly: true
initContainers:
- name: init-home
image: busybox:latest
command: ["sh", "-c", "chown -R 1000:1000 /home/dev"]
volumeMounts:
- name: dev-home
mountPath: /home/dev
volumes:
- name: dev-home
persistentVolumeClaim:
claimName: dev-home-pvc
- name: training-data
persistentVolumeClaim:
claimName: training-data-pvc

View File

@@ -57,6 +57,35 @@ build-ci-builder:
--dockerfile "${CI_PROJECT_DIR}/infra/docker/Dockerfile.ci-builder"
--destination "${CI_BUILDER_IMAGE}"
# --------------------------------------------------------------------------
# Stage 0b: Build devcontainer image → push to internal GitLab registry
# --------------------------------------------------------------------------
build-devcontainer:
stage: prepare
image:
name: gcr.io/kaniko-project/executor:debug
entrypoint: [""]
tags:
- kapsule
- docker
rules:
- if: $CI_PIPELINE_SOURCE == "push"
changes:
- .devcontainer/**
when: on_success
- when: manual
allow_failure: true
before_script:
- mkdir -p /kaniko/.docker
- |
echo "{\"auths\":{\"rg.fr-par.scw.cloud\":{\"username\":\"nologin\",\"password\":\"${SCW_SECRET_KEY}\"}}}" > /kaniko/.docker/config.json
script:
- /kaniko/executor
--context "${CI_PROJECT_DIR}"
--dockerfile "${CI_PROJECT_DIR}/.devcontainer/Dockerfile"
--destination "rg.fr-par.scw.cloud/foxhunt-ci/devcontainer:${CI_COMMIT_SHA}"
--destination "rg.fr-par.scw.cloud/foxhunt-ci/devcontainer:latest"
# Base template for Rust jobs — pre-baked CI builder from Scaleway CR
# Image pre-exists in SCR; rebuild via build-ci-builder when Dockerfile changes
.rust-base:

View File

@@ -0,0 +1,16 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: dev-home-pvc
namespace: foxhunt
labels:
app.kubernetes.io/name: dev-home
app.kubernetes.io/part-of: foxhunt
app.kubernetes.io/component: devpod
spec:
storageClassName: scw-bssd
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 50Gi

View File

@@ -26,4 +26,9 @@ inputs = {
gitlab_type = "GP1-XS"
# CI builds run on gpu-training pool (H100: 24 vCPU, 240GB, fast builds + GPU tests)
# Dev pool (GP1-L for DevPod remote development, autoscales to zero)
enable_dev_pool = true
dev_pool_type = "GP1-L"
dev_pool_max_size = 1
}

View File

@@ -89,3 +89,21 @@ resource "scaleway_k8s_pool" "gitlab" {
region = var.region
}
# CPU pool for remote development (DevPod — autoscales to zero)
resource "scaleway_k8s_pool" "dev" {
count = var.enable_dev_pool ? 1 : 0
cluster_id = scaleway_k8s_cluster.foxhunt.id
name = "gpu-dev"
node_type = var.dev_pool_type
size = 1
min_size = 0
max_size = var.dev_pool_max_size
autoscaling = true
autohealing = true
region = var.region
lifecycle {
ignore_changes = [size]
}
}

View File

@@ -69,3 +69,21 @@ variable "gitlab_type" {
default = "DEV1-L"
}
variable "enable_dev_pool" {
description = "Create CPU node pool for remote development (DevPod)"
type = bool
default = false
}
variable "dev_pool_type" {
description = "Instance type for the dev node pool"
type = string
default = "GP1-L"
}
variable "dev_pool_max_size" {
description = "Maximum number of nodes in the dev pool"
type = number
default = 1
}

70
scripts/devpod-setup.sh Executable file
View File

@@ -0,0 +1,70 @@
#!/bin/bash
set -euo pipefail
# DevPod one-time setup for Foxhunt development
# Prerequisites: devpod CLI installed, kubectl configured for foxhunt cluster
#
# Install DevPod:
# curl -fsSL https://devpod.sh/install.sh | bash
#
# Usage:
# ./scripts/devpod-setup.sh
# devpod up . # from the foxhunt repo root
# devpod up git@100.90.76.85:2222/root/foxhunt.git # from anywhere
echo "=== Foxhunt DevPod Setup ==="
# Check prerequisites
command -v devpod >/dev/null 2>&1 || { echo "Error: devpod not installed. Run: curl -fsSL https://devpod.sh/install.sh | bash"; exit 1; }
command -v kubectl >/dev/null 2>&1 || { echo "Error: kubectl not found"; exit 1; }
# Verify cluster access
if ! kubectl -n foxhunt get ns foxhunt >/dev/null 2>&1; then
echo "Error: Cannot reach foxhunt namespace. Check kubectl config."
exit 1
fi
# Ensure PVC exists
if ! kubectl -n foxhunt get pvc dev-home-pvc >/dev/null 2>&1; then
echo "Creating dev-home-pvc (50Gi block storage)..."
kubectl apply -f infra/k8s/storage/dev-home-pvc.yaml
fi
# Add kubernetes provider (idempotent)
devpod provider add kubernetes 2>/dev/null || true
# Configure provider
devpod provider set kubernetes \
KUBERNETES_NAMESPACE=foxhunt \
NODE_SELECTOR="k8s.scaleway.com/pool-name=gpu-dev" \
DISK_SIZE=10Gi \
STORAGE_CLASS=scw-bssd \
INACTIVITY_TIMEOUT=30m \
IMAGE_PULL_SECRETS=gitlab-registry \
POD_MANIFEST_TEMPLATE=.devcontainer/pod-template.yaml
# Configure image pull secrets if not already present
if ! kubectl -n foxhunt get secret gitlab-registry >/dev/null 2>&1; then
echo ""
echo "NOTE: You need to create the gitlab-registry pull secret:"
echo " kubectl -n foxhunt create secret docker-registry gitlab-registry \\"
echo " --docker-server=gitlab-registry.foxhunt.svc.cluster.local:5000 \\"
echo " --docker-username=<user> --docker-password=<token>"
echo ""
fi
echo ""
echo "=== Setup complete ==="
echo ""
echo "Usage:"
echo " devpod up . # from repo root"
echo " devpod up git@100.90.76.85:2222/root/foxhunt.git # from anywhere"
echo ""
echo "Open in DevPod link (bookmark this):"
echo " https://devpod.sh/open#ssh://git@100.90.76.85:2222/root/foxhunt.git"
echo ""
echo "Session lifecycle:"
echo " devpod up foxhunt # start/reconnect"
echo " devpod stop foxhunt # stop (PVC preserved, node scales down)"
echo " devpod delete foxhunt # delete workspace (PVC preserved)"
echo ""