Skip to content
Closed
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
184 changes: 184 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,18 @@ env:
flake.lock
tools/toolchain/microvm-vmm-env.nix
.github/workflows/release.yml
# The guest artifact's own closure set, SEPARATE from the two above: this
# publishes the three guest assets, so an agent- or runner-only change must
# not republish it. Derived from guest-image/moon.yml's build.inputs plus the
# publish lane itself; the agent pin travels inside guest-image/.
GUEST_IMAGE_CLOSURE_PATHS: |
guest-image/**
tools/guest-image/**
go/go.mod
go/go.sum
go/cmd/compass-guestd/**
go/internal/**
.github/workflows/release.yml

jobs:
release-pr:
Expand Down Expand Up @@ -1278,3 +1290,175 @@ jobs:
--repo ghcr.io/rigelbuild/compass-runner --sha "$sha12")"
echo "published $ref"
echo "runner image: \`$ref\`" >> "$GITHUB_STEP_SUMMARY"

publish-guest-image:
name: publish-guest-image
runs-on: ubuntu-latest
# Least privilege: read the tree, write the GHCR package, nothing else.
permissions:
contents: read
packages: write
# Its own group: a different package from the agent and runner images, so
# serializing against those would only add latency. Within this package,
# publishes must serialize — two runs pushing one tag race over which bytes
# it names.
concurrency:
group: publish-guest-image
cancel-in-progress: false
queue: max
# A dispatch from a feature branch must never publish assets for unmerged
# code. Main pushes satisfy this trivially.
if: github.ref == 'refs/heads/main'
# The rootfs is a ~2 GiB erofs built from source; that nix build sizes this.
timeout-minutes: 90
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Decide whether this push touches the guest-image closure
id: gate
# The sibling publish jobs' push-event tree diff, over
# GUEST_IMAGE_CLOSURE_PATHS. Every fallback errs toward publishing, so
# a missing diff base never silently drops a closure change.
env:
EVENT_NAME: ${{ github.event_name }}
BEFORE_SHA: ${{ github.event.before }}
HEAD_SHA: ${{ github.sha }}
run: |
set -euo pipefail

if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
echo "should_publish=true" >> "$GITHUB_OUTPUT"
echo "workflow_dispatch: force-publish (no push range to diff)"
exit 0
fi

if [ -z "$BEFORE_SHA" ] || [ "$BEFORE_SHA" = "0000000000000000000000000000000000000000" ]; then
echo "should_publish=true" >> "$GITHUB_OUTPUT"
echo "no diff base (first push to branch): force-publish"
exit 0
fi

git fetch --no-tags --depth=1 origin "$BEFORE_SHA" >/dev/null 2>&1 || true

if ! changed="$(git diff --name-only "$BEFORE_SHA" "$HEAD_SHA" 2>/dev/null)"; then
echo "should_publish=true" >> "$GITHUB_OUTPUT"
echo "before-sha unreachable: force-publish (no-drop errs toward publishing)"
exit 0
fi

should_publish=false
while IFS= read -r pattern; do
[ -n "$pattern" ] || continue
case "$pattern" in
*'/**')
prefix="${pattern%'/**'}/"
while IFS= read -r f; do
[ -n "$f" ] || continue
case "$f" in
"$prefix"*) should_publish=true ;;
esac
done <<< "$changed"
;;
*)
while IFS= read -r f; do
[ "$f" = "$pattern" ] && should_publish=true
done <<< "$changed"
;;
esac
[ "$should_publish" = true ] && break
done <<< "$GUEST_IMAGE_CLOSURE_PATHS"

echo "should_publish=$should_publish" >> "$GITHUB_OUTPUT"
echo "changed-path gate over the guest-image closure set: should_publish=$should_publish"

- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31
if: steps.gate.outputs.should_publish == 'true'
with:
# Declared here rather than via `accept-flake-config`, which would
# make nix trust the nixConfig of any flake it evaluates.
extra_nix_config: |
experimental-features = nix-command flakes
extra-substituters = https://devenv.cachix.org https://cachix.cachix.org
extra-trusted-public-keys = devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw= cachix.cachix.org-1:eWNHQldwUO7G2VkjpnjDbWwy4KQ/HNxht7H4SSoMckM=

- name: Put the pinned bun and moon on PATH
if: steps.gate.outputs.should_publish == 'true'
# Both come from the gate-tools pin, so the lane runs byte-identical
# binaries here and on a dev box. moon is required, not incidental: the
# lane realises its assets through the gate's own build task.
run: |
set -euo pipefail
for lang in bun moon; do
# `jq -r` renders a missing `.store` as the string `null`, a value
# rather than an absence, so test for it explicitly.
store=$(nix eval --json -f tools/toolchain/gate-tools.nix "langs.$lang" | jq -r '.store')
if [ -z "$store" ] || [ "$store" = null ]; then
echo "::error::gate-tools.nix langs.$lang produced no store path" >&2
exit 1
fi
nix build --no-link "$store"
echo "$store/bin" >>"$GITHUB_PATH"
done

- name: Put the fork's patched skopeo on PATH
if: steps.gate.outputs.should_publish == 'true'
# The publish lane probes and copies with a plain `skopeo` (the
# RigelBuild/nix2container fork's patched build). Resolve it from the
# shared pinned helper so this privileged job cannot run a mutable
# upstream build, the same pattern the sibling publish jobs use.
run: |
set -euo pipefail
# `--print-out-paths` prints every output (skopeo ships a `-man` output
# too); take the one carrying bin/skopeo, not a fixed line.
skopeo_bin=""
for store in $(nix build --no-link --print-out-paths \
-f tools/toolchain/skopeo-nix2container-env.nix skopeo); do
if [ -x "$store/bin/skopeo" ]; then
skopeo_bin="$store/bin"
break
fi
done
if [ -z "$skopeo_bin" ]; then
echo "::error::skopeo-nix2container-env.nix produced no output carrying bin/skopeo" >&2
exit 1
fi
echo "$skopeo_bin" >> "$GITHUB_PATH"

- name: Pin the registry auth file
if: steps.gate.outputs.should_publish == 'true'
# LOAD-BEARING. The login and the lane's own skopeo calls are SEPARATE
# processes and must resolve the SAME creds file; the default location
# is environment-dependent on hosted runners, and a mismatch greens the
# login then 401s the push.
run: echo "REGISTRY_AUTH_FILE=$RUNNER_TEMP/ghcr-auth.json" >> "$GITHUB_ENV"

- name: Log in to GHCR
if: steps.gate.outputs.should_publish == 'true'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Pass the actor through env rather than interpolating ${{ }} into the
# shell, keeping context values off the run: command line.
ACTOR: ${{ github.actor }}
# The token is passed via env and `--password-stdin` only — never on a
# command line or in a log.
run: |
skopeo \
login ghcr.io -u "$ACTOR" --password-stdin \
--authfile "$REGISTRY_AUTH_FILE" <<< "$GITHUB_TOKEN"

- name: Publish the guest artifact by digest
if: steps.gate.outputs.should_publish == 'true'
# The lane realises the three assets, assembles the layout, scans its
# annotations before any push, and asserts the registry resolved the
# manifest it built. The deployable `repo@sha256:…` lands in the summary:
# GHCR has no server-side tag immutability, so nothing resolves by tag.
run: |
set -euo pipefail
# `--short=12` returns the shortest UNIQUE length >= 12, so a collision
# would yield 13+ chars and fail the lane's strict 12-hex check. A
# deterministic truncation keeps the contract exact.
sha12="$(git rev-parse HEAD | cut -c1-12)"
ref="$(bun tools/guest-image/publish.ts \
--repo ghcr.io/rigelbuild/compass-guest-image --sha "$sha12")"
echo "published $ref"
echo "guest image: \`$ref\`" >> "$GITHUB_STEP_SUMMARY"
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,7 @@ result-*
# entrypoint symlink) and the OCI layout, realised by tools/runner-image/build.ts.
/runner-image/store/
/runner-image/out/

# guest-image publish output: the OCI layout a local dry run writes, multi-GiB
# because it holds a copy of the rootfs blob.
/guest-image/oci-layout/
44 changes: 42 additions & 2 deletions go/internal/stack/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,20 @@ type Config struct {
// the agent's ~/.omp/agent (RIG-1787 H3); the embedded supervisor and the
// compass-stack CLI leave it unset.
Mounts []string
// RuntimeBackend selects the runner's session backend (e.g. "microvm").
// Empty omits --backend entirely, leaving the runner on its own resolution
// — the core applies no default, the CLI slice resolves one if it wants.
RuntimeBackend string
// GuestArtifact is the digest-pinned repo@sha256:... guest image the
// stack materialises before spawning the runner. A tag-pinned ref is
// rejected: a mutable tag would defeat the content-addressed state dir.
// Mutually exclusive with GuestDir; empty means no artifact is fetched.
GuestArtifact string
// GuestDir points the stack at an already-materialised guest directory and
// skips all fetching — the air-gapped path, where no pull is ever
// mandatory. Mutually exclusive with GuestArtifact. Empty leaves the guest
// paths unset, which keeps the assets baked into the Runner image live.
GuestDir string
}

// sunPathMax is the longest NUL-terminated path an AF_UNIX address holds on this
Expand All @@ -131,8 +145,8 @@ var agentSocketTailWidth = len(filepath.Join(
)) + 1 // +1 for the separator joining RuntimeDir to the tail

// Validate enforces the config invariants that would otherwise surface as opaque
// runtime failures far from the misconfiguration: an unbindable network door and
// an over-budget runner socket path.
// runtime failures far from the misconfiguration: an unbindable network door,
// an over-budget runner socket path, and coherent guest image settings.
func (c Config) Validate() error {
if c.ListenAddr == "" {
return errors.New("stack config: ListenAddr is required (a fixed loopback TLS door, e.g. 127.0.0.1:50052)")
Expand All @@ -143,6 +157,15 @@ func (c Config) Validate() error {
if _, port, ok := splitPort(c.ListenAddr); ok && port == "0" {
return fmt.Errorf("stack config: ListenAddr %q must be a fixed port, not :0 (no bound-address discovery API exists)", c.ListenAddr)
}
if c.GuestArtifact != "" && c.GuestDir != "" {
return fmt.Errorf("stack config: GuestArtifact %q and GuestDir %q are mutually exclusive", c.GuestArtifact, c.GuestDir)
}
if c.GuestArtifact != "" && !isDigestPinned(c.GuestArtifact) {
return fmt.Errorf("stack config: GuestArtifact %q must be a digest-pinned repo@sha256:<64 hex> reference", c.GuestArtifact)
}
if c.RuntimeBackend != "microvm" && (c.GuestArtifact != "" || c.GuestDir != "") {
return fmt.Errorf("stack config: GuestArtifact/GuestDir require RuntimeBackend %q, got %q", "microvm", c.RuntimeBackend)
}
// The runner builds agent sockets at RuntimeDir/containers/
// compass-agent-<32hex>/agent.sock; the fixed tail is agentSocketTailWidth
// bytes, so RuntimeDir may not exceed sunPathMax-tail. Name the budget so an
Expand All @@ -156,6 +179,23 @@ func (c Config) Validate() error {
return nil
}

// isDigestPinned reports whether ref carries a @sha256:<64 lowercase hex>
// tail. Lowercase is deliberate: the pin and publish lanes emit only lowercase
// and the digest string keys the content-addressed state dir, so accepting an
// uppercase variant would fetch to a second dir for identical content.
func isDigestPinned(ref string) bool {
i := strings.LastIndex(ref, "@sha256:")
if i <= 0 || len(ref)-i-len("@sha256:") != 64 {
return false
}
for _, ch := range ref[i+len("@sha256:"):] {
if !((ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f')) {
return false
}
}
return true
}

// splitPort extracts the port from a host:port authority without importing net's
// resolution machinery. It returns ok=false when there is no ":port" tail.
func splitPort(addr string) (host, port string, ok bool) {
Expand Down
58 changes: 58 additions & 0 deletions go/internal/stack/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,64 @@ func TestConfigValidate(t *testing.T) {
},
wantErr: false,
},
{
name: "guest artifact and dir mutually exclusive",
mutate: func(c *Config) {
c.RuntimeBackend = "microvm"
c.GuestArtifact = "repo/image@sha256:" + strings.Repeat("a", 64)
c.GuestDir = "/state/guest"
},
wantErr: true,
errSubstrs: []string{"GuestArtifact", "GuestDir"},
},
{
name: "guest artifact must be digest pinned",
mutate: func(c *Config) {
c.RuntimeBackend = "microvm"
c.GuestArtifact = "repo/image:latest"
},
wantErr: true,
errSubstrs: []string{"GuestArtifact", "digest"},
},
{
name: "guest artifact rejects uppercase digest hex",
mutate: func(c *Config) {
c.RuntimeBackend = "microvm"
c.GuestArtifact = "repo/image@sha256:" + strings.Repeat("A", 64)
},
wantErr: true,
errSubstrs: []string{"GuestArtifact", "digest"},
},
{
name: "guest fields require microvm backend",
mutate: func(c *Config) {
c.RuntimeBackend = "container"
c.GuestDir = "/state/guest"
},
wantErr: true,
errSubstrs: []string{"GuestDir", "RuntimeBackend"},
},
{
name: "microvm with neither guest field",
mutate: func(c *Config) { c.RuntimeBackend = "microvm" },
wantErr: false,
},
{
name: "microvm with guest dir",
mutate: func(c *Config) {
c.RuntimeBackend = "microvm"
c.GuestDir = "/state/guest"
},
wantErr: false,
},
{
name: "microvm with digest pinned guest artifact",
mutate: func(c *Config) {
c.RuntimeBackend = "microvm"
c.GuestArtifact = "repo/image@sha256:" + strings.Repeat("a", 64)
},
wantErr: false,
},
}

for _, tc := range tests {
Expand Down
12 changes: 12 additions & 0 deletions go/internal/stack/spec.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package stack

import (
"fmt"
"path/filepath"
"strings"
)

Expand Down Expand Up @@ -83,6 +84,17 @@ func runnerSpec(cfg Config, cert CertResult, token string) ProcessSpec {
for _, m := range cfg.Mounts {
args = append(args, "--mount", m)
}
if cfg.RuntimeBackend != "" {
args = append(args, "--backend", cfg.RuntimeBackend)
if cfg.GuestDir != "" {
args = append(args,
"--microvm-kernel", filepath.Join(cfg.GuestDir, "kernel"),
"--microvm-rootfs", filepath.Join(cfg.GuestDir, "rootfs.erofs"),
"--microvm-initrd", filepath.Join(cfg.GuestDir, "initrd"),
"--microvm-image-manifest", filepath.Join(cfg.GuestDir, "manifest.sha256"),
)
}
}
return ProcessSpec{
Component: ComponentRunner,
Args: args,
Expand Down
23 changes: 23 additions & 0 deletions go/internal/stack/spec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,29 @@ func TestRunnerSpecForwardsOptionalFlagsConditionally(t *testing.T) {
}
}

func TestRunnerSpecGuestArgs(t *testing.T) {
base := Config{ListenAddr: "127.0.0.1:50052", AgentImage: "agent:latest", RuntimeDir: "/run/compass"}
cert := CertResult{CertPath: "/state/tls.crt"}
tests := []struct {
name string
cfg Config
wantEnd []string
}{
{name: "non-microvm remains unchanged", cfg: base, wantEnd: nil},
{name: "backend without guest paths", cfg: Config{ListenAddr: base.ListenAddr, AgentImage: base.AgentImage, RuntimeDir: base.RuntimeDir, RuntimeBackend: "container"}, wantEnd: []string{"--backend", "container"}},
{name: "microvm guest dir", cfg: Config{ListenAddr: base.ListenAddr, AgentImage: base.AgentImage, RuntimeDir: base.RuntimeDir, RuntimeBackend: "microvm", GuestDir: "/state/guest"}, wantEnd: []string{"--backend", "microvm", "--microvm-kernel", "/state/guest/kernel", "--microvm-rootfs", "/state/guest/rootfs.erofs", "--microvm-initrd", "/state/guest/initrd", "--microvm-image-manifest", "/state/guest/manifest.sha256"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := runnerSpec(tt.cfg, cert, "token").Args
want := append(baseRunnerArgs(tt.cfg, cert), tt.wantEnd...)
if !slices.Equal(got, want) {
t.Fatalf("runnerSpec Args = %q, want %q", got, want)
}
})
}
}

// The empty arm is the load-bearing one: an unset SecretProvider must yield a
// byte-identical argv, since the embedded supervisor and compass-stack's
// resolveConfig both leave it zero.
Expand Down
Loading
Loading