diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c4e1e96 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,19 @@ +# The image only needs backend sources and the frontend source tree. Without +# this file the whole repo — including local node_modules, labs/ and .git — +# is uploaded as build context and `COPY frontend/ ./` clobbers the +# container's freshly installed node_modules with the host's. +.git +.github +# A developer's local .npmrc holds a real token — keep it out of the build +# context entirely; the Dockerfile writes its own from the BuildKit secret. +**/.npmrc +**/node_modules +**/dist +**/__pycache__ +**/*.pyc +.venv +labs +desktop +docs +**/.pytest_cache +**/.ruff_cache diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 975a45c..3f11b8d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,7 @@ jobs: outputs: backend: ${{ steps.filter.outputs.backend }} frontend: ${{ steps.filter.outputs.frontend }} + docker: ${{ steps.filter.outputs.docker }} steps: - uses: actions/checkout@v4 with: @@ -35,6 +36,7 @@ jobs: # Manual runs have no diff to inspect — exercise everything. echo "backend=true" >> "$GITHUB_OUTPUT" echo "frontend=true" >> "$GITHUB_OUTPUT" + echo "docker=true" >> "$GITHUB_OUTPUT" exit 0 fi @@ -56,6 +58,14 @@ jobs: echo "frontend=false" >> "$GITHUB_OUTPUT" fi + # The release image is only ever built on a tag push, so without this + # a broken Dockerfile stays invisible until release day. + if match '^(Dockerfile|\.dockerignore|\.github/workflows/(ci|container-release)\.yml)'; then + echo "docker=true" >> "$GITHUB_OUTPUT" + else + echo "docker=false" >> "$GITHUB_OUTPUT" + fi + backend: needs: changes if: needs.changes.outputs.backend == 'true' @@ -120,3 +130,33 @@ jobs: run: npm run build env: NODE_OPTIONS: --max_old_space_size=4096 + + # Builds the release image exactly as container-release.yml would, minus the + # push — so Dockerfile regressions surface on the PR instead of on a tag. + docker: + needs: changes + # Fork PRs cannot read GH_PACKAGES_TOKEN, and `npm ci` for clab-ui needs it, + # so the job would always fail there. Skip rather than show a false red. + if: >- + needs.changes.outputs.docker == 'true' && + (github.event_name != 'pull_request' || + github.event.pull_request.head.repo.full_name == github.repository) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/build-push-action@v6 + with: + context: . + push: false + # Single arch on PRs: this is a "does it build" gate, and emulating + # arm64 here would roughly double the run for no extra signal. + platforms: linux/amd64 + # Shares the cache the release build populates, so tagging later is + # mostly cache hits. + cache-from: type=gha + cache-to: type=gha,mode=max + secrets: | + github_token=${{ secrets.GH_PACKAGES_TOKEN }} diff --git a/.github/workflows/container-release.yml b/.github/workflows/container-release.yml new file mode 100644 index 0000000..538d969 --- /dev/null +++ b/.github/workflows/container-release.yml @@ -0,0 +1,108 @@ +name: Publish Container Image + +# `desktop-v*` tags are handled by desktop-release.yml. Regular semver tags +# produce the all-in-one web application image from the root Dockerfile. +on: + push: + tags: + - "v*.*.*" + +permissions: + contents: write # the release job creates the GitHub Release for the tag + packages: write + +concurrency: + group: container-release-${{ github.ref }} + cancel-in-progress: false + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # The registry config now lives in the Dockerfile so local and CI builds + # take the same path; all this job supplies is the token itself, as a + # BuildKit secret. GitHub Packages requires auth to read npm packages + # even when they are public, which is why clab-ui needs a token at all. + - uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - id: metadata + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + + - uses: docker/build-push-action@v6 + with: + context: . + # arm64 matters for Apple Silicon and arm-based lab hosts. Only the + # Python stage is emulated — the frontend stage builds natively. + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.metadata.outputs.tags }} + labels: ${{ steps.metadata.outputs.labels }} + provenance: mode=max + sbom: true + cache-from: type=gha + cache-to: type=gha,mode=max + secrets: | + github_token=${{ secrets.GH_PACKAGES_TOKEN }} + + release: + needs: publish + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # The GitHub Release is the landing page people reach from the tag, so + # it leads with the pull command rather than only a source zip. + - uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true + files: | + docker-compose.yml + Dockerfile + body: | + ## Run it + + ```bash + docker run -d --name netlab-ui \ + -p 8000:8000 \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v "$PWD/labs:/work" \ + ghcr.io/${{ github.repository }}:${{ github.ref_name }} + ``` + + Then open . + + Images: `linux/amd64`, `linux/arm64`. Also tagged `latest`. + + ### Scope + + This image is the **UI only**. `netlab`, Ansible and containerlab + stay on your host — run it on a machine that already has netlab + set up, and point Settings → Environment at that install (or set + `NETLAB_BIN`). + + Features that call containerlab directly — link impairment, + orphaned-instance cleanup, post-start link reconcile — need the + binary visible inside the container. It is a static Go binary, so + add `-v /usr/bin/containerlab:/usr/bin/containerlab:ro` to enable + them. Everything else works without it. diff --git a/Dockerfile b/Dockerfile index ed67c66..9f0a19c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,19 +1,40 @@ -# Single-container "web app" image: builds the frontend, then serves the -# built static assets straight from the FastAPI backend on one port. This is -# the containerized alternative to the two-process dev setup in -# docker-compose.yml — one image, `docker run`, open a browser. +# Single-container "web app" image: builds the frontend, then serves the built +# static assets straight from the FastAPI backend on one port. This is the +# containerized alternative to the two-process dev setup in docker-compose.yml +# — one image, `docker run`, open a browser. # -# @srl-labs/clab-ui is a private GitHub Packages dependency, so the frontend -# build stage needs a token with `read:packages` passed as a BuildKit secret -# (never baked into a layer as a build ARG): +# Scope: this is the *UI*, not a netlab distribution. netlab, Ansible and +# containerlab all stay on the host, where whoever runs this already has them. +# The container talks to that host install through NETLAB_BIN / Settings → +# Environment and the mounted Docker socket. Keeping the toolchain out means +# no version skew against the netlab the user actually runs, and it is what +# lets this ship as a netlab *tool* rather than a parallel install of one. +# +# @srl-labs/clab-ui (Apache-2.0) is published to GitHub Packages, whose npm +# registry requires authentication for every read — even for public packages. +# So the frontend build stage needs a token with `read:packages`, passed as a +# BuildKit secret (never baked into a layer as a build ARG): # # DOCKER_BUILDKIT=1 docker build --secret id=github_token,env=GITHUB_TOKEN -t netlab-ui . # ---- frontend build stage ---- -FROM node:24-slim AS frontend-build +# Pinned to the *build* platform: the frontend output is arch-independent, so +# building it under QEMU for the arm64 variant would cost minutes for nothing. +FROM --platform=$BUILDPLATFORM node:24-slim AS frontend-build WORKDIR /app COPY frontend/package.json frontend/package-lock.json ./ -COPY frontend/.npmrc ./ +# Generated here rather than copied from the build context: the token normally +# lives in the developer's ~/.npmrc, which Docker cannot see. Only the +# *variable name* is written to the layer — the value arrives from the secret +# mount at `npm ci` time and is never baked in. +RUN printf '%s\n' \ + '@srl-labs:registry=https://npm.pkg.github.com' \ + '//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}' \ + > .npmrc +# patches/ must land before `npm ci`: the postinstall hook runs patch-package, +# and with no patches directory it silently no-ops — shipping an unpatched +# clab-ui (wrong palette tab order) instead of failing the build. +COPY frontend/patches ./patches RUN --mount=type=secret,id=github_token \ NODE_AUTH_TOKEN="$(cat /run/secrets/github_token)" npm ci COPY frontend/ ./ @@ -21,29 +42,62 @@ RUN npm run build # ---- backend runtime stage ---- FROM python:3.11-slim -RUN apt-get update && apt-get install -y git docker.io curl && rm -rf /var/lib/apt/lists/* +# git — lab file history/diffs (app/lab/files.py) and the version string in +# app/main.py. docker.io — the backend enumerates and cleans up lab containers +# through the Docker CLI. curl is gone with the containerlab installer that +# was its only user. +RUN apt-get update && apt-get install -y --no-install-recommends git docker.io \ + && rm -rf /var/lib/apt/lists/* WORKDIR /app COPY backend/pyproject.toml ./ COPY backend/app ./app COPY backend/services ./services -RUN pip install --no-cache-dir . +# `[assistant]` ships the AI assistant's SDKs, so the API-key providers +# (OpenAI, Gemini, OpenAI-compatible) work in the container once the user +# supplies a key. The feature still stays off until NETLAB_APP_ASSISTANT is +# set — this means "available without rebuilding", not "on by default". +# +# The CLI-driven providers (Claude Code, Codex, Antigravity) are a different +# story: they drive an already-logged-in CLI on the host, so they stay +# unavailable here unless that CLI and its credentials are mounted in. The +# provider probes report exactly that in Settings, so nothing breaks silently. +RUN pip install --no-cache-dir ".[assistant]" # netlab and Ansible intentionally are not installed in this UI image. Point # Settings → Environment at an existing/mounted netlab executable, or set # NETLAB_BIN. The backend probes that executable's Python environment for # netsim metadata, so it does not need a second netlab installation of its own. -# containerlab — netlab's default provider — so `netlab up`, status and shell -# actions work out of the box. libvirt-based providers are still not included. -RUN bash -c "$(curl -sL https://get.containerlab.dev)" +# containerlab is deliberately absent too. It is the host's job: this image is +# aimed at a machine that already runs netlab, and netlab already brings its +# own containerlab. Shipping a second copy only invites version skew between +# the one netlab drives and the one the backend would call. +# +# A few backend features shell out to `containerlab` directly rather than +# through netlab — link impairment (`tools netem set`), orphaned-instance +# cleanup (`destroy --cleanup`) and post-start link reconcile (`apply`). They +# need the binary on this container's PATH, so bind-mount the host's copy to +# enable them: +# +# -v /usr/bin/containerlab:/usr/bin/containerlab:ro +# +# It is a static Go binary, so that mount works with no further plumbing. +# Without it those three features report containerlab as unavailable in +# Settings → Environment; nothing else is affected. COPY --from=frontend-build /app/dist ./frontend_dist ENV FRONTEND_DIST_DIR=/app/frontend_dist -# NOTE: containerlab drives the host's Docker daemon to create lab nodes, so -# the container must be run with access to a Docker daemon — mount the socket +# NOTE: the backend inspects lab containers through the Docker CLI, so the +# container needs access to a Docker daemon — mount the socket # (`-v /var/run/docker.sock:/var/run/docker.sock`) or run with `--privileged`. EXPOSE 8000 CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] + +# Populates the GHCR package page (README, repo link, license) instead of the +# "no description" placeholder a pulled-from-nowhere image would show. +LABEL org.opencontainers.image.source="https://github.com/Muddyblack/netlab-ui" \ + org.opencontainers.image.description="Web UI for netlab — topology editor, lab lifecycle and device consoles in one container." \ + org.opencontainers.image.licenses="Apache-2.0"