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
16 changes: 16 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,22 @@ jobs:
- run: |
pytest

launcher:
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Install command fails closed on a bad checksum
env:
REQUIRE_HTTP_FIXTURE: "1"
run: test/launcher/test-install-command.sh
- name: Release stamp pins the launcher image
run: test/launcher/test-stamp-launcher-image.sh
- name: Release assets are built in the right order
run: test/launcher/test-build-launcher-release.sh

frontend:
runs-on: ubuntu-24.04
timeout-minutes: 10
Expand Down
42 changes: 41 additions & 1 deletion .github/workflows/publish-image.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
name: publish-image
# Build the dmf-init container image (amd64+arm64) and push to GHCR per release
# (v* tag) or manual dispatch. Public repo -> free hosted runners; the built-in
# (v* tag) or manual dispatch. For a v* tag, a second job then publishes the
# launcher and its SHA-256 checksum as Release assets, pinned to the digest of
# the image that this run pushed. Public repo -> free hosted runners; the built-in
# GITHUB_TOKEN publishes to ghcr.io/dmfdeploy/dmf-init (no PAT). The image fetches
# the component repos at RUNTIME, so it needs rebuilding only when dmf-init is
# released, not when playbooks change.
Expand All @@ -20,6 +22,11 @@ concurrency:
jobs:
build-push:
runs-on: ubuntu-24.04
permissions:
contents: read
packages: write
outputs:
digest: ${{ steps.build.outputs.digest }}
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
Expand All @@ -39,6 +46,7 @@ jobs:
type=semver,pattern={{version}}
type=raw,value=latest
- name: Build and push (amd64 + arm64)
id: build
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
with:
context: .
Expand All @@ -48,3 +56,35 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

# Runs only after the image was built and pushed. A failed or skipped image
# job means no launcher assets, so a published launcher always has its image.
launcher:
needs: build-push
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions:
contents: write
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Build the launcher and checksum
env:
TAG: ${{ github.ref_name }}
DIGEST: ${{ needs.build-push.outputs.digest }}
run: |
set -euo pipefail
[[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "tag must look like v1.2.3, got: $TAG" >&2; exit 1; }
[[ "$DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]] || { echo "no image digest from the build job: $DIGEST" >&2; exit 1; }
# The image job tags semver releases without the leading "v".
scripts/build-launcher-release.sh "ghcr.io/dmfdeploy/dmf-init:${TAG#v}@${DIGEST}" release
grep -n '^DEFAULT_IMAGE=' release/dmf-init
cat release/SHA256SUMS
- name: Attach to Release
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
with:
files: |
release/dmf-init
release/SHA256SUMS
62 changes: 51 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,19 +60,59 @@ To get started, the `dmf-init` launcher wraps Docker for you; it
starts the container with the correct flags, waits until it's ready, extracts the
one-time launch link, and opens your browser. No clone required:

Read the launcher before you run it. Download it, read it, then run it:

```bash
curl -fsSL https://raw.githubusercontent.com/dmfdeploy/dmf-init/main/bin/dmf-init -o dmf-init
less dmf-init
bash dmf-init up
```

If you trust the source, you can pipe the launcher into your shell instead. This runs remote code without a chance to read it first:

From v0.4.0 on, each release publishes the launcher together with a SHA-256
checksum file, after the release's container image is built and pushed. Earlier
releases have neither asset. The command below downloads both for one tagged
release, checks the launcher against the checksum, and runs it only if the check
passes. If a download fails, or the checksum file is missing, malformed, or
doesn't match, the command stops before it runs anything.

The command does the following, in order:

1. Creates a new empty temporary directory and changes to it, so a file that
already exists in your current directory can never run or be overwritten. If
the directory can't be created, the command stops before it downloads
anything.
2. Downloads `dmf-init` and `SHA256SUMS` with one `curl` call each, so a failed
download stops the command.
3. Requires `SHA256SUMS` to hold exactly one line: a 64-character lowercase hex
digest, two spaces, and the name `dmf-init`.
4. Computes the digest of `dmf-init` with `sha256sum` (Linux) or `shasum`
(macOS), and compares it with the digest from `SHA256SUMS`.
5. Runs `bash dmf-init up` only if the digests are identical.

<!-- install-command:start -->
```bash
curl -fsSL https://raw.githubusercontent.com/dmfdeploy/dmf-init/main/bin/dmf-init | bash -s -- up
version=v0.4.0
base="https://github.com/dmfdeploy/dmf-init/releases/download/${version}"

dir="$(mktemp -d)" &&
[ -n "${dir}" ] &&
[ -d "${dir}" ] &&
cd "${dir}" &&
curl -fsSL -o dmf-init "${base}/dmf-init" &&
curl -fsSL -o SHA256SUMS "${base}/SHA256SUMS" &&
[ "$(grep -c '' SHA256SUMS)" -eq 1 ] &&
expected="$(sed -n -E 's/^([0-9a-f]{64}) dmf-init$/\1/p' SHA256SUMS)" &&
[ -n "${expected}" ] &&
actual="$({ sha256sum dmf-init 2>/dev/null || shasum -a 256 dmf-init; } | cut -d ' ' -f 1)" &&
[ "${actual}" = "${expected}" ] &&
bash dmf-init up
```
<!-- install-command:end -->

The command saves `dmf-init` and `SHA256SUMS` in the new directory and leaves
your shell there. The published launcher starts the container image that was
built for the same release, pinned by digest. To use a different image, pass
`--image`.

To choose a version, set `version` to a tag from the
[releases page](https://github.com/dmfdeploy/dmf-init/releases) that lists both
`dmf-init` and `SHA256SUMS` as assets. Use a tag, never `main`. A checksum from
the same release catches a corrupted or partial download. It doesn't prove who
published the release, so read the launcher first if you want to review it. To
read it before you run it, replace the last line with `less dmf-init`, then run
`bash dmf-init up` yourself.

From a clone, run `bin/dmf-init`:

Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.3.5
0.4.0
5 changes: 4 additions & 1 deletion bin/dmf-init
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
# dmf-init down stop and remove the container
#
# Options for `up`:
# --image REF container image (default ghcr.io/dmfdeploy/dmf-init:latest)
# --image REF container image (default: ghcr.io/dmfdeploy/dmf-init, tagged
# to match a release launcher; :latest in a clone)
# --port N host port to publish on (default 8000; container stays 8000)
# --tls serve HTTPS (self-signed; DMF_TLS_ENABLED=true)
# --repo-base-url U fetch component repos from a private mirror
Expand All @@ -31,6 +32,8 @@ set -euo pipefail
CONTAINER_NAME="dmf-init"
LABEL_KEY="org.dmfdeploy.launcher"
LABEL_VAL="dmf-init"
# The release workflow rewrites the next line (scripts/stamp-launcher-image.sh),
# so keep it as a single line in exactly this form.
DEFAULT_IMAGE="ghcr.io/dmfdeploy/dmf-init:latest"
DATA_ROOT="/tmp/dmf-init-data"
INTERNAL_PORT=8000
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "dmf-init"
version = "0.3.5"
version = "0.4.0"
description = "DMF Init - stateless localhost bootstrap container for DMF environments"
readme = "README.md"
requires-python = ">=3.12"
Expand Down
31 changes: 31 additions & 0 deletions scripts/build-launcher-release.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Build the launcher release assets.
#
# scripts/build-launcher-release.sh <image-ref> <outdir>
#
# Writes <outdir>/dmf-init (the launcher pinned to <image-ref>) and
# <outdir>/SHA256SUMS (one line for that file). The stamp happens before the
# checksum, so the checksum covers the bytes that users download. The release
# workflow calls this script and attaches both files unchanged; a test checks
# that order.
set -euo pipefail

if [ "$#" -ne 2 ]; then
echo "usage: $0 <image-ref> <outdir>" >&2
exit 2
fi
ref="$1"
outdir="$2"
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

mkdir -p "$outdir"
cp "$here/../bin/dmf-init" "$outdir/dmf-init"
"$here/stamp-launcher-image.sh" "$outdir/dmf-init" "$ref"

# Hash from inside the directory so the file name in SHA256SUMS is plain
# "dmf-init", which the documented install command requires.
if command -v sha256sum >/dev/null 2>&1; then
(cd "$outdir" && sha256sum dmf-init > SHA256SUMS)
else
(cd "$outdir" && shasum -a 256 dmf-init > SHA256SUMS)
fi
51 changes: 51 additions & 0 deletions scripts/stamp-launcher-image.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Pin a launcher's default image to an exact image reference.
#
# scripts/stamp-launcher-image.sh <launcher-file> <image-ref>
#
# <image-ref> is ghcr.io/dmfdeploy/dmf-init with a tag, a digest, or both:
# ghcr.io/dmfdeploy/dmf-init:0.4.0
# ghcr.io/dmfdeploy/dmf-init@sha256:<64 hex>
# ghcr.io/dmfdeploy/dmf-init:0.4.0@sha256:<64 hex>
#
# scripts/build-launcher-release.sh runs this on a copy of bin/dmf-init before
# it computes the checksum, so a verified launcher starts the image that the
# release built instead of whatever :latest points to later.
#
# The script fails unless the marker line appears exactly once. A silent no-op
# would publish a launcher that is still pinned to :latest. On any failure the
# launcher file stays as it was.
set -euo pipefail

if [ "$#" -ne 2 ]; then
echo "usage: $0 <launcher-file> <image-ref>" >&2
exit 2
fi
file="$1"
ref="$2"

image_repo="ghcr.io/dmfdeploy/dmf-init"
tag_re=':[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}'
digest_re='@sha256:[0-9a-f]{64}'
if ! [[ "$ref" =~ ^${image_repo//./\\.}(${tag_re})?(${digest_re})?$ ]] || [ "$ref" = "$image_repo" ]; then
echo "image ref must be ${image_repo} with a :tag and/or @sha256:<64 hex> digest, got: $ref" >&2
exit 2
fi
[ -f "$file" ] || { echo "no such file: $file" >&2; exit 2; }

marker="DEFAULT_IMAGE=\"${image_repo}:latest\""
stamped="DEFAULT_IMAGE=\"${ref}\""

count="$(grep -cFx "$marker" "$file" || true)"
if [ "$count" -ne 1 ]; then
echo "expected exactly one line '$marker' in $file, found $count" >&2
exit 1
fi

tmp="$(mktemp)"
trap 'rm -f "$tmp"' EXIT
while IFS= read -r line || [ -n "$line" ]; do
if [ "$line" = "$marker" ]; then line="$stamped"; fi
printf '%s\n' "$line"
done < "$file" > "$tmp"
cat "$tmp" > "$file"
50 changes: 50 additions & 0 deletions test/launcher/test-build-launcher-release.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# Tests scripts/build-launcher-release.sh with no network. The checks run on
# the files the script leaves in the output directory, which are the files the
# release workflow attaches. If a change stamps the launcher after it writes the
# checksum, SHA256SUMS no longer matches dmf-init and this test fails.
#
# test/launcher/test-build-launcher-release.sh
set -euo pipefail

ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
BUILD="$ROOT/scripts/build-launcher-release.sh"
PASS=0; FAIL=0
ok() { PASS=$((PASS+1)); printf ' ok %s\n' "$1"; }
bad() { FAIL=$((FAIL+1)); printf ' FAIL %s\n' "$1" >&2; }

WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT

DIGEST="sha256:$(printf 'a%.0s' $(seq 64))"
REF="ghcr.io/dmfdeploy/dmf-init:0.4.0@${DIGEST}"
OUT="$WORK/out"

digest_of() { if command -v sha256sum >/dev/null; then sha256sum "$1"; else shasum -a 256 "$1"; fi | cut -d ' ' -f 1; }

if "$BUILD" "$REF" "$OUT" >"$WORK/log" 2>&1; then
ok "build succeeds"
else
bad "build failed"; cat "$WORK/log" >&2
fi

if [ "$(find "$OUT" -type f | wc -l | tr -d ' ')" = 2 ] && [ -f "$OUT/dmf-init" ] && [ -f "$OUT/SHA256SUMS" ]; then ok "output holds exactly dmf-init and SHA256SUMS"; else bad "unexpected output files"; fi

if grep -qFx "DEFAULT_IMAGE=\"${REF}\"" "$OUT/dmf-init"; then ok "published launcher carries the stamped ref"; else bad "stamped ref missing from the published launcher"; fi
if ! grep -q ':latest"$' <(grep '^DEFAULT_IMAGE=' "$OUT/dmf-init"); then ok "published launcher is no longer :latest"; else bad "still :latest"; fi

if [ "$(grep -c '' "$OUT/SHA256SUMS")" = 1 ] && grep -qE '^[0-9a-f]{64} dmf-init$' "$OUT/SHA256SUMS"; then ok "SHA256SUMS is one line in the documented form"; else bad "SHA256SUMS form"; fi

want="$(cut -d ' ' -f 1 "$OUT/SHA256SUMS")"
if [ "$(digest_of "$OUT/dmf-init")" = "$want" ]; then ok "SHA256SUMS matches the final published bytes"; else bad "SHA256SUMS does not match the published launcher"; fi

# The checksum of the unstamped launcher must differ, so the check above can
# only pass when the stamp came first.
if [ "$(digest_of "$ROOT/bin/dmf-init")" != "$want" ]; then ok "checksum is not that of the unstamped launcher"; else bad "checksum covers the unstamped launcher"; fi

# A bad ref publishes nothing usable: the script fails and writes no checksum.
rm -rf "$WORK/bad"
if ! "$BUILD" "ghcr.io/dmfdeploy/dmf-init:latest@nope" "$WORK/bad" >/dev/null 2>&1 && [ ! -e "$WORK/bad/SHA256SUMS" ]; then ok "bad image ref fails before any checksum is written"; else bad "bad ref accepted or checksum written"; fi

printf '\n%d passed, %d failed\n' "$PASS" "$FAIL"
[ "$FAIL" = 0 ]
Loading
Loading