-
Notifications
You must be signed in to change notification settings - Fork 4
Ship projectMM as a container, and give each instance an identity #98
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
b94a833
3ac229a
3727e63
b8bbf95
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,182 @@ | ||
| # TEMPORARY: a full end-to-end rehearsal of a release, as a PRERELEASE. | ||
| # | ||
| # Why this exists. The real release workflow cannot be rehearsed from a branch: when its tag | ||
| # resolves to `latest` it force-pushes that tag and deletes the `latest` release, which the web | ||
| # installer and the OTA update badge both read. Running it from a feature branch would hand every | ||
| # updating user a branch build. | ||
| # | ||
| # So this does the whole thing under a different name: build the Linux desktop package, build a | ||
| # container image from that exact .deb, push the image, pull it back and prove it serves, then | ||
| # attach the artifacts to a PRERELEASE. Publicly visible, so the person who asked for the container | ||
| # can test it, and reachable by no device: the stable update check reads `/releases/latest` (newest | ||
| # non-prerelease) and the dev channel reads `/releases/tags/latest` (a specific tag), so neither | ||
| # sees a prerelease under its own tag. Delete it when you are done. | ||
| # | ||
| # DELETE THIS FILE once release.yml's publish-container job has run for real on main. It exists to | ||
| # de-risk that first run, not to become a second way of releasing. | ||
| # | ||
| # It never touches the `latest` tag or any vX.Y.Z tag. | ||
| name: container test (temporary, prerelease) | ||
|
|
||
| # PUSH on this one branch, plus manual. The push trigger is what makes it runnable at all: a | ||
| # `workflow_dispatch` workflow is addressable only once its file sits on the DEFAULT branch, because | ||
| # GitHub resolves the name you dispatch against main, so dispatching this from its own branch fails | ||
| # with a 404 and no Run button appears. A push trigger has no such requirement, and this is how | ||
| # `moonbase-test-release.yml` was run for the MoonBase test releases. | ||
| # | ||
| # Scoped to `docker-container` so it cannot follow the code onto main: this file is deleted once | ||
| # release.yml's publish-container job has run for real, and until then the branch name IS the guard. | ||
| # `workflow_dispatch` stays for a re-run without an empty commit, and starts working once main has | ||
| # the file. | ||
| on: | ||
| push: | ||
| branches: | ||
| - docker-container | ||
| workflow_dispatch: | ||
|
|
||
| jobs: | ||
| container-test: | ||
| runs-on: ubuntu-latest | ||
| permissions: | ||
| contents: write # the prerelease and its own `container-test` tag; it moves no other | ||
| packages: write # push the image to ghcr.io | ||
| steps: | ||
| - uses: actions/checkout@v4 | ||
| with: | ||
| persist-credentials: false | ||
| # compute_version.py counts commits since the last v* tag, as release.yml does. | ||
| fetch-depth: 0 | ||
|
|
||
| - uses: astral-sh/setup-uv@v3 | ||
|
|
||
| # The same packaging step release.yml's build-linux runs, so the .deb under test is built the | ||
| # way a released one is rather than by a path that exists only here. | ||
| # | ||
| # `--tag latest`, NOT `--tag container-test`: compute_version maps `latest` to the rolling | ||
| # prerelease channel and treats every other tag as stable, carrying it through as the version | ||
| # verbatim. So a made-up tag becomes a made-up version, and dpkg-deb rejects it outright, since | ||
| # a Debian version must start with a digit. `latest` yields the real `<core>-dev.<N>` a rolling | ||
| # release builds, which is also the truer rehearsal. What isolates this run is the IMAGE tag | ||
| # and the RELEASE tag, both `container-test` and both set below. | ||
| - name: Build + package Linux x64 | ||
| id: pkg | ||
| run: | | ||
| set -euo pipefail | ||
| V=$(uv run python moondeck/build/compute_version.py --tag latest) | ||
| echo "version=$V" >> "$GITHUB_OUTPUT" | ||
| uv run moondeck/ci/package_desktop.py --version "$V" | ||
| ls -la dist/ | ||
|
|
||
| - uses: docker/setup-buildx-action@v3 | ||
|
|
||
| - uses: docker/login-action@v3 | ||
| with: | ||
| registry: ghcr.io | ||
| username: ${{ github.actor }} | ||
| password: ${{ secrets.GITHUB_TOKEN }} | ||
|
|
||
| # Lowercase, because a registry path must be and the org is not. | ||
| - name: Resolve image name | ||
| id: img | ||
| env: | ||
| REPO: ${{ github.repository }} | ||
| run: | | ||
| set -euo pipefail | ||
| echo "name=ghcr.io/$(echo "$REPO" | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT" | ||
|
|
||
| # Byte-for-byte the Dockerfile release.yml writes: a rehearsal of a different file would | ||
| # prove nothing about the one that ships. | ||
| - name: Write the release Dockerfile | ||
| run: | | ||
| set -euo pipefail | ||
| deb=$(ls dist/projectmm_*_amd64.deb | head -1) | ||
| test -n "$deb" | ||
| mkdir -p ctx && cp "$deb" ctx/projectmm.deb | ||
| cat > ctx/Dockerfile <<'DOCKERFILE' | ||
| FROM debian:trixie-slim AS fetch | ||
| COPY projectmm.deb /tmp/projectmm.deb | ||
| RUN dpkg-deb -x /tmp/projectmm.deb /rootfs | ||
| FROM gcr.io/distroless/cc-debian13 | ||
| COPY --from=fetch /rootfs/usr/bin/projectMM /usr/bin/projectMM | ||
| ENV XDG_DATA_HOME=/data | ||
| VOLUME /data | ||
| EXPOSE 8080 | ||
| ENTRYPOINT ["/usr/bin/projectMM"] | ||
| DOCKERFILE | ||
|
|
||
| - uses: docker/build-push-action@v6 | ||
| with: | ||
| context: ctx | ||
| platforms: linux/amd64 | ||
| push: true | ||
| # `container-test` only: never `latest`, never a version tag. This image is a rehearsal | ||
| # artifact and must not be mistaken for a release. | ||
| tags: ${{ steps.img.outputs.name }}:container-test | ||
| labels: | | ||
| org.opencontainers.image.source=https://github.com/${{ github.repository }} | ||
| org.opencontainers.image.description=TEMPORARY container-publishing rehearsal, not a release | ||
|
|
||
| # The point of the whole workflow: pull what was pushed and prove it SERVES, rather than | ||
| # trusting that a successful push means a working image. | ||
| - name: Pull it back and check it answers | ||
| id: verify | ||
| run: | | ||
| set -euo pipefail | ||
| img="${{ steps.img.outputs.name }}:container-test" | ||
| docker rmi "$img" 2>/dev/null || true # force a real pull, not the local cache | ||
| docker pull "$img" | ||
| docker run -d --name mmtest -p 8080:8080 -v mmtest-data:/data "$img" | ||
| ok="" | ||
| for i in $(seq 1 30); do | ||
| if curl -fsS -m 3 http://localhost:8080/api/modules/System >/dev/null 2>&1; then | ||
| ok="yes"; echo "UI answered after ~${i}s"; break | ||
| fi | ||
| sleep 1 | ||
| done | ||
| test -n "$ok" || { echo "the image never served"; docker logs mmtest; exit 1; } | ||
| name=$(curl -fsS -m 5 http://localhost:8080/api/modules/System \ | ||
| | python3 -c 'import sys,json; d=json.load(sys.stdin); v={c["name"]:c.get("value") for c in d["controls"]}; print(v.get("deviceName"), v.get("mac"))') | ||
| echo " identity: $name" | ||
| echo "identity=$name" >> "$GITHUB_OUTPUT" | ||
| # The identity must have been generated and stored: that is what makes an instance | ||
| # distinguishable and what survives an upgrade. | ||
| docker run --rm -v mmtest-data:/data alpine cat /data/projectMM/.config/identity | ||
| docker rm -f mmtest | ||
|
|
||
| # A PRERELEASE carrying the same assets a real one would, so the whole path is exercised and | ||
| # anyone can try it: a draft would be invisible to people without write access, and the point | ||
| # of this rehearsal is that the person who asked for the container can test it too. | ||
| # | ||
| # Safe because of WHAT the update checks read. The stable channel fetches | ||
| # `/releases/latest`, which the GitHub API defines as the newest NON-prerelease, and the dev | ||
| # channel fetches `/releases/tags/latest`, a specific tag. Neither enumerates releases, so a | ||
| # prerelease under its own tag reaches no device. It does appear on the Releases page, which | ||
| # is the visibility we want. | ||
| - name: Prerelease with the artifacts | ||
| uses: softprops/action-gh-release@v2 | ||
| with: | ||
| tag_name: container-test | ||
| name: "container test ${{ steps.pkg.outputs.version }} (rehearsal, delete me)" | ||
| draft: false | ||
| prerelease: true | ||
| make_latest: "false" | ||
| fail_on_unmatched_files: true | ||
| files: | | ||
| dist/projectMM-linux-x64-*.tar.gz | ||
| dist/projectmm_*_amd64.deb | ||
| body: | | ||
| **Rehearsal, not a release.** Produced by `.github/workflows/container-test.yml` to | ||
| prove the container path before it ships in `release.yml`. It is marked a prerelease so | ||
| no device update check reaches it. Delete it when done. | ||
|
|
||
| The image is public, so anyone can run it without a GitHub login. | ||
|
|
||
| Container image, pulled back and verified serving in the same run: | ||
|
|
||
| ``` | ||
| docker run -d -p 8081:8080 -v projectmm:/data \ | ||
| ${{ steps.img.outputs.name }}:container-test | ||
| ``` | ||
|
|
||
| Reported identity on first start: `${{ steps.verify.outputs.identity }}` | ||
| (generated and stored in the volume, so it survives an upgrade). |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,85 @@ | ||
| # projectMM as a container: the desktop firmware, which is the whole system without an ESP32. | ||
| # | ||
| # The desktop build is not a simulator. It runs the same effect pipeline, the same web UI and the | ||
| # same driver stack as a board, and it drives real fixtures over Art-Net, DDP and E1.31, so a | ||
| # container is a complete live installation for anyone whose fixtures are on the network. | ||
| # | ||
| # It INSTALLS the released .deb rather than building from source, deliberately. The release already | ||
| # produces that package; building here would be a second build path to keep working, and the two | ||
| # would drift. The image is packaging, not a build. | ||
| # | ||
| # docker build -t projectmm . # the rolling prerelease (default) | ||
| # docker build --build-arg RELEASE=stable -t projectmm . # the newest stable release | ||
| # docker build --build-arg RELEASE=v4.0.0 -t projectmm . # a specific tag | ||
| # docker run --rm -p 8080:8080 -v projectmm:/data projectmm | ||
| # | ||
| # Then open http://localhost:8080/. | ||
| # | ||
| # **Ports.** 8080 is the web UI, and the only port needed to try it out. Driving fixtures is | ||
| # OUTBOUND: Art-Net on UDP 6454, DDP on 4048, E1.31/sACN on 5568. Those are L3 and reach a unicast | ||
| # fixture address through ordinary bridge networking. | ||
| # | ||
| # **When L2 matters.** mDNS discovery (finding boards, being found by them) is multicast and does | ||
| # not cross a bridge network, and Art-Net's broadcast mode has the same problem. For those, attach | ||
| # the container to the host's network directly (`--network host`, or an L2 CNI on Kubernetes). | ||
| # Unicast output needs none of it. NOT verified on a Linux host yet: on macOS and Windows, Docker | ||
| # Desktop runs a Linux VM, so `--network host` joins the VM rather than the machine's LAN and the | ||
| # question cannot be answered there. | ||
| # | ||
| # **Capabilities.** None. It binds 8080 as an ordinary process and needs no added capability. | ||
| # | ||
| # **amd64 only.** The release ships no arm64 LINUX binary (macOS arm64 is a different target), so | ||
| # an arm64 image needs an arm64 build in the release pipeline first, not a change here. | ||
|
|
||
| # --- stage 1: fetch the release and unpack it ------------------------------------------------- | ||
| # A full Debian image, used only to resolve and extract the .deb. None of it reaches the result. | ||
| FROM debian:trixie-slim AS fetch | ||
|
|
||
| # WHICH release to install, and the default is the ROLLING PRERELEASE, matching what the installer | ||
| # page offers rather than the last tagged version: projectMM ships from `main` continuously, so a | ||
| # tagged release can be months behind what a board would be flashed with, and an image that lagged | ||
| # the firmware would be the wrong thing to test against. | ||
| # | ||
| # `latest` here is a real git TAG carrying that rolling build, not GitHub's "latest release" idea. | ||
| # `stable` is the special value asking for GitHub's newest NON-prerelease, and anything else is | ||
| # taken as a literal tag. The two words genuinely differ, which is why both exist. | ||
| ARG RELEASE=latest | ||
| ARG REPO=MoonModules/projectMM | ||
|
|
||
| RUN apt-get update \ | ||
| && apt-get install -y --no-install-recommends ca-certificates curl \ | ||
| && if [ "$RELEASE" = "stable" ]; then \ | ||
| api="https://api.github.com/repos/${REPO}/releases/latest"; \ | ||
| else \ | ||
| api="https://api.github.com/repos/${REPO}/releases/tags/${RELEASE}"; \ | ||
| fi \ | ||
| && url=$(curl -fsSL "$api" | grep -o 'https://[^"]*_amd64\.deb' | head -1) \ | ||
| && test -n "$url" || { echo "no amd64 .deb in release ${RELEASE}" >&2; exit 1; } \ | ||
| && curl -fsSL -o /tmp/projectmm.deb "$url" \ | ||
| && dpkg-deb -x /tmp/projectmm.deb /rootfs | ||
|
|
||
| # --- stage 2: the image that ships ------------------------------------------------------------ | ||
| # Distroless: the binary plus its four shared libraries, with no shell and no package manager, so | ||
| # the attack surface is the application rather than a distribution. `ldd` on the release binary | ||
| # lists exactly libstdc++, libm, libgcc_s and libc, which is the whole reason this fits: nothing | ||
| # else has to come along. 45 MB against 140 MB for the full-Debian form. | ||
| # | ||
| # **debian13, NOT debian12**, and this is load-bearing. The release is built on ubuntu-24.04 | ||
| # (glibc 2.39), so the binary requires glibc >= 2.38. The debian12/bookworm images ship 2.36, where | ||
| # it installs cleanly and then dies at startup with "GLIBC_2.38 not found" from libc and libm. | ||
| # Verified both ways on the bench. If the release ever moves to an older builder, this can too. | ||
| FROM gcr.io/distroless/cc-debian13 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win 🔎 Supported by static analysis🤖 get_repo_knowledge executed:
Length of output: 2947 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- Dockerfile runtime section ---'
sed -n '55,85p' Dockerfile
printf '%s\n' '--- release workflow template ---'
sed -n '635,680p' .github/workflows/release.yml
printf '%s\n' '--- container-test workflow template ---'
sed -n '60,100p' .github/workflows/container-test.yml
printf '%s\n' '--- data-path and runtime-user references ---'
rg -n --glob 'Dockerfile' --glob '*.yml' --glob '*.yaml' --glob '*.sh' --glob '*.js' --glob '*.ts' '(/data|USER |useradd|adduser|chown|projectMM)' .Repository: MoonModules/projectMM Length of output: 15858 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- files ---'
git ls-files Dockerfile .github/workflows/release.yml .github/workflows/container-test.yml
printf '%s\n' '--- Dockerfile ---'
cat -n Dockerfile | sed -n '60,80p'
printf '%s\n' '--- release ---'
cat -n .github/workflows/release.yml | sed -n '648,675p'
printf '%s\n' '--- container-test ---'
cat -n .github/workflows/container-test.yml | sed -n '70,95p'Repository: MoonModules/projectMM Length of output: 4594 🌐 Web query:
💡 Result: The official Distroless container image for C/C++ applications based on Debian 13 is identified by the repository gcr.io/distroless/cc-debian13 [1][2][3]. To use the version configured for a non-root user, you should use the nonroot tag: gcr.io/distroless/cc-debian13:nonroot [1][2][3]. Because container image tags are mutable and point to different digests as updates are pushed to the registry, there is no single static digest for the nonroot tag. To obtain the current, immutable digest for this image, you must query the Google Container Registry directly using the crane tool or Docker CLI [4]. You can retrieve it by running the following command: docker buildx imagetools inspect gcr.io/distroless/cc-debian13:nonroot --format '{{json.Manifest.Digest}}' Alternatively, if you have crane installed, you can use: crane digest gcr.io/distroless/cc-debian13:nonroot These commands will return the specific SHA256 digest currently associated with that tag in the registry [1][3]. Citations:
Use a pinned non-root runtime image in all three templates. The three runtime stages use the mutable 🧰 Tools🪛 Checkov (3.3.11)[low] 71-71: Ensure the base image uses a non latest version tag (CKV_DOCKER_7) [low] 1-85: Ensure that HEALTHCHECK instructions have been added to container images (CKV_DOCKER_2) [low] 1-85: Ensure that a user for the container has been created (CKV_DOCKER_3) 🪛 Hadolint (2.15.1)[warning] 71-71: Always tag the version of an image explicitly (DL3006) 🪛 Trivy (0.74.0)[warning] 71-71: ':latest' tag used Specify a tag in the 'FROM' statement for image 'gcr.io/distroless/cc-debian13' Rule: DS-0001 (IaC/Dockerfile) 📍 Affects 3 files
🤖 Prompt for AI AgentsSource: Linters/SAST tools |
||
|
|
||
| COPY --from=fetch /rootfs/usr/bin/projectMM /usr/bin/projectMM | ||
|
|
||
| # WHERE THE CONFIG LIVES, and why this line is required rather than a convenience. The desktop | ||
| # build resolves its data directory from the environment (platform_desktop.cpp, userDataDir): on | ||
| # Linux XDG_DATA_HOME first, then HOME/.local/share. A container has NEITHER, and the function then | ||
| # returns empty, so without this the app has nowhere defined to write. Setting it explicitly also | ||
| # gives the volume one documented path instead of a guess: config lands in /data/projectMM/.config. | ||
| ENV XDG_DATA_HOME=/data | ||
| VOLUME /data | ||
|
|
||
| EXPOSE 8080 | ||
|
|
||
| ENTRYPOINT ["/usr/bin/projectMM"] | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
🔎 Supported by static analysis
🤖 get_repo_knowledge executed:
get_repo_knowledge MoonModules/projectMM /tmp/coderabbit-repo-knowledge/moonmodules-projectmm-df788503Length of output: 2931
🏁 Script executed:
Repository: MoonModules/projectMM
Length of output: 19687
🏁 Script executed:
Repository: MoonModules/projectMM
Length of output: 19536
🏁 Script executed:
Repository: MoonModules/projectMM
Length of output: 12658
Pin the listed GitHub Actions to reviewed full commit SHAs.
These actions run in jobs with
packages: write;container-test.ymlalso hascontents: write. Mutable version tags can therefore introduce changed action code into publishing workflows. Apply this to the five references in.github/workflows/release.ymland the six references in.github/workflows/container-test.yml.🧰 Tools
🪛 zizmor (1.29.0)
[error] 613-613: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)
(unpinned-uses)
📍 Affects 2 files
.github/workflows/release.yml#L613-L613(this comment).github/workflows/release.yml#L618-L618.github/workflows/release.yml#L623-L623.github/workflows/release.yml#L625-L625.github/workflows/release.yml#L668-L668.github/workflows/container-test.yml#L31-L31.github/workflows/container-test.yml#L37-L37.github/workflows/container-test.yml#L50-L50.github/workflows/container-test.yml#L52-L52.github/workflows/container-test.yml#L87-L87.github/workflows/container-test.yml#L136-L136🤖 Prompt for AI Agents
Source: Linters/SAST tools