diff --git a/.github/workflows/build-cache.yml b/.github/workflows/build-cache.yml index e162ad540..60fa951a3 100644 --- a/.github/workflows/build-cache.yml +++ b/.github/workflows/build-cache.yml @@ -28,7 +28,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: "1.27.2" + go-version: "1.27.3" - name: Verify Buildx selection and cache arguments reach the docker CLI if: matrix.language == 'go' run: go test ./agents/helpers/docker -run '^TestBuildx|^TestBuildOmits|^TestBuildCarries' -count=1 -v -timeout=5m @@ -62,7 +62,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: "1.27.2" + go-version: "1.27.3" - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: registry-cache-${{ matrix.language }} diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index f59d7bc67..fa71fac6e 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -112,7 +112,7 @@ jobs: - name: set up uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: "1.27.2" + go-version: "1.27.3" - name: checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/agents/services/builder.go b/agents/services/builder.go index 8c0f44f52..611351402 100644 --- a/agents/services/builder.go +++ b/agents/services/builder.go @@ -58,7 +58,13 @@ func (b *BuilderAgent) Build(ctx context.Context, req *builderv0.BuildRequest, o if err == nil && cache != nil && resp.GetState().GetState() == builderv0.BuildStatus_SUCCESS && resp.GetResult().GetDockerBuildPlan() == nil && resp.GetCacheContractVersion() != dockerhelpers.CacheContractVersion { return nil, fmt.Errorf("builder agent did not acknowledge build cache contract %s; upgrade the agent or remove cache options", dockerhelpers.CacheContractVersion) } - if selected != "" && err == nil && resp.GetState().GetState() == builderv0.BuildStatus_SUCCESS && resp.GetResult().GetDockerBuildPlan() == nil && resp.GetBuildxBuilder() != selected { + // Only an in-agent image build has an executor to acknowledge. A recipe + // (docker_build_plan) is built by the caller, on the builder it selected; + // an answer with no result kind built nothing — a vendor-image agent whose + // image is published elsewhere — and has nothing to acknowledge either. + // Reading "no recipe" as "built in-agent" refused every render composing + // such an agent the moment the environment selected a builder. + if selected != "" && err == nil && resp.GetState().GetState() == builderv0.BuildStatus_SUCCESS && resp.GetResult().GetDockerBuildResult() != nil && resp.GetBuildxBuilder() != selected { return nil, fmt.Errorf("builder agent did not acknowledge requested Buildx builder %q; upgrade the agent", selected) } if err == nil && req.GetExecution() != nil { diff --git a/agents/services/builder_cache_test.go b/agents/services/builder_cache_test.go index f5c5f3d23..149bc799b 100644 --- a/agents/services/builder_cache_test.go +++ b/agents/services/builder_cache_test.go @@ -2,7 +2,6 @@ package services import ( "context" - "fmt" "net" "sync/atomic" "testing" @@ -87,12 +86,21 @@ func (s *buildxTestServer) Build(ctx context.Context, req *builderv0.BuildReques return resp, err } +// Only an in-agent build has an executor to acknowledge: a recipe is built by +// the caller on the builder it selected, and an answer that built nothing (a +// vendor-image agent) has nothing to acknowledge. Treating "no recipe" as +// "built in-agent" refused every render composing a vendor-image agent. func TestBuildxSelectionRequiresExecutorAcknowledgementOverGRPC(t *testing.T) { - for _, recipe := range []bool{false, true} { - t.Run(fmt.Sprintf("recipe=%t", recipe), func(t *testing.T) { + for name, result := range map[string]*builderv0.BuildResult{ + "in-agent build": {Kind: &builderv0.BuildResult_DockerBuildResult{DockerBuildResult: &builderv0.DockerBuildResult{}}}, + "recipe": {Kind: &builderv0.BuildResult_DockerBuildPlan{DockerBuildPlan: &builderv0.DockerBuildPlan{}}}, + "nothing built": nil, + } { + t.Run(name, func(t *testing.T) { + recipe := name != "in-agent build" wrapper := &BuilderWrapper{Base: &Base{loaded: true}} - if recipe { - wrapper.BuildResult = &builderv0.BuildResult{Kind: &builderv0.BuildResult_DockerBuildPlan{DockerBuildPlan: &builderv0.DockerBuildPlan{}}} + if result != nil { + wrapper.BuildResult = result } server := grpc.NewServer() builderv0.RegisterBuilderServer(server, &buildxTestServer{ @@ -144,7 +152,7 @@ func TestBuildxCapabilitiesCheckedBeforeBuildOverGRPC(t *testing.T) { } { t.Run(tc.name, func(t *testing.T) { implementation := &buildxTestServer{ - DefaultBuilder: NewDefaultBuilder(&BuilderWrapper{Base: &Base{loaded: true}}), + DefaultBuilder: NewDefaultBuilder(&BuilderWrapper{Base: &Base{loaded: true}, BuildResult: &builderv0.BuildResult{Kind: &builderv0.BuildResult_DockerBuildResult{DockerBuildResult: &builderv0.DockerBuildResult{}}}}), // an in-agent build: the one answer that owes an acknowledgement capabilities: &builderv0.BuildCapabilitiesResponse{BuildxSelection: tc.supported}, capabilityError: tc.probeError, acknowledgement: tc.ack, diff --git a/go.mod b/go.mod index cb90dfc38..dd72734c9 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/codefly-dev/core -go 1.27.0 +go 1.27.3 require ( buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2 diff --git a/runners/sandbox/bwrap.go b/runners/sandbox/bwrap.go index 29e854899..8a9eb6508 100644 --- a/runners/sandbox/bwrap.go +++ b/runners/sandbox/bwrap.go @@ -12,6 +12,7 @@ import ( // bwrap \ // --die-with-parent \ // --new-session \ +// --unshare-pid \ // --proc /proc \ // --dev /dev \ // --tmpfs /tmp \ @@ -24,9 +25,18 @@ import ( // [--unshare-net] // -- // -// `--die-with-parent` ensures the bwrap child gets SIGTERM if the Go +// `--die-with-parent` asks for the bwrap child to be killed if the Go // parent dies abnormally. `--new-session` blocks TIOCSTI escapes from -// the sandboxed process onto the controlling tty. The system-image +// the sandboxed process onto the controlling tty. `--unshare-pid` is +// what actually bounds the payload's lifetime: with it the payload runs +// under a pid-namespace init bubblewrap supervises, so ending the +// supervisor ends the namespace and every process in it. Without it the +// bound rests on the parent-death signal alone, and that signal is not +// delivered on every platform: on Ubuntu 26.04 (kernel 7.0, bubblewrap +// 0.11.1 confined as bwrap//&unpriv_bwrap) a payload outlived its +// terminated supervisor, in its own session because of --new-session, +// holding the supervisor's stdio pipes, so the caller's reaper never +// returned and cleanup ran into its deadline. The system-image // binds (/usr, /lib, etc.) are required because most binaries are // dynamically linked to libraries rooted there; without them, even // `cat` fails to start in the sandbox. @@ -108,6 +118,7 @@ func (s *bwrapSandbox) buildArgs() []string { args := []string{ "--die-with-parent", "--new-session", + "--unshare-pid", "--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp", diff --git a/runners/sandbox/sandbox_test.go b/runners/sandbox/sandbox_test.go index c43929725..8e8484ff5 100644 --- a/runners/sandbox/sandbox_test.go +++ b/runners/sandbox/sandbox_test.go @@ -202,6 +202,21 @@ func TestBwrap_NetworkLoopback_UsesIsolatedNetwork(t *testing.T) { require.Equal(t, []string{"/tmp/codefly-agent", "--fixture"}, cmd.Args[len(cmd.Args)-2:]) } +func TestBwrapPayloadLifetimeIsBoundToAPidNamespace(t *testing.T) { + if runtime.GOOS != "linux" { + // bubblewrap is Linux-only; returning early per the no-skip rule. + return + } + sb, err := sandbox.New() + require.NoError(t, err) + cmd := exec.Command("/tmp/codefly-agent", "--fixture") + require.NoError(t, sb.Wrap(cmd)) + args := strings.Join(cmd.Args, " ") + require.Contains(t, args, "--unshare-pid", + "the payload must run under a pid-namespace init bubblewrap supervises: --die-with-parent alone is not honoured on every platform (Ubuntu 26.04 let a payload outlive its terminated supervisor)") + require.Contains(t, args, "--die-with-parent") +} + func TestSandbox_Wrap_RefusesDoubleWrap(t *testing.T) { sb, err := sandbox.New() if err != nil {