Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -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
40 changes: 40 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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

Expand All @@ -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'
Expand Down Expand Up @@ -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 }}
108 changes: 108 additions & 0 deletions .github/workflows/container-release.yml
Original file line number Diff line number Diff line change
@@ -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 <http://localhost:8000>.

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.
86 changes: 70 additions & 16 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,49 +1,103 @@
# 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/ ./
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"
Loading