Skip to content
Draft
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
2 changes: 1 addition & 1 deletion base/code/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ module codefly-base
go 1.27.3

require (
github.com/codefly-dev/core v0.19.9
github.com/codefly-dev/core v0.19.10
github.com/codefly-dev/sdk-go v0.4.3
)

Expand Down
4 changes: 2 additions & 2 deletions base/code/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ github.com/cheggaaa/pb/v3 v3.2.1 h1:aprZbFRG+B7+ug76S8QZ6Y1PW168UHzOmbC3wa+aU6I=
github.com/cheggaaa/pb/v3 v3.2.1/go.mod h1:U9hSVxoKqJrZIE3PkFG1xXXNaK/Ilzg+EF/scpXzEdw=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/codefly-dev/core v0.19.9 h1:lTDsRJTceSsHLTmVttxFzNM3rEjuKsT8w5NmwozLu3Y=
github.com/codefly-dev/core v0.19.9/go.mod h1:aTJY3Rw0OSVkbWRThgDzSkqieoF9mW4MiPRXM8Vzxww=
github.com/codefly-dev/core v0.19.10 h1:rTeh1vph0xGtpiCCt2Dl1AH2Rz9hzIYt1YGcbv6bLYE=
github.com/codefly-dev/core v0.19.10/go.mod h1:aTJY3Rw0OSVkbWRThgDzSkqieoF9mW4MiPRXM8Vzxww=
github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ=
github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg=
github.com/codefly-dev/sdk-go v0.4.3 h1:f7QHqqE7xqqpglOQ2kJ+DZCO/TbrNaDEZhGZ1IOd46Q=
Expand Down
30 changes: 29 additions & 1 deletion docs/PACKAGING.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ cancellation. If the build succeeded and shutdown returns a wrapped
keeps the packaged result. Other shutdown errors remain fatal. When the build
failed, any shutdown error is joined to the build error so both remain visible.

The companion's Docker lifecycle is owned by core. The pinned core v0.19.4 also
The companion's Docker lifecycle is owned by core. The pinned Core also
applies its own ten-second timeout to the removal request; the outer two-minute
context does not override that timeout. A removal timeout can leave a container
behind; this change preserves the artifact and reports its container name, and
Expand All @@ -17,3 +17,31 @@ does not change core's container recovery policy.
injects only the shutdown error to exercise these outcomes without Docker.
`TestPackageRealCGO` retains the real native/cross CGO contract and requires
`SERVICE_GO_PACKAGE_TESTS=required` and Docker.

## Graph identity in package evidence

Each executable receives its own SBOM using Core's existing inventory and
artifact-binding operations. A native target honors the CLI's prepared GOWORK;
without one it uses the pinned module graph. Cross targets use GOWORK=off for
source discovery, toolchain selection, build and inventory because their
companion mounts the source module, not host workspace siblings. The service's
with-workspace setting is not Package's graph selector. A single mixed-target
request may therefore produce different dependency graphs, each bound to its
own executable digest. The public Package regression links a workspace sibling,
checks the actual source attachment and all component references, and compares
the selected graph with dependency versions embedded in real CGO executables.

Workspace SBOM qualification requires the Core workspace-root correction. The
current released pin, Core v0.19.10, omits other workspace main modules and can
attach an executable to the wrong source. The strengthened regression is qualified
with an uncommitted local Core workspace override while that owner fix awaits a
release. This does not qualify a clean install from the current pin. Adopt the
released correction through `codefly agent deps --pin`, then rerun standalone,
generated-service and public native/cross packaging checks before release.

Core v0.19.10 includes the Linux process-identity correction. Native ownership
records use authenticated-v2; older agents/CLIs that only know authenticated-v1
do not reap those records. Use compatible CLI recovery tooling; an installation
key alone is not evidence that a locally built binary is the published release.
The ownership gate retains the launch birth and requires it before signaling,
then verifies the recovery receipt and preservation of an independent sibling.
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ module github.com/codefly-dev/service-go
go 1.27.3

require (
github.com/codefly-dev/core v0.19.9
github.com/codefly-dev/core v0.19.10
github.com/stretchr/testify v1.12.1
golang.org/x/mod v0.42.0
golang.org/x/tools v0.52.0
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cloudflare/circl v1.6.5 h1:O64F26HEqNhznd/hrC5KZXVKYuKM2rx4deZDTc4ihQA=
github.com/cloudflare/circl v1.6.5/go.mod h1:h5LNyxAc5nTue9DS5jT+48en2PSDYt3zdGnz5OstK6c=
github.com/codefly-dev/core v0.19.9 h1:lTDsRJTceSsHLTmVttxFzNM3rEjuKsT8w5NmwozLu3Y=
github.com/codefly-dev/core v0.19.9/go.mod h1:aTJY3Rw0OSVkbWRThgDzSkqieoF9mW4MiPRXM8Vzxww=
github.com/codefly-dev/core v0.19.10 h1:rTeh1vph0xGtpiCCt2Dl1AH2Rz9hzIYt1YGcbv6bLYE=
github.com/codefly-dev/core v0.19.10/go.mod h1:aTJY3Rw0OSVkbWRThgDzSkqieoF9mW4MiPRXM8Vzxww=
github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ=
github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
Expand Down
55 changes: 49 additions & 6 deletions native_ownership_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +69,24 @@ func TestNativeAgentOwnershipLifecycle(t *testing.T) {
// Restart through the same Runtime, then kill only its disposable owner
// birth so no agent Stop/finalizer can mask recovery of an orphan workload.
a.group = startNativeWorkload(t, ctx, a.client, a.workspace)
owner, err := runners.InspectProcess(a.conn.ProcessInfo().PID)
if err != nil {
t.Fatal(err)
// A fresh inspection alone could select a replacement at the saved PID.
// Exercise the real pre-signal guard against a stale birth: the current
// disposable agent and its workload must remain alive and authenticated.
if !t.Run("stale_owner_refuses_delivery", func(t *testing.T) {
stale := a.owner
stale.StartID++
if err := signalNativeOwner(ctx, stale); !errors.Is(err, errNativeOwnerChanged) {
t.Fatalf("stale owner was not refused before delivery: %v", err)
}
current, err := runners.InspectProcess(a.owner.PID)
if err != nil || current != a.owner {
t.Fatalf("stale-owner refusal affected the original agent: %+v, %v", current, err)
}
assertNativeOwnership(t, ctx, a.group, a.workspace, true)
}) {
t.FailNow()
}
if err := runners.SignalProcess(ctx, owner, syscall.SIGKILL); err != nil {
if err := signalNativeOwner(ctx, a.owner); err != nil {
t.Fatal(err)
}
waitNativeCondition(t, func() bool {
Expand All @@ -91,9 +104,14 @@ func TestNativeAgentOwnershipLifecycle(t *testing.T) {
t.Fatalf("preview: %v", err)
}
expected, err := runners.ParseProcessGroupRecoveryExpectation(preview)
if err != nil || expected.Namespace != "authenticated-v2" || expected.PGID != a.group.PGID() || expected.Owner.PID != owner.PID {
if err != nil || expected.Namespace != "authenticated-v2" || expected.PGID != a.group.PGID() {
t.Fatalf("actual agent did not write the expected v2 registration: %+v, %v", expected, err)
}
// Recovery receipts represent the recorded owner with PGID zero. Its birth
// must still be the authenticated agent captured before any workload RPC.
if expected.Owner.PGID != 0 || expected.Owner.PID != a.owner.PID || expected.Owner.BootID != a.owner.BootID || expected.Owner.StartID != a.owner.StartID || expected.Owner.Executable != a.owner.Executable {
t.Fatalf("recovery receipt names a different owner: %+v, original %+v", expected.Owner, a.owner)
}
t.Logf("orphan preview: %s", preview)
assertNativeOwnership(t, ctx, a.group, a.workspace, false)
receipt := filepath.Join(t.TempDir(), "recovery.json")
Expand Down Expand Up @@ -124,6 +142,22 @@ type ownedNativeService struct {
conn *manager.AgentConn
client runtimev0.RuntimeClient
group *runners.TrackedProcessGroup
owner runners.ProcessIdentity
}

var errNativeOwnerChanged = errors.New("native agent owner identity changed before signal")

// This fixture never execs or changes process groups. Require the entire
// original identity, then let Core bind the current identity to signal delivery.
func signalNativeOwner(ctx context.Context, original runners.ProcessIdentity) error {
current, err := runners.InspectProcess(original.PID)
if err != nil {
return err
}
if current != original {
return errNativeOwnerChanged
}
return runners.SignalProcess(ctx, current, syscall.SIGKILL)
}

func startOwnedNativeService(t *testing.T, ctx context.Context, name string) *ownedNativeService {
Expand All @@ -147,6 +181,15 @@ func main() {
t.Fatal(err)
}
t.Cleanup(conn.Close)
ownerGroup, err := runners.LookupProcessGroup(conn.ProcessInfo().PID)
if err != nil {
t.Fatal(err)
}
owner := ownerGroup.Leader()
ownership, err := ownerGroup.InspectOwnership(ctx)
if err != nil || !ownership.Authenticated || !ownership.OwnerAlive || owner.PID != conn.ProcessInfo().PID || len(ownership.Members) != 1 || ownership.Members[0] != owner {
t.Fatalf("agent launch did not authenticate its original owner identity: %+v, %v", ownership, err)
}
client := runtimev0.NewRuntimeClient(conn.GRPCConn())
t.Cleanup(func() {
cleanup, cancel := context.WithTimeout(context.Background(), 10*time.Second)
Expand All @@ -168,7 +211,7 @@ func main() {
if err != nil || initialized.GetStatus().GetState() != runtimev0.InitStatus_READY {
t.Fatalf("init: %v, %v\n%s", initialized, err, conn.StderrTail())
}
return &ownedNativeService{workspace, conn, client, startNativeWorkload(t, ctx, client, workspace)}
return &ownedNativeService{workspace, conn, client, startNativeWorkload(t, ctx, client, workspace), owner}
}

func startNativeWorkload(t *testing.T, ctx context.Context, client runtimev0.RuntimeClient, workspace string) *runners.TrackedProcessGroup {
Expand Down
166 changes: 166 additions & 0 deletions package_evidence_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
package main

import (
"crypto/sha256"
"debug/buildinfo"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"

builderv0 "github.com/codefly-dev/core/generated/go/codefly/services/builder/v0"
"github.com/stretchr/testify/require"
)

// Exercise the public Package RPC, real binaries and Core's attached evidence.
// The workspace links a sibling main module instead of its pinned release.
func TestPackageEvidenceFollowsEachTargetGraph(t *testing.T) {
root, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatal(err)
}
source := filepath.Join(root, "code")
write(t, filepath.Join(root, "service.codefly.yaml"), serviceConfig)
write(t, filepath.Join(source, "go.mod"), "module example.test/package-evidence\n\ngo 1.27.0\n\nrequire rsc.io/quote v1.5.1\n")
write(t, filepath.Join(source, "main.go"), "package main\n/* static int answer(void) { return 42; } */\nimport \"C\"\nimport (\"fmt\"; \"rsc.io/quote\")\nfunc main() { if C.answer()!=42 { panic(\"C compiler\") }; fmt.Println(quote.Hello()) }\n")
prepare := exec.Command("go", "mod", "tidy")
prepare.Dir = source
prepare.Env = append(os.Environ(), "GOWORK=off")
if out, err := prepare.CombinedOutput(); err != nil {
t.Fatalf("prepare pinned graph: %v %s", err, out)
}
work := filepath.Join(root, "go.work")
write(t, filepath.Join(root, "sibling/go.mod"), "module rsc.io/quote\n\ngo 1.27.0\n")
write(t, filepath.Join(root, "sibling/quote.go"), "package quote\nfunc Hello() string { return \"workspace sibling\" }\n")
write(t, work, "go 1.27.0\nuse (\n./code\n./sibling\n)\n")
for _, workspace := range []bool{false, true} {
name := "pinned"
if workspace {
name = "prepared-workspace"
}
t.Run(name, func(t *testing.T) {
mode := "off"
if workspace {
mode = work
}
t.Setenv("GOWORK", mode)
b, ctx := loadBuilderAt(t, root)
b.Settings.WithWorkspace = !workspace // not Package's graph selector
targets := []*builderv0.PackageTarget{{Os: runtime.GOOS, Architecture: runtime.GOARCH}}
if os.Getenv("SERVICE_GO_PACKAGE_TESTS") == "required" && (runtime.GOOS != "linux" || runtime.GOARCH != "amd64") {
targets = append(targets, &builderv0.PackageTarget{Os: "linux", Architecture: "amd64"})
}
response, err := grpcBuilderClient(t, b).Package(ctx, &builderv0.PackageRequest{OutputDirectory: t.TempDir(), ArtifactName: "evidence", Targets: targets, IncludeSbom: true})
if err != nil || response.GetState().GetState() != builderv0.PackageStatus_SUCCESS {
t.Fatalf("Package: %v %v", response, err)
}
if len(response.Artifacts) != 2*len(targets) {
t.Fatalf("artifacts: %v", response.Artifacts)
}
for _, artifact := range response.Artifacts {
if artifact.Kind != builderv0.PackageArtifact_EXECUTABLE {
continue
}
want := "v1.5.1"
isNative := artifact.Target.Os == runtime.GOOS && artifact.Target.Architecture == runtime.GOARCH
if workspace && isNative {
want = "(devel)"
}
info, err := buildinfo.ReadFile(artifact.Path)
if err != nil {
t.Fatal(err)
}
found := false
for _, dep := range info.Deps {
if dep.Path == "rsc.io/quote" {
version := dep.Version
if dep.Replace != nil {
version = dep.Replace.Version
}
if version != want {
t.Fatalf("binary dependency %s want %s", version, want)
}
found = true
}
}
if !found {
t.Fatal("binary omitted fixture dependency")
}
if isNative {
out, err := exec.CommandContext(ctx, artifact.Path).CombinedOutput()
require.NoError(t, err, "%s", out)
if workspace {
require.Equal(t, "workspace sibling\n", string(out))
} else {
require.NotEqual(t, "workspace sibling\n", string(out))
}
}
payload, err := os.ReadFile(artifact.Path + ".cdx.json")
if err != nil {
t.Fatal(err)
}
assertPackageEvidence(t, artifact, payload, workspace && isNative)
}
})
}
}

func assertPackageEvidence(t *testing.T, artifact *builderv0.PackageArtifact, payload []byte, workspace bool) {
t.Helper()
type component struct {
Ref string `json:"bom-ref"`
Hashes []struct{ Alg, Content string }
}
var document struct {
Metadata struct{ Component component }
Components []component
Dependencies []struct {
Ref string
DependsOn []string
}
}
require.NoError(t, json.Unmarshal(payload, &document))
root := document.Metadata.Component
bytes, err := os.ReadFile(artifact.Path)
require.NoError(t, err)
digest := fmt.Sprintf("%x", sha256.Sum256(bytes))
require.Equal(t, digest, artifact.Sha256)
require.Len(t, root.Hashes, 1)
require.Equal(t, "SHA-256", root.Hashes[0].Alg)
require.Equal(t, digest, root.Hashes[0].Content)
refs := map[string]bool{root.Ref: true}
for _, component := range document.Components {
require.NotEmpty(t, component.Ref)
require.False(t, refs[component.Ref], "duplicate component %s", component.Ref)
refs[component.Ref] = true
}
edges := map[string][]string{}
for _, dependency := range document.Dependencies {
require.True(t, refs[dependency.Ref], "unknown graph source %s", dependency.Ref)
for _, ref := range dependency.DependsOn {
require.True(t, refs[ref], "unknown graph target %s", ref)
}
edges[dependency.Ref] = dependency.DependsOn
}
const source = "pkg:golang/example.test/package-evidence"
quote := "pkg:golang/rsc.io/quote"
if !workspace {
quote += "@v1.5.1"
}
require.Contains(t, refs, source)
require.Contains(t, refs, quote)
require.Equal(t, []string{source}, edges[root.Ref], "executable must attach its actual source")
require.Contains(t, edges[source], quote)
const sampler = "pkg:golang/rsc.io/sampler@v1.3.0"
if workspace {
require.NotContains(t, refs, quote+"@v1.5.1")
require.NotContains(t, edges[quote], sampler, "local sibling has no released-version requirements")
} else {
require.NotContains(t, refs, strings.TrimSuffix(quote, "@v1.5.1"))
require.Contains(t, edges[quote], sampler)
}
}
16 changes: 9 additions & 7 deletions pkg/builder/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -384,13 +384,15 @@ func (s *Builder) Package(ctx context.Context, req *builderv0.PackageRequest) (*
}

if req.GetIncludeSbom() {
source, err := sbom.GolangWithOptions(ctx, s.Service.SourceLocation, sbom.GolangOptions{
UseWorkspace: s.Settings.WithWorkspace,
})
if err != nil {
return s.Builder.PackageError(fmt.Errorf("generate package SBOM: %w", err))
}
for _, executable := range append([]*builderv0.PackageArtifact(nil), artifacts...) {
// Inventory the same graph that produced this target. Settings.WithWorkspace
// configures service builds; Package follows the CLI's prepared GOWORK.
source, err := sbom.GolangWithOptions(ctx, s.Service.SourceLocation, sbom.GolangOptions{
UseWorkspace: packageGoWorkMode(executable.GetTarget()) != "off",
})
if err != nil {
return s.Builder.PackageError(fmt.Errorf("generate package SBOM: %w", err))
}
subject := req.GetSubject()
publisher, name, version := "", artifactName, ""
if subject != nil {
Expand Down Expand Up @@ -552,7 +554,7 @@ func packageCrossGoBinaryWithRunner(ctx context.Context, source, entry, destinat
if !supported {
return fmt.Errorf("go package %s: no CGO cross toolchain; supported cross targets are darwin/amd64, darwin/arm64, linux/amd64, linux/arm64", identity)
}
goToolchain, err := resolvePackageGoToolchain(ctx, source)
goToolchain, err := resolvePackageGoToolchain(ctx, source, target)
if err != nil {
return err
}
Expand Down
2 changes: 1 addition & 1 deletion pkg/builder/package_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ func TestPackageRealCGO(t *testing.T) {
t.Fatal(err)
}
}
selectedToolchain, err := resolvePackageGoToolchain(ctx, source)
selectedToolchain, err := resolvePackageGoToolchain(ctx, source, &builderv0.PackageTarget{Os: runtime.GOOS, Architecture: runtime.GOARCH})
if err != nil {
t.Fatal(err)
}
Expand Down
Loading