From c8914581cc02920ee5a59b65cdc495d5277489d5 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 26 Jul 2026 18:02:35 -0500 Subject: [PATCH] feat(agent): add configurable static helper image for volume ops Add a purpose-built agent-helper image (Zig, FROM scratch, ~130 KB) as a smaller alternative to busybox for agent volume population/cleanup, plus a publish workflow and Go build tool. Consolidate the helper image reference into a single configurable source: config.HelperImage resolves explicit config/flag, then DEVSY_HELPER_IMAGE, then config.DefaultHelperImage. Default remains busybox until the shell-based call sites are ported to the helper's subcommands. --- .github/workflows/publish-agent-helper.yml | 92 ++++++++++++++++++++++ cmd/internal/agentworkspace/clean.go | 11 +-- hack/agent_helper_image/main.go | 59 ++++++++++++++ images/agent-helper/.gitignore | 2 + images/agent-helper/Dockerfile | 4 + images/agent-helper/README.md | 10 +++ images/agent-helper/helper.zig | 52 ++++++++++++ pkg/agent/delivery/local_docker.go | 14 ++-- pkg/config/env.go | 3 + pkg/config/helper.go | 17 ++++ pkg/config/helper_test.go | 26 ++++++ pkg/provider/provider.go | 4 +- 12 files changed, 276 insertions(+), 18 deletions(-) create mode 100644 .github/workflows/publish-agent-helper.yml create mode 100644 hack/agent_helper_image/main.go create mode 100644 images/agent-helper/.gitignore create mode 100644 images/agent-helper/Dockerfile create mode 100644 images/agent-helper/README.md create mode 100644 images/agent-helper/helper.zig create mode 100644 pkg/config/helper.go create mode 100644 pkg/config/helper_test.go diff --git a/.github/workflows/publish-agent-helper.yml b/.github/workflows/publish-agent-helper.yml new file mode 100644 index 000000000..ac6f68bef --- /dev/null +++ b/.github/workflows/publish-agent-helper.yml @@ -0,0 +1,92 @@ +name: Publish Agent Helper Image + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: optional version override (defaults to the latest git tag) + required: false + type: string + pull_request: + paths: + - images/agent-helper/** + - hack/agent_helper_image/** + - .github/workflows/publish-agent-helper.yml + +env: + IMAGE: ghcr.io/${{ github.repository_owner }}/agent-helper + +jobs: + publish: + name: Build and publish agent-helper image + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 + with: + go-version-file: go.mod + + - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 + with: + version: 0.16.0 + + - name: cross-compile helper binaries + run: go run ./hack/agent_helper_image + + - name: resolve version + id: version + run: | + set -euo pipefail + if [ -n "${{ inputs.tag }}" ]; then + version="${{ inputs.tag }}" + elif [ "${{ github.event_name }}" = "release" ]; then + version="${{ github.ref_name }}" + else + version="$(git describe --tags --abbrev=0)" + fi + echo "value=$version" >> "$GITHUB_OUTPUT" + + - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + + # Publish only on release / manual dispatch; pull_request runs build-only. + - name: generate GitHub App token + id: app-token + if: ${{ github.event_name != 'pull_request' }} + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 + with: + app-id: ${{ secrets.DEVSY_GITHUB_APP_ID }} + private-key: ${{ secrets.DEVSY_GITHUB_APP_PRIVATE_KEY }} + + - name: log in to ghcr + if: ${{ github.event_name != 'pull_request' }} + uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 + with: + registry: ghcr.io + username: ${{ steps.app-token.outputs.app-slug }} + password: ${{ steps.app-token.outputs.token }} + + - name: docker metadata + id: meta + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + with: + images: ${{ env.IMAGE }} + tags: | + type=raw,value=${{ steps.version.outputs.value }} + type=raw,value=latest,enable=${{ github.event_name == 'release' && !github.event.release.prerelease }} + + - name: build and push + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: images/agent-helper + file: images/agent-helper/Dockerfile + platforms: linux/amd64,linux/arm64 + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/cmd/internal/agentworkspace/clean.go b/cmd/internal/agentworkspace/clean.go index 71a472cc0..ca3e51f00 100644 --- a/cmd/internal/agentworkspace/clean.go +++ b/cmd/internal/agentworkspace/clean.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/devsy-org/devsy/cmd/flags" + pkgconfig "github.com/devsy-org/devsy/pkg/config" cliflags "github.com/devsy-org/devsy/pkg/flags" "github.com/devsy-org/devsy/pkg/flags/names" "github.com/devsy-org/devsy/pkg/log" @@ -17,7 +18,6 @@ const ( cleanVolumePrefix = "devsy-agent-" cleanVolumeMountPath = "/opt/devsy" cleanBinaryName = "devsy" - cleanHelperImage = "busybox:latest" cleanDefaultDockerCmd = "docker" ) @@ -55,8 +55,8 @@ This forces a fresh binary injection on the next workspace start.`, cliflags.String( &cmd.HelperImage, names.HelperImage, - cleanHelperImage, - "Helper image for volume operations", + "", + "Helper image for volume operations (default "+pkgconfig.DefaultHelperImage+", or $"+pkgconfig.EnvHelperImage+")", ), ) return cleanCmd @@ -125,8 +125,5 @@ func (cmd *CleanCmd) dockerCommand() string { } func (cmd *CleanCmd) helperImage() string { - if cmd.HelperImage != "" { - return cmd.HelperImage - } - return cleanHelperImage + return pkgconfig.HelperImage(cmd.HelperImage) } diff --git a/hack/agent_helper_image/main.go b/hack/agent_helper_image/main.go new file mode 100644 index 000000000..cf87f7f87 --- /dev/null +++ b/hack/agent_helper_image/main.go @@ -0,0 +1,59 @@ +// Cross-compiles the agent-helper volume helper for each published arch into +// images/agent-helper/dist, producing the per-arch binaries the FROM scratch +// image copies in via TARGETARCH. +package main + +import ( + "flag" + "fmt" + "os" + "os/exec" + "path/filepath" +) + +type target struct { + zig string // Zig -target triple + out string // output name, matching the Dockerfile's TARGETARCH +} + +var targets = []target{ + {zig: "x86_64-linux-musl", out: "helper-amd64"}, + {zig: "aarch64-linux-musl", out: "helper-arm64"}, +} + +func main() { + dir := flag.String("dir", "images/agent-helper", "agent-helper image directory") + flag.Parse() + + if err := run(*dir); err != nil { + fmt.Fprintln(os.Stderr, "agent_helper_image:", err) + os.Exit(1) + } +} + +func run(dir string) error { + distDir := filepath.Join(dir, "dist") + if err := os.MkdirAll(distDir, 0o755); err != nil { + return err + } + + src := filepath.Join(dir, "helper.zig") + for _, t := range targets { + out := filepath.Join(distDir, t.out) + cmd := exec.Command("zig", "build-exe", src, + "-target", t.zig, "-O", "ReleaseSmall", "-femit-bin="+out, + "--cache-dir", filepath.Join(distDir, ".zig-cache")) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("build %s: %w", t.out, err) + } + + info, err := os.Stat(out) + if err != nil { + return err + } + fmt.Printf("built %s (%d bytes)\n", out, info.Size()) + } + return nil +} diff --git a/images/agent-helper/.gitignore b/images/agent-helper/.gitignore new file mode 100644 index 000000000..3c0ccd2e2 --- /dev/null +++ b/images/agent-helper/.gitignore @@ -0,0 +1,2 @@ +dist/ +.zig-cache/ diff --git a/images/agent-helper/Dockerfile b/images/agent-helper/Dockerfile new file mode 100644 index 000000000..6bb655d50 --- /dev/null +++ b/images/agent-helper/Dockerfile @@ -0,0 +1,4 @@ +FROM scratch +ARG TARGETARCH +COPY dist/helper-${TARGETARCH} /helper +ENTRYPOINT ["/helper"] diff --git a/images/agent-helper/README.md b/images/agent-helper/README.md new file mode 100644 index 000000000..0fc966fa7 --- /dev/null +++ b/images/agent-helper/README.md @@ -0,0 +1,10 @@ +# agent-helper image + +## Build locally + +Requires [Zig](https://ziglang.org) 0.16.0. + +```sh +go run ./hack/agent_helper_image # cross-compiles dist/helper-{amd64,arm64} +docker buildx build --platform linux/arm64 --load -t agent-helper:dev images/agent-helper +``` diff --git a/images/agent-helper/helper.zig b/images/agent-helper/helper.zig new file mode 100644 index 000000000..780ec4943 --- /dev/null +++ b/images/agent-helper/helper.zig @@ -0,0 +1,52 @@ +//! Minimal volume-helper for agent delivery. +//! +//! helper populate read stdin, write it to , chmod 0755 +//! helper clean remove (idempotent; missing file is ok) +const std = @import("std"); +const linux = std.os.linux; + +fn die(msg: []const u8) noreturn { + std.debug.print("helper: {s}\n", .{msg}); + linux.exit(1); +} + +fn check(rc: usize, what: []const u8) usize { + if (@as(isize, @bitCast(rc)) < 0) die(what); + return rc; +} + +fn cmdPopulate(path: [:0]const u8) void { + const fd: i32 = @intCast(check( + linux.open(path.ptr, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, 0o755), + "open", + )); + var buf: [64 * 1024]u8 = undefined; + while (true) { + const n = check(linux.read(0, &buf, buf.len), "read"); + if (n == 0) break; + var off: usize = 0; + while (off < n) off += check(linux.write(fd, buf[off..].ptr, n - off), "write"); + } + _ = check(linux.fchmod(fd, 0o755), "fchmod"); + _ = linux.close(fd); +} + +fn cmdClean(path: [:0]const u8) void { + const err = @as(isize, @bitCast(linux.unlink(path.ptr))); + // ENOENT (-2) is fine: nothing to remove. + if (err < 0 and err != -2) die("unlink"); +} + +pub fn main(init: std.process.Init.Minimal) !void { + var it = std.process.Args.Iterator.init(init.args); + _ = it.next(); // argv[0] + const sub = it.next() orelse die("usage: helper "); + const path = it.next() orelse die("usage: helper "); + if (std.mem.eql(u8, sub, "populate")) { + cmdPopulate(path); + } else if (std.mem.eql(u8, sub, "clean")) { + cmdClean(path); + } else { + die("unknown subcommand"); + } +} diff --git a/pkg/agent/delivery/local_docker.go b/pkg/agent/delivery/local_docker.go index c61545a22..bae75e844 100644 --- a/pkg/agent/delivery/local_docker.go +++ b/pkg/agent/delivery/local_docker.go @@ -20,11 +20,10 @@ import ( var _ AgentDelivery = (*LocalDockerDelivery)(nil) const ( - defaultDockerCmd = "docker" - podmanCmd = "podman" - volumePrefix = "devsy-agent-" - volumeMountPath = "/opt/devsy" - defaultHelperImage = "busybox:latest" + defaultDockerCmd = "docker" + podmanCmd = "podman" + volumePrefix = "devsy-agent-" + volumeMountPath = "/opt/devsy" // cmdRun / flagRM build throwaway helper-container invocations. cmdRun = "run" @@ -141,10 +140,7 @@ func (d *LocalDockerDelivery) createVolume( } func (d *LocalDockerDelivery) helperImageName() string { - if d.HelperImage != "" { - return d.HelperImage - } - return defaultHelperImage + return pkgconfig.HelperImage(d.HelperImage) } func (d *LocalDockerDelivery) expectedVersion() string { diff --git a/pkg/config/env.go b/pkg/config/env.go index 9e930a08d..29990484d 100644 --- a/pkg/config/env.go +++ b/pkg/config/env.go @@ -42,6 +42,9 @@ const ( // EnvAgentURL overrides the agent download URL. EnvAgentURL = "DEVSY_AGENT_URL" + // EnvHelperImage overrides the helper image for agent volume operations. + EnvHelperImage = "DEVSY_HELPER_IMAGE" + // EnvAgentPreferDownload forces agent binary download even if a local copy exists. EnvAgentPreferDownload = "DEVSY_AGENT_PREFER_DOWNLOAD" diff --git a/pkg/config/helper.go b/pkg/config/helper.go new file mode 100644 index 000000000..01c396839 --- /dev/null +++ b/pkg/config/helper.go @@ -0,0 +1,17 @@ +package config + +import "os" + +const DefaultHelperImage = "busybox:latest" + +// HelperImage resolves the helper image: explicit value, then +// DEVSY_HELPER_IMAGE, then DefaultHelperImage. +func HelperImage(explicit string) string { + if explicit != "" { + return explicit + } + if env := os.Getenv(EnvHelperImage); env != "" { + return env + } + return DefaultHelperImage +} diff --git a/pkg/config/helper_test.go b/pkg/config/helper_test.go new file mode 100644 index 000000000..41212697b --- /dev/null +++ b/pkg/config/helper_test.go @@ -0,0 +1,26 @@ +package config + +import "testing" + +func TestHelperImage(t *testing.T) { + t.Run("explicit wins over env and default", func(t *testing.T) { + t.Setenv(EnvHelperImage, "env-image") + if got := HelperImage("explicit-image"); got != "explicit-image" { + t.Fatalf("got %q, want explicit-image", got) + } + }) + + t.Run("env used when no explicit value", func(t *testing.T) { + t.Setenv(EnvHelperImage, "env-image") + if got := HelperImage(""); got != "env-image" { + t.Fatalf("got %q, want env-image", got) + } + }) + + t.Run("default when nothing set", func(t *testing.T) { + t.Setenv(EnvHelperImage, "") + if got := HelperImage(""); got != DefaultHelperImage { + t.Fatalf("got %q, want %q", got, DefaultHelperImage) + } + }) +} diff --git a/pkg/provider/provider.go b/pkg/provider/provider.go index 582d523b0..1cdadb0c1 100644 --- a/pkg/provider/provider.go +++ b/pkg/provider/provider.go @@ -212,8 +212,8 @@ type ProviderDockerDriverConfig struct { // Environment variables to set when running docker commands Env map[string]string `json:"env,omitempty"` - // HelperImage is used by LocalDockerDelivery for volume population. - // When empty, defaults to busybox:latest. A direct-copy fallback is used if the helper container approach fails. + // HelperImage overrides the helper image for volume operations. Empty falls + // back to DEVSY_HELPER_IMAGE, then config.DefaultHelperImage. HelperImage string `json:"helperImage,omitempty"` // Runtime identifies the container runtime explicitly (docker, podman, nerdctl).