diff --git a/.dockerignore b/.dockerignore index a12d528..a092cd5 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,6 +2,7 @@ .github *.md docs/ +tests/ templates/ workspace/ runtime-config/ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc484ad..e7d4503 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,9 @@ jobs: just --list (cd templates/enterprise && just --fmt --check && just --list) + - name: Test launcher behavior + run: python3 -m unittest discover -s tests -v + - name: Check Compose configuration run: | docker compose config --quiet diff --git a/.github/workflows/release-images.yml b/.github/workflows/release-images.yml index 25bd373..1a5ef38 100644 --- a/.github/workflows/release-images.yml +++ b/.github/workflows/release-images.yml @@ -120,6 +120,7 @@ jobs: - name: Build release image for scanning env: REGISTRY: workspace-test + TAG: latest CACHE_FROM: "true" CACHE_REGISTRY: ghcr.io/${{ github.repository_owner }} CACHE_IMAGE: workspace-cache @@ -131,7 +132,7 @@ jobs: docker run --rm \ --entrypoint /bin/sh \ --volume /var/run/docker.sock:/var/run/docker.sock \ - workspace-test/workspace:full -c ' + workspace-test/workspace:full-latest -c ' set -eu trivy image --download-db-only --no-progress trivy image --download-java-db-only --no-progress @@ -143,7 +144,7 @@ jobs: --ignore-unfixed \ --scanners vuln \ --severity CRITICAL \ - workspace-test/workspace:full + workspace-test/workspace:full-latest ' - name: Publish code, platform, and full diff --git a/.gitignore b/.gitignore index f76aaa0..082d5b2 100644 --- a/.gitignore +++ b/.gitignore @@ -17,5 +17,8 @@ secrets/ # Docker .env +# Python test artifacts +__pycache__/ + # OS .DS_Store diff --git a/README.md b/README.md index e8b3b78..c6a5053 100644 --- a/README.md +++ b/README.md @@ -18,11 +18,30 @@ Containerized development workspace images and an enterprise overlay template. just start # code just start platform just start full + +# Use a published image without a local build +just pull platform +just start platform ``` `just start` builds only when the selected local image is missing. Use -`just up ` when you explicitly want to rebuild. Builds go through -`docker buildx bake`, not `docker compose up --build`. +`just up ` to rebuild. Both wait for the proxy and enabled browser +services to be healthy. Builds go through `docker buildx bake`. + +Images use `-`, such as `platform-latest`. Set `FLAVOR`, `TAG`, and +`REGISTRY` in `.env` to use the same selection for builds, pulls, and startup; +shell environment values take precedence. For a specific release, set +`TAG=1.3.0`, then run `just pull` and `just start`. + +Use `just shell` for an interactive terminal, or `just exec ...` to +run a command as `dev` in `/workspace`, including pipelines: + +```bash +just exec git status +printf 'hello\n' | just exec cat +just health # proxy and enabled services +just status # container state and Docker health +``` Open: @@ -30,7 +49,6 @@ Open: - `http://localhost:8080/code/` — code-server - `http://localhost:8080/lab` — JupyterLab in `full` - `http://localhost:8080/health` — proxy liveness -- `http://localhost:8080/status` — compact status ## Supported images @@ -98,8 +116,8 @@ platform secret store. ## Requirements - Docker with BuildKit/buildx -- Docker Compose v2 -- [`just`](https://just.systems) +- Docker Compose v2 with `up --wait` support +- [`just`](https://just.systems) 1.54 or newer ## More docs diff --git a/compose.yaml b/compose.yaml index 55afd4b..f20f13a 100644 --- a/compose.yaml +++ b/compose.yaml @@ -5,7 +5,7 @@ services: workspace: - image: ${REGISTRY:-ghcr.io/jo-cube}/workspace:${FLAVOR:-code} + image: ${REGISTRY:-ghcr.io/jo-cube}/workspace:${FLAVOR:-code}-${TAG:-latest} ports: - "${WORKSPACE_BIND_ADDRESS:-127.0.0.1}:${WORKSPACE_PORT:-8080}:8080" volumes: diff --git a/config/Caddyfile b/config/Caddyfile index c54b9f7..0ea9f6d 100644 --- a/config/Caddyfile +++ b/config/Caddyfile @@ -8,11 +8,6 @@ respond "OK" } - handle /status { - header Content-Type application/json - respond `{"status":"running"}` - } - @lab path /lab /lab/* handle @lab { reverse_proxy 127.0.0.1:8888 diff --git a/config/index.html b/config/index.html index 8570542..274c231 100644 --- a/config/index.html +++ b/config/index.html @@ -100,7 +100,6 @@

Workspace

diff --git a/docker-bake.hcl b/docker-bake.hcl index 67ebc5c..d825be6 100644 --- a/docker-bake.hcl +++ b/docker-bake.hcl @@ -51,7 +51,7 @@ target "code-core" { target "code" { dockerfile = "docker/runtime.Dockerfile" context = "." - tags = ["${REGISTRY}/workspace:code-${TAG}", "${REGISTRY}/workspace:code"] + tags = ["${REGISTRY}/workspace:code-${TAG}"] args = { BASE_IMAGE = "${REGISTRY}/workspace:code-core" } contexts = { "${REGISTRY}/workspace:code-core" = "target:code-core" } output = PUBLISH == "true" ? [] : ["type=docker"] @@ -78,7 +78,7 @@ target "platform-core" { target "platform" { dockerfile = "docker/runtime.Dockerfile" context = "." - tags = ["${REGISTRY}/workspace:platform-${TAG}", "${REGISTRY}/workspace:platform"] + tags = ["${REGISTRY}/workspace:platform-${TAG}"] args = { BASE_IMAGE = "${REGISTRY}/workspace:platform-core" } contexts = { "${REGISTRY}/workspace:platform-core" = "target:platform-core" } output = PUBLISH == "true" ? [] : ["type=docker"] @@ -96,7 +96,7 @@ target "full-core" { target "full" { dockerfile = "docker/runtime.Dockerfile" context = "." - tags = ["${REGISTRY}/workspace:full-${TAG}", "${REGISTRY}/workspace:full", "${REGISTRY}/workspace:latest"] + tags = ["${REGISTRY}/workspace:full-${TAG}"] args = { BASE_IMAGE = "${REGISTRY}/workspace:full-core" } contexts = { "${REGISTRY}/workspace:full-core" = "target:full-core" } output = PUBLISH == "true" ? [] : ["type=docker"] diff --git a/docker/full.Dockerfile b/docker/full.Dockerfile index dcfe290..5b29121 100644 --- a/docker/full.Dockerfile +++ b/docker/full.Dockerfile @@ -27,6 +27,7 @@ esac >> /etc/arch-env EOF # JupyterLab +# Build the Rust kernel serially to keep peak compiler memory manageable. USER dev RUN --mount=type=cache,target=/cache/uv,sharing=locked,uid=1000,gid=1000 \ --mount=type=cache,target=/opt/rust/cargo/registry,sharing=locked,uid=1000,gid=1000 \ @@ -36,7 +37,7 @@ RUN --mount=type=cache,target=/cache/uv,sharing=locked,uid=1000,gid=1000 \ && /opt/uv-tools/jupyterlab/bin/python -m bash_kernel.install --sys-prefix \ && /opt/uv-tools/jupyterlab/bin/python -c 'import json,pathlib,sys; p=pathlib.Path(sys.prefix)/"share/jupyter/kernels/kotlin/kernel.json"; data=json.loads(p.read_text()); data["argv"][0]=sys.executable; data["metadata"]["jar_path_detect_command"][0]=sys.executable; p.write_text(json.dumps(data, indent=2)+"\n")' \ && rustup component add rust-src \ - && cargo install --locked evcxr_jupyter \ + && cargo install --locked --jobs 1 evcxr_jupyter \ && JUPYTER_PATH=/opt/uv-tools/jupyterlab/share/jupyter evcxr_jupyter --install USER root diff --git a/docker/runtime.Dockerfile b/docker/runtime.Dockerfile index 12dbdb4..50f046c 100644 --- a/docker/runtime.Dockerfile +++ b/docker/runtime.Dockerfile @@ -18,7 +18,7 @@ RUN chmod +x /etc/s6-overlay/s6-rc.d/caddy/run \ COPY config/cont-init.d/ /etc/cont-init.d/ RUN chmod +x /etc/cont-init.d/* -COPY scripts/ /scripts/ +COPY scripts/doctor.sh scripts/healthcheck.sh /scripts/ RUN chmod +x /scripts/*.sh COPY config/Caddyfile /etc/caddy/Caddyfile diff --git a/docs/architecture.md b/docs/architecture.md index 0fcd54b..7d80990 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -57,7 +57,6 @@ image, so a failed browser application cannot leave the container healthy. ```text / static service links /health static 200 response -/status {"status":"running"} /code, /code/* code-server /lab, /lab/* JupyterLab ``` @@ -71,4 +70,9 @@ proxy. - `docker/*.Dockerfile` — readable internal layers and the final runtime overlay - `docker-bake.hcl` — dependency graph for the three supported images - `compose.yaml` — local launcher -- `justfile` — thin build and runtime commands +- `justfile` — thin build and runtime commands, including pull and command execution + +Bake, Compose, and published releases use `-` image names. +The launchers load `.env`, honor shell overrides, and wait for Docker health +before reporting successful startup. `just health` runs the same probe as +Docker; `/health` checks only Caddy liveness. diff --git a/docs/coder.md b/docs/coder.md index e3395bb..6a7be24 100644 --- a/docs/coder.md +++ b/docs/coder.md @@ -42,7 +42,7 @@ resource "coder_agent" "main" { } resource "docker_image" "workspace" { - name = "ghcr.io/jo-cube/workspace:platform" + name = "ghcr.io/jo-cube/workspace:platform-latest" keep_locally = true } @@ -146,7 +146,7 @@ coder port-forward --tcp 8080:8080 Then access locally: - `http://localhost:8080/code/` — code-server - `http://localhost:8080/lab` — JupyterLab -- `http://localhost:8080/status` — workspace status +- `http://localhost:8080/health` — proxy liveness ## Single-port model @@ -160,7 +160,7 @@ Choose the image flavor per workspace: ```hcl resource "docker_image" "workspace" { - name = "ghcr.io/jo-cube/workspace:full" + name = "ghcr.io/jo-cube/workspace:full-latest" } ``` diff --git a/docs/images.md b/docs/images.md index 5986f86..18f85f8 100644 --- a/docs/images.md +++ b/docs/images.md @@ -42,6 +42,32 @@ Everything in `platform` plus: - Trivy and Gitleaks - hyperfine and sqlite3 +## Image selection + +Runtime image names are `ghcr.io/jo-cube/workspace:-`, for example +`code-latest` or `full-1.3.0`. `TAG` defaults to `latest`. Local builds and +published releases use the same naming scheme. + +To run a published image without a local build, put your selection in `.env`: + +```dotenv +FLAVOR=platform +TAG=1.3.0 +REGISTRY=ghcr.io/jo-cube +``` + +```bash +just pull +just start +``` + +`just pull` downloads the selected image without starting it. `just start` +uses the local image if present, otherwise builds it from this checkout. +`just up` always rebuilds. Use `just pull` again to refresh a moving tag. +An explicit flavor argument overrides `FLAVOR`; exported variables override +`.env`. Direct Bake invocations use exported variables, so use `just build` +when you want `.env` selection applied. + ## Internal build layers The Bake graph uses `base-core`, `code-core`, `polyglot-core`, diff --git a/docs/local-development.md b/docs/local-development.md index 361144d..5333008 100644 --- a/docs/local-development.md +++ b/docs/local-development.md @@ -2,166 +2,140 @@ ## Prerequisites -- Docker with BuildKit enabled -- Docker Compose v2 -- [just](https://just.systems) command runner +- Docker with BuildKit/buildx and Compose v2 with `up --wait` support +- [just](https://just.systems) 1.54 or newer -## Start workspace +## Start a workspace ```bash -# Default (code flavor, build only if missing) -just start - -# Specific flavor +just start # code, build only if the selected image is missing just start platform just start full ``` -## Access +Use `just up ` to rebuild before starting, or `just pull ` to +download a published image before `just start `. Both start commands +wait up to 120 seconds for Caddy and every enabled browser service to be +healthy. A failed build or readiness check returns a failure; inspect +`just logs`, `just status`, or `just doctor` for details. + +## Access and commands | Method | Command / URL | |--------|---------------| | Dashboard | `http://localhost:8080` | | Browser IDE | `http://localhost:8080/code/` | -| Jupyter | `http://localhost:8080/lab` (when enabled) | +| Jupyter | `http://localhost:8080/lab` (`full`) | | Proxy liveness | `http://localhost:8080/health` | -| Status | `http://localhost:8080/status` | -| Shell | `just shell` | - -The dashboard at root provides links to the browser services and status routes. -JupyterLab opens at `/workspace`, the bind-mounted project root. User state and shell history live in `/home/dev`. - -## Jupyter kernels - -`full` includes Python, Bash, Rust via Evcxr, Go via GoNB, and Kotlin kernels. - -GoNB uses the Go compiler, so bare expressions like `2 + 2` are not valid top-level cells. Use a normal `func main`: - -```go -func main() { - fmt.Println(2 + 2) -} -``` +| Container state and Docker health | `just status` | +| Interactive shell | `just shell` | +| Run a command | `just exec git status` | -Or use GoNB's `%%` shortcut, which wraps the cell body in `func main`: - -```go -%% -fmt.Println(2 + 2) -``` - -## Volumes - -```yaml -volumes: - - ./workspace:/workspace # your projects - - home:/home/dev # persists dotfiles, shell history - - cache:/cache # package caches (uv, gradle, go, etc.) - - ./runtime-config:/etc/workspace:ro # optional local app config -``` - -The `home` and `cache` volumes persist across container recreations. Image -dotfiles seed a new home volume once; image updates do not overwrite later user -changes. Remove the volumes with: +Shells and commands run as `dev` in `/workspace` with `HOME=/home/dev`. +`just exec` preserves arguments and exit status, passes stdin through, and +works without a terminal: ```bash -just reset +just exec python -c 'print("hello from Python")' +printf 'hello\n' | just exec cat +just exec sh -c 'git status && git diff --stat' ``` -`./workspace` is a host bind mount, not a Docker volume, so `just reset` keeps its files. To remove those files too without pruning Docker build cache: +Use an explicit shell such as `sh -c` when you need shell operators inside +the container. Use `just shell` for interactive programs that need a terminal. -```bash -just reset-workspace -``` - -`just start` and `just up` make the bind root writable for the image's fixed -`dev` user. This assumes the documented trusted single-user host; use a -deployment-specific UID or mount policy on a multi-user host. - -## Environment overrides +## Image and environment selection -Create a `.env` file for host-side selection: +The launchers load `.env` beside their `justfile`. Exported shell variables take precedence, and an +explicit flavor argument overrides `FLAVOR`: -```bash +```dotenv FLAVOR=full +TAG=latest +REGISTRY=ghcr.io/jo-cube WORKSPACE_PORT=9090 WORKSPACE_BIND_ADDRESS=127.0.0.1 PASSWORD='change-me' JUPYTER_TOKEN='change-me-too' ``` -Service defaults come from the image. Use `full` for JupyterLab. -Set `PASSWORD` or `HASHED_PASSWORD` for code-server auth. Set `JUPYTER_TOKEN` for JupyterLab auth. Caddy is the path router. -Leave those values unset for an unauthenticated local container on a trusted loopback-only setup. -Compose binds to loopback by default. Set `WORKSPACE_BIND_ADDRESS=0.0.0.0` only -behind an authenticated workspace proxy such as Coder, or after configuring -app credentials and a suitable network/TLS boundary. +This selects `ghcr.io/jo-cube/workspace:full-latest`. To use a specific published +release, set its `TAG`, then run `just pull` followed by `just start`. +Builds, pulls, and startup share the same image selection. See [Images](images.md). -You can also use a generic runtime config file: +## Authentication -```bash -mkdir -p runtime-config -cat > runtime-config/config.env -``` +Set `PASSWORD` or `HASHED_PASSWORD` for code-server, and `JUPYTER_TOKEN` for +JupyterLab. Unset credentials allow trusted single-user local operation. +Compose binds to loopback by default. Listen on `0.0.0.0` only behind an +authenticated workspace proxy such as Coder, or with app credentials and an +appropriate network/TLS boundary. + +Credentials can also live in `runtime-config/config.env`: ```bash PASSWORD='change-me' JUPYTER_TOKEN='change-me-too' ``` -`runtime-config/config.env` is gitignored and mounted read-only at -`/etc/workspace/config.env`. It is also excluded from the Docker build context, -so credentials stay out of image layers. A runtime connector can mount the same -file elsewhere and set `WORKSPACE_CONFIG_FILE` to that container path. -Use quoted values for secrets or hashes so shell metacharacters stay literal. +This optional shell-format file is gitignored, excluded from the build +context, and mounted read-only at `/etc/workspace/config.env`. Quote values +so shell metacharacters stay literal. A deployment can mount it elsewhere +and set `WORKSPACE_CONFIG_FILE` to that container path. -## Health check +## Persistent data -```bash -# Caddy liveness from host -just health +| Path | Storage | Purpose | +|------|---------|---------| +| `/workspace` | `./workspace` bind mount | Project files | +| `/home/dev` | `home` named volume | User configuration and history | +| `/cache` | `cache` named volume | Package caches | +| `/etc/workspace` | `./runtime-config` read-only bind mount | Optional app configuration | -# Full check inside container -just doctor -``` +Image dotfiles seed a new home volume once; image updates preserve user edits. +Image-owned runtimes live outside `/home/dev`, so they remain available when +switching flavors or reusing volumes. -## Rebuilding +`just start` makes the workspace bind root writable for the fixed `dev` user. +This assumes a trusted single-user host; use a deployment-specific UID or +mount policy on a multi-user host. ```bash -# Start without rebuilding when the image exists -just start - -# Rebuild and start -just up full +just down # stop, preserve all data +just reset # remove home/cache volumes, preserve project files +just reset-workspace # also delete ./workspace contents +just clean-build-cache # prune Docker build cache +just clean # reset volumes and prune build cache +``` -# Rebuild one flavor and its final runtime overlay -docker buildx bake full +## Health and validation -# Full clean rebuild -just clean -just up full +```bash +just health # probe Caddy and every enabled browser service +just doctor # also check tools and filesystem permissions +just logs # follow service logs ``` -`just clean` also prunes Docker build cache. Use `just reset` when you only want fresh runtime volumes, or `just reset-workspace` when you also want to empty the bind-mounted project directory. +`just health` runs inside the selected Compose container, so it works with +custom host ports and bind addresses. `/health` is only a Caddy liveness probe. -## Useful commands +For repository changes, run: ```bash -just status # show container state -just logs # follow logs -just doctor # health checks inside container -just start # start, build only if image is missing -just reset # remove home/cache volumes, keep ./workspace and build cache -just reset-workspace # remove home/cache volumes and ./workspace contents -just # show all available commands +python3 -m unittest discover -s tests -v # recipe behavior and Compose/Bake agreement +./scripts/smoke-test.sh all # build and exercise every image and kernel ``` -## Tips +The recipe tests require Python 3, Just, and the Docker CLI; they do not need +a running Docker daemon. Smoke tests use isolated containers and volumes. + +## Jupyter kernels + +`full` includes Python, Bash, Rust via Evcxr, Go via GoNB, and Kotlin kernels. +JupyterLab opens at `/workspace`. GoNB uses the Go compiler, so wrap expressions +in a function or use its `%%` shortcut: -- The `workspace/` directory is bind-mounted, so its files survive `just reset`. -- Shell history and user-edited config persist in the `home` volume. Use - `just reset` when you intentionally want the current image defaults again. -- Package caches (uv, gradle, go modules) persist in the `cache` volume. -- Use `just shell` for quick terminal access. -- The workspace index page at root links to the browser services. -- `docker buildx bake` resolves all parent images automatically. Use bake/`just`, not `docker compose up --build`. +```go +%% +fmt.Println(2 + 2) +``` diff --git a/justfile b/justfile index fb3f85b..e83e8fd 100644 --- a/justfile +++ b/justfile @@ -1,35 +1,39 @@ -# justfile +# Load only this project's optional configuration. +set dotenv-command := "if [ -f .env ]; then cat .env; fi" +set positional-arguments +set shell := ["sh", "-eu", "-c"] -registry := env("REGISTRY", "ghcr.io/jo-cube") -tag := env("TAG", "latest") -flavor := env("FLAVOR", "code") +export REGISTRY := if env("REGISTRY", "") == "" { "ghcr.io/jo-cube" } else { env("REGISTRY") } +export TAG := if env("TAG", "") == "" { "latest" } else { env("TAG") } +flavor := if env("FLAVOR", "") == "" { "code" } else { env("FLAVOR") } # List available commands default: @just --list # Build a specific flavor (and its dependencies) -build target=flavor: - docker buildx bake {{ target }} +build target=flavor: (_validate-flavor target) + docker buildx bake "$1" # Build all supported images build-all: docker buildx bake all -# Rebuild and start a specific flavor -up target=flavor: - just build {{ target }} - mkdir -p workspace runtime-config - chmod 0777 workspace - REGISTRY={{ registry }} FLAVOR={{ target }} docker compose up -d --no-build - -# Start a flavor, building only when the local runtime image is missing -start target=flavor: - @image="{{ registry }}/workspace:{{ target }}"; \ - docker image inspect "$image" >/dev/null 2>&1 || just build {{ target }}; \ +# Rebuild and wait for a healthy workspace +up target=flavor: (build target) + just start "$1" + +# Start a flavor, building only when the selected image is missing +start target=flavor: (_validate-flavor target) + @image="${REGISTRY}/workspace:${1}-${TAG}"; \ + if ! docker image inspect "$image" >/dev/null 2>&1; then just build "$1"; fi; \ mkdir -p workspace runtime-config; \ chmod 0777 workspace; \ - REGISTRY={{ registry }} FLAVOR={{ target }} docker compose up -d --no-build + FLAVOR="$1" docker compose up --wait --wait-timeout 120 --no-build + +# Pull a published flavor at TAG without building locally +pull target=flavor: (_validate-flavor target) + FLAVOR="$1" docker compose pull workspace # Stop the running workspace down: @@ -37,27 +41,36 @@ down: # Open a dev shell in the running workspace shell: - docker compose exec --user dev --env HOME=/home/dev --env USER=dev workspace zsh + docker compose exec --user dev --env HOME=/home/dev --env USER=dev --workdir /workspace workspace zsh -l + +# Run a command as dev; preserve arguments, stdin, and exit status +exec +command: + @docker compose exec -T --user dev --env HOME=/home/dev --env USER=dev --workdir /workspace workspace "$@" # Follow workspace logs logs: docker compose logs -f -# Show running container status +# Show container state, including Docker health status status: docker compose ps # Run health checks inside the container doctor: - docker compose exec workspace bash /scripts/doctor.sh + docker compose exec -T workspace bash /scripts/doctor.sh + +# Check the proxy and every enabled browser service +health: + @docker compose exec -T workspace /scripts/healthcheck.sh + @echo "workspace healthy" # Push a specific flavor to registry -push target=flavor: - REGISTRY={{ registry }} TAG={{ tag }} docker buildx bake {{ target }} --push +push target=flavor: (_validate-flavor target) + docker buildx bake "$1" --push # Push all images to registry push-all: - REGISTRY={{ registry }} TAG={{ tag }} docker buildx bake all --push + docker buildx bake all --push # Reset runtime data, keep build cache reset: @@ -75,6 +88,5 @@ clean-build-cache: # Remove runtime data and Docker build cache clean: reset clean-build-cache -# Quick Caddy liveness check from host -health: - @curl --noproxy '*' -sf http://localhost:${WORKSPACE_PORT:-8080}/health >/dev/null && echo "workspace healthy" +_validate-flavor target: + @case "$1" in code|platform|full) ;; *) echo "Unsupported flavor: $1 (expected code, platform, or full)" >&2; exit 1 ;; esac diff --git a/scripts/doctor.sh b/scripts/doctor.sh index 6af8a6b..49a7c84 100755 --- a/scripts/doctor.sh +++ b/scripts/doctor.sh @@ -80,7 +80,6 @@ done echo "" echo "--- Network ---" check_service "http://127.0.0.1:8080/health" "Caddy proxy" -check_service "http://127.0.0.1:8080/status" "status endpoint" [ "${ENABLE_CODE:-false}" = "true" ] && check_service "http://127.0.0.1:8081/healthz" "code-server" [ "${ENABLE_JUPYTER:-false}" = "true" ] && check_service "http://127.0.0.1:8888/lab" "JupyterLab" diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 0430ac4..95caf39 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -22,14 +22,14 @@ fail() { echo -e " ${RED}✗${NC} $1"; ((FAIL += 1)); } header() { echo -e "\n${BOLD}=== $1 ===${NC}"; } image_for() { - printf '%s/workspace:%s' "$IMAGE_REGISTRY" "$1" + printf '%s/workspace:%s-smoke' "$IMAGE_REGISTRY" "$1" } build_flavors() { header "Building: $*" ( cd "$ROOT_DIR" - REGISTRY="$IMAGE_REGISTRY" docker buildx bake "$@" + REGISTRY="$IMAGE_REGISTRY" TAG=smoke docker buildx bake "$@" ) } @@ -69,7 +69,7 @@ run_version() { wait_for_url() { local container="$1" url="$2" for _ in {1..30}; do - docker exec "$container" curl -fsS --max-time 2 "$url" &>/dev/null && return 0 + docker exec "$container" curl --noproxy '*' -fsS --max-time 2 "$url" &>/dev/null && return 0 sleep 1 done return 1 @@ -115,11 +115,6 @@ test_code() { else fail "code-server health endpoint responding" fi - if docker exec "$cid" sh -lc "curl -fsS http://127.0.0.1:8080/status | jq -e '. == {\"status\":\"running\"}'" &>/dev/null; then - pass "compact status endpoint responding" - else - fail "compact status endpoint responding" - fi if docker exec "$cid" sh -lc "curl -fsS http://127.0.0.1:8080/ | grep -q 'Code Server'" &>/dev/null; then pass "static dashboard responding" else diff --git a/templates/enterprise/.dockerignore b/templates/enterprise/.dockerignore index 44d8947..728919e 100644 --- a/templates/enterprise/.dockerignore +++ b/templates/enterprise/.dockerignore @@ -5,5 +5,6 @@ docs/ workspace/ runtime-config/ secrets/ +.env* config/ca-certificates/* !config/ca-certificates/*.crt diff --git a/templates/enterprise/Dockerfile b/templates/enterprise/Dockerfile index 5aa514e..1ab5a7f 100644 --- a/templates/enterprise/Dockerfile +++ b/templates/enterprise/Dockerfile @@ -2,7 +2,7 @@ # Enterprise workspace overlay # Builds FROM the generic workspace image and adds enterprise-specific config. -ARG BASE_IMAGE=ghcr.io/jo-cube/workspace:platform +ARG BASE_IMAGE=ghcr.io/jo-cube/workspace:platform-latest FROM ${BASE_IMAGE} # Enterprise CA certificates diff --git a/templates/enterprise/README.md b/templates/enterprise/README.md index b6c0c98..ce526f2 100644 --- a/templates/enterprise/README.md +++ b/templates/enterprise/README.md @@ -2,6 +2,8 @@ Enterprise overlay for the generic workspace images. Use this as a small template for corporate trust, proxy, registry, Git, and internal-tool defaults. +Requires Docker with Compose `up --wait` support and Just 1.54 or newer. + ## Quick start ```bash @@ -15,7 +17,7 @@ just shell just up # Build with a different supported base -BASE_IMAGE=ghcr.io/jo-cube/workspace:full just build +BASE_IMAGE=ghcr.io/jo-cube/workspace:full-latest just build ``` ## What this adds @@ -41,9 +43,20 @@ just down # stop just shell # open zsh just logs # follow logs just push # push to enterprise registry -just clean # remove volumes +just pull # download the selected enterprise image +just exec git status # run as dev; stdin and exit status pass through +just status # container state and Docker health +just health # proxy and enabled browser services +just doctor # tools, services, and filesystem checks +just reset # remove home/cache volumes, keep workspace files ``` +`just start` and `just up` wait for service readiness. All commands load `.env` +and honor exported shell overrides. Set `REGISTRY` (the complete enterprise +image repository), `TAG`, and `BASE_IMAGE` there; the selected runtime image is +`${REGISTRY}:${TAG}`. To use a published enterprise image, run `just pull` then +`just start`. `BASE_IMAGE` is used only when building the overlay. + ## Configuration 1. Place public CA certificates in `config/ca-certificates/`. CI can materialize secret-managed files there before build. diff --git a/templates/enterprise/docker-bake.hcl b/templates/enterprise/docker-bake.hcl index 4ff1ae0..395ad6a 100644 --- a/templates/enterprise/docker-bake.hcl +++ b/templates/enterprise/docker-bake.hcl @@ -7,7 +7,7 @@ variable "TAG" { } variable "BASE_IMAGE" { - default = "ghcr.io/jo-cube/workspace:platform" + default = "ghcr.io/jo-cube/workspace:platform-latest" } variable "HTTP_PROXY" { diff --git a/templates/enterprise/docs/architecture.md b/templates/enterprise/docs/architecture.md index 1af2125..24e73de 100644 --- a/templates/enterprise/docs/architecture.md +++ b/templates/enterprise/docs/architecture.md @@ -5,7 +5,7 @@ The enterprise overlay does not build from scratch. It layers enterprise-specific configuration on top of a generic workspace image: ``` -ghcr.io/jo-cube/workspace:platform (generic base) +ghcr.io/jo-cube/workspace:platform-latest (generic base) └── enterprise overlay ├── CA certificates ├── proxy configuration @@ -28,7 +28,7 @@ ghcr.io/jo-cube/workspace:platform (generic base) ## Security - No secrets baked into images -- CA certificates and Git config are the only build-time additions +- Non-secret CA, proxy, Git, and registry defaults are baked into the overlay - Proxy URLs use placeholders in version control - Secrets are mounted read-only at `/secrets/` - App credentials are mounted read-only at `/etc/workspace/`, not baked @@ -51,7 +51,7 @@ steps: run: | install -m 0644 "$CI_TRUST_BUNDLE" config/ca-certificates/root-ca.crt REGISTRY=registry.internal.example.com/workspace \ - BASE_IMAGE=ghcr.io/jo-cube/workspace:platform \ + BASE_IMAGE=ghcr.io/jo-cube/workspace:platform-latest \ docker buildx bake enterprise --push ``` diff --git a/templates/enterprise/docs/configuration.md b/templates/enterprise/docs/configuration.md index 56283f6..64b6ae0 100644 --- a/templates/enterprise/docs/configuration.md +++ b/templates/enterprise/docs/configuration.md @@ -1,5 +1,20 @@ # Configuration guide +## Image selection + +Set non-secret image selection in `.env`: + +```dotenv +REGISTRY=registry.internal.example.com/workspace +TAG=latest +BASE_IMAGE=ghcr.io/jo-cube/workspace:platform-latest +``` + +The Just commands load this file for both Bake and Compose. Exported variables +take precedence. `just build` builds `${REGISTRY}:${TAG}` from `BASE_IMAGE`; +`just pull` downloads it; `just start` waits for it to be healthy, building only +if it is missing locally. `just up` always rebuilds before starting. + ## CA certificates Place `.crt` files in `config/ca-certificates/`. They are added to the system trust store at build time. diff --git a/templates/enterprise/justfile b/templates/enterprise/justfile index 8fdbc82..353554f 100644 --- a/templates/enterprise/justfile +++ b/templates/enterprise/justfile @@ -1,8 +1,11 @@ -# templates/enterprise/justfile +# Load only this project's optional configuration. +set dotenv-command := "if [ -f .env ]; then cat .env; fi" +set positional-arguments +set shell := ["sh", "-eu", "-c"] -base_image := env("BASE_IMAGE", "ghcr.io/jo-cube/workspace:platform") -registry := env("REGISTRY", "registry.internal.example.com/workspace") -tag := env("TAG", "latest") +export BASE_IMAGE := if env("BASE_IMAGE", "") == "" { "ghcr.io/jo-cube/workspace:platform-latest" } else { env("BASE_IMAGE") } +export REGISTRY := if env("REGISTRY", "") == "" { "registry.internal.example.com/workspace" } else { env("REGISTRY") } +export TAG := if env("TAG", "") == "" { "latest" } else { env("TAG") } # List available commands default: @@ -11,22 +14,23 @@ default: # Build the enterprise overlay image build: set -a; [ ! -f config/proxy.env ] || . ./config/proxy.env; set +a; \ - REGISTRY={{ registry }} TAG={{ tag }} BASE_IMAGE={{ base_image }} docker buildx bake enterprise + docker buildx bake enterprise -# Rebuild and start the workspace -up: - just build - mkdir -p workspace runtime-config secrets - chmod 0777 workspace - REGISTRY={{ registry }} TAG={{ tag }} docker compose up -d --no-build +# Rebuild and wait for a healthy workspace +up: build + just start -# Start the workspace, building only when the local image is missing +# Start the workspace, building only when the selected image is missing start: - @image="{{ registry }}:{{ tag }}"; \ - docker image inspect "$image" >/dev/null 2>&1 || just build; \ + @image="${REGISTRY}:${TAG}"; \ + if ! docker image inspect "$image" >/dev/null 2>&1; then just build; fi; \ mkdir -p workspace runtime-config secrets; \ chmod 0777 workspace; \ - REGISTRY={{ registry }} TAG={{ tag }} docker compose up -d --no-build + docker compose up --wait --wait-timeout 120 --no-build + +# Pull the published enterprise image at TAG without building locally +pull: + docker compose pull workspace # Stop the workspace down: @@ -34,17 +38,34 @@ down: # Open a dev shell in the workspace shell: - docker compose exec --user dev --env HOME=/home/dev --env USER=dev workspace zsh + docker compose exec --user dev --env HOME=/home/dev --env USER=dev --workdir /workspace workspace zsh -l + +# Run a command as dev; preserve arguments, stdin, and exit status +exec +command: + @docker compose exec -T --user dev --env HOME=/home/dev --env USER=dev --workdir /workspace workspace "$@" # Follow logs logs: docker compose logs -f +# Show container state, including Docker health status +status: + docker compose ps + +# Run health checks inside the container +doctor: + docker compose exec -T workspace bash /scripts/doctor.sh + +# Check the proxy and every enabled browser service +health: + @docker compose exec -T workspace /scripts/healthcheck.sh + @echo "workspace healthy" + # Push to enterprise registry push: set -a; [ ! -f config/proxy.env ] || . ./config/proxy.env; set +a; \ - REGISTRY={{ registry }} TAG={{ tag }} BASE_IMAGE={{ base_image }} docker buildx bake enterprise --push + docker buildx bake enterprise --push -# Remove volumes -clean: +# Reset runtime data, keep workspace files and build cache +reset: docker compose down -v diff --git a/tests/test_launcher.py b/tests/test_launcher.py new file mode 100644 index 0000000..41067f3 --- /dev/null +++ b/tests/test_launcher.py @@ -0,0 +1,213 @@ +"""Exercise the public recipes without starting containers or contacting a registry.""" + +import json +import os +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + +FAKE_DOCKER = r'''#!/usr/bin/env python3 +import json +import os +import sys + +args = sys.argv[1:] +record = {"args": args, "env": {name: os.environ.get(name) for name in + ("REGISTRY", "TAG", "FLAVOR", "BASE_IMAGE")}} +if args[:2] == ["compose", "exec"]: + record["stdin"] = sys.stdin.read() + sys.stdout.write(record["stdin"]) +with open(os.environ["TEST_DOCKER_LOG"], "a") as log: + log.write(json.dumps(record) + "\n") +if args[:2] == ["image", "inspect"]: + sys.exit(int(os.environ.get("TEST_IMAGE_MISSING", "0"))) +if args[:2] == ["buildx", "bake"]: + sys.exit(int(os.environ.get("TEST_BUILD_EXIT", "0"))) +if args[:2] == ["compose", "up"]: + sys.exit(int(os.environ.get("TEST_UP_EXIT", "0"))) +if args[:2] == ["compose", "exec"]: + sys.exit(int(os.environ.get("TEST_EXEC_EXIT", "0"))) +''' + + +class LauncherTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="workspace-launcher-") + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.bin = self.root / "bin" + self.bin.mkdir() + docker = self.bin / "docker" + docker.write_text(FAKE_DOCKER) + docker.chmod(0o755) + self.log = self.root / "docker.jsonl" + self.env = {k: v for k, v in os.environ.items() if not k.startswith( + ("JUST_", "COMPOSE_", "TEST_")) and k not in + ("REGISTRY", "TAG", "FLAVOR", "BASE_IMAGE", "WORKSPACE_PORT", + "WORKSPACE_BIND_ADDRESS", "PASSWORD", "HASHED_PASSWORD", + "JUPYTER_TOKEN", "WORKSPACE_CONFIG_FILE", "PUBLISH", "CACHE_FROM")} + self.env["TEST_DOCKER_LOG"] = str(self.log) + + def project(self, enterprise=False, dotenv=""): + path = self.root / ("enterprise" if enterprise else "generic") + path.mkdir(exist_ok=True) + source = ROOT / "templates/enterprise" if enterprise else ROOT + for name in ("justfile", "compose.yaml", "docker-bake.hcl"): + shutil.copy(source / name, path / name) + if enterprise: + (path / "config").mkdir(exist_ok=True) + (path / "config/proxy.env").write_text("") + (path / ".env").write_text(dotenv) + self.log.write_text("") + return path + + def run_just(self, project, *args, env=None, stdin="", real_docker=False): + runtime_env = self.env | (env or {}) + if not real_docker: + runtime_env["PATH"] = str(self.bin) + os.pathsep + self.env["PATH"] + return subprocess.run(["just", "--justfile", str(project / "justfile"), *args], + input=stdin, text=True, capture_output=True, env=runtime_env, check=False) + + def calls(self): + return [json.loads(line) for line in self.log.read_text().splitlines()] + + def assert_success(self, result): + self.assertEqual(result.returncode, 0, result.stderr) + + def test_start_uses_dotenv_image_and_waits_without_rebuilding(self): + for enterprise in (False, True): + with self.subTest(enterprise=enterprise): + project = self.project(enterprise, "REGISTRY=example.invalid/team\nTAG=1.2.3\nFLAVOR=full\n") + self.assert_success(self.run_just(project, "start")) + inspect, up = self.calls() + expected = "example.invalid/team:1.2.3" if enterprise else "example.invalid/team/workspace:full-1.2.3" + self.assertEqual(inspect["args"], ["image", "inspect", expected]) + self.assertEqual(up["args"], ["compose", "up", "--wait", "--wait-timeout", "120", "--no-build"]) + self.assertTrue((project / "workspace").is_dir()) + + def test_missing_dotenv_uses_defaults_without_inheriting_another_project(self): + (self.root / ".env").write_text("REGISTRY=example.invalid/other-project\nTAG=wrong\nFLAVOR=full\n") + for enterprise in (False, True): + with self.subTest(enterprise=enterprise): + project = self.project(enterprise) + (project / ".env").unlink() + self.assert_success(self.run_just(project, "start")) + expected = "registry.internal.example.com/workspace:latest" if enterprise else "ghcr.io/jo-cube/workspace:code-latest" + self.assertEqual(self.calls()[0]["args"][-1], expected) + + def test_empty_selection_values_use_the_same_defaults_as_compose(self): + for enterprise in (False, True): + with self.subTest(enterprise=enterprise): + project = self.project(enterprise, "REGISTRY=\nTAG=\nFLAVOR=\nBASE_IMAGE=\n") + self.assert_success(self.run_just(project, "start")) + expected = "registry.internal.example.com/workspace:latest" if enterprise else "ghcr.io/jo-cube/workspace:code-latest" + self.assertEqual(self.calls()[0]["args"][-1], expected) + compose = self.run_just(project, "--command", "docker", "compose", "config", "--images", real_docker=True) + self.assert_success(compose) + self.assertEqual(compose.stdout.strip(), expected) + + def test_environment_and_explicit_flavor_override_dotenv(self): + project = self.project(dotenv="REGISTRY=example.invalid/file\nTAG=old\nFLAVOR=full\n") + self.assert_success(self.run_just(project, "start", "platform", env={"TAG": "new"})) + inspect, up = self.calls() + self.assertEqual(inspect["args"][-1], "example.invalid/file/workspace:platform-new") + self.assertEqual(up["env"]["FLAVOR"], "platform") + self.assertEqual(up["env"]["TAG"], "new") + + def test_missing_image_is_built_with_the_selected_registry_and_tag(self): + for enterprise in (False, True): + with self.subTest(enterprise=enterprise): + project = self.project(enterprise, "REGISTRY=example.invalid/team\nTAG=dev\nFLAVOR=platform\n") + self.assert_success(self.run_just(project, "start", env={"TEST_IMAGE_MISSING": "1"})) + _inspect, build, up = self.calls() + self.assertEqual(build["args"], ["buildx", "bake", "enterprise" if enterprise else "platform"]) + self.assertEqual(build["env"]["REGISTRY"], "example.invalid/team") + self.assertEqual(build["env"]["TAG"], "dev") + self.assertEqual(up["args"][:2], ["compose", "up"]) + + def test_failed_build_never_starts_or_creates_runtime_directories(self): + for enterprise in (False, True): + for recipe in ("start", "up"): + with self.subTest(enterprise=enterprise, recipe=recipe): + project = self.project(enterprise) + result = self.run_just(project, recipe, env={"TEST_IMAGE_MISSING": "1", "TEST_BUILD_EXIT": "17"}) + self.assertNotEqual(result.returncode, 0) + self.assertFalse(any(c["args"][:2] == ["compose", "up"] for c in self.calls())) + self.assertFalse((project / "workspace").exists()) + + def test_up_rebuilds_even_when_an_image_exists(self): + for enterprise in (False, True): + with self.subTest(enterprise=enterprise): + project = self.project(enterprise) + self.assert_success(self.run_just(project, "up")) + self.assertEqual([c["args"][:2] for c in self.calls()], + [["buildx", "bake"], ["image", "inspect"], ["compose", "up"]]) + + def test_invalid_flavors_never_reach_docker_or_the_shell(self): + project = self.project() + for recipe in ("start", "up", "build", "pull", "push"): + for flavor in ("all", "code-core", "full; touch injected", "$(touch injected)"): + with self.subTest(recipe=recipe, flavor=flavor): + result = self.run_just(project, recipe, flavor) + self.assertNotEqual(result.returncode, 0) + self.assertIn("Unsupported flavor:", result.stderr) + self.assertEqual(self.calls(), []) + self.assertFalse((project / "injected").exists()) + + def test_pull_uses_selection_without_building_or_starting(self): + for enterprise in (False, True): + with self.subTest(enterprise=enterprise): + project = self.project(enterprise, "REGISTRY=example.invalid/team\nTAG=1.2.3\n") + args = ["pull"] if enterprise else ["pull", "full"] + self.assert_success(self.run_just(project, *args)) + call, = self.calls() + self.assertEqual(call["args"], ["compose", "pull", "workspace"]) + self.assertEqual(call["env"]["TAG"], "1.2.3") + if not enterprise: + self.assertEqual(call["env"]["FLAVOR"], "full") + + def test_exec_preserves_arguments_stdin_and_exit_code(self): + for enterprise in (False, True): + with self.subTest(enterprise=enterprise): + project = self.project(enterprise) + args = ["printf", "%s\\n", "two words", "", "'quoted'", "$HOME", "*", + "$(touch injected)", "`touch injected`", "; touch injected"] + result = self.run_just(project, "exec", *args, stdin="first\nsecond\n", env={"TEST_EXEC_EXIT": "23"}) + self.assertEqual(result.returncode, 23, result.stderr) + self.assertEqual(result.stdout, "first\nsecond\n") + call, = self.calls() + self.assertEqual(call["args"], ["compose", "exec", "-T", "--user", "dev", + "--env", "HOME=/home/dev", "--env", "USER=dev", "--workdir", "/workspace", "workspace", *args]) + self.assertFalse((project / "injected").exists()) + + def test_unhealthy_start_and_health_probe_fail(self): + for enterprise in (False, True): + with self.subTest(enterprise=enterprise): + project = self.project(enterprise) + result = self.run_just(project, "start", env={"TEST_UP_EXIT": "1"}) + self.assertNotEqual(result.returncode, 0) + result = self.run_just(project, "health", env={"TEST_EXEC_EXIT": "1"}) + self.assertNotEqual(result.returncode, 0) + self.assertNotIn("workspace healthy", result.stdout) + self.assertEqual(self.calls()[-1]["args"], + ["compose", "exec", "-T", "workspace", "/scripts/healthcheck.sh"]) + + def test_compose_and_bake_resolve_the_same_image(self): + for enterprise in (False, True): + with self.subTest(enterprise=enterprise): + project = self.project(enterprise, "REGISTRY=example.invalid/team\nTAG=1.2.3\nFLAVOR=full\n") + compose = self.run_just(project, "--command", "docker", "compose", "config", "--images", real_docker=True) + bake = self.run_just(project, "--command", "docker", "buildx", "bake", "--print", + "enterprise" if enterprise else "full", real_docker=True) + self.assert_success(compose) + self.assert_success(bake) + target = json.loads(bake.stdout)["target"]["enterprise" if enterprise else "full"] + self.assertEqual(target["tags"], [compose.stdout.strip()]) + + +if __name__ == "__main__": + unittest.main()