diff --git a/cmd/test/service.go b/cmd/test/service.go index 42f3acc2c..123f071bf 100644 --- a/cmd/test/service.go +++ b/cmd/test/service.go @@ -4,10 +4,7 @@ import ( "context" "errors" "fmt" - "maps" "os" - "slices" - "strings" "time" "github.com/codefly-dev/cli/cmd/common" @@ -223,40 +220,20 @@ func testEnvironment(workspace *resources.Workspace) (*resources.Environment, er return env, nil } -// verifyOriginReceivesStartInputs refuses a test whose inputs cannot reach the -// service under test. Codefly delivers a service's process overrides and its -// exported runtime environment through StartRequest, and only START_STACK -// actually starts the origin. Running anyway would report a green suite for a -// composition whose CODEFLY__API_CONSUMES and registration secrets were never -// delivered — a false pass, which is worse than a failure. -func verifyOriginReceivesStartInputs(flow *orchestration.Flow, serviceName, fixture string, derived solutionrun.RunInputs) error { - if flow.OriginStartsForTest() || flow.OriginTestSkipped() { - return nil - } - // The fixture reaches every dependency that starts, which is the point of a - // fixture for a dependency-backed suite, so it does not invalidate the run — - // but the origin not seeing it is silent, and silence is what hid this. The - // caller passes the RESOLVED fixture: an environment-declared one is just as - // undeliverable as an explicit --fixture. - if fixture != "" { - cli.Warning( - "fixture %q reaches the dependencies but not %s: dependency mode %s never starts the service under test", - fixture, serviceName, flow.TestDependencyModeName()) - } - var undeliverable []string - if keys := slices.Sorted(maps.Keys(derived.Overrides[serviceName])); len(keys) > 0 { - undeliverable = append(undeliverable, "the solution-derived variables "+strings.Join(keys, ", ")) - } - if outputEnv != "" { - undeliverable = append(undeliverable, - "--output-env (the origin's endpoints, fixture and dependency connections are written at Start)") - } - if len(undeliverable) == 0 { +// verifyOutputEnvReachesOrigin refuses a test that asks for an exported runtime +// environment its origin will never produce. The fixture and the process +// overrides ride InitRequest, which reaches every service, but the exported +// environment is composed inside Start: only there, after the dependency +// barrier, are the origin's own endpoints and its dependencies' connections +// final. A suite that never starts the origin would get a file silently missing +// exactly those, which is worse than being told it cannot be written. +func verifyOutputEnvReachesOrigin(flow *orchestration.Flow, serviceName string) error { + if outputEnv == "" || flow.OriginStartsForTest() || flow.OriginTestSkipped() { return nil } return fmt.Errorf( - "%s runs its tests in dependency mode %s, which never starts the service under test, so %s cannot reach it: select a suite whose agent advertises START_STACK, or drop the inputs that depend on it", - serviceName, flow.TestDependencyModeName(), strings.Join(undeliverable, " and ")) + "%s runs its tests in dependency mode %s, which never starts the service under test, so --output-env cannot be written for it: its endpoints and dependency connections are composed at Start. Select a suite whose agent advertises START_STACK, or drop --output-env", + serviceName, flow.TestDependencyModeName()) } // shouldIsolateInvocation decides whether this test takes a generated identity @@ -333,10 +310,10 @@ func initRunService(ctx context.Context, workspace *resources.Workspace, module return flow, w.Wrap(err) } // Load resolved the origin's dependency mode, so this is the earliest point - // the flow can tell whether what the caller asked for can be delivered at + // the flow can tell whether the exported environment can be composed at // all. Skipped when nothing is going to run anyway. if !loadOnly && !initOnly { - if err = verifyOriginReceivesStartInputs(flow, resources.WithUnique(service).Unique(), selectedFixture, derived); err != nil { + if err = verifyOutputEnvReachesOrigin(flow, resources.WithUnique(service).Unique()); err != nil { return flow, err } } diff --git a/cmd/test/solution_test.go b/cmd/test/solution_test.go index aca33eee8..df0cbe573 100644 --- a/cmd/test/solution_test.go +++ b/cmd/test/solution_test.go @@ -6,9 +6,7 @@ import ( "github.com/codefly-dev/cli/cmd/run" "github.com/codefly-dev/cli/pkg/orchestration" - "github.com/codefly-dev/cli/pkg/solutionrun" "github.com/codefly-dev/core/resources" - "github.com/codefly-dev/core/solution/manifest" "github.com/spf13/cobra" ) @@ -181,44 +179,30 @@ func TestTestEnvironmentSelectsAndScopes(t *testing.T) { } } -// Codefly delivers a service's process overrides and its exported runtime -// environment through StartRequest, and only START_STACK actually starts the -// origin. Booting anyway ran the suite with CODEFLY__API_CONSUMES unset and -// reported it green — a false pass for a composition that was never wired. -func TestVerifyOriginReceivesStartInputsRefusesUndeliverableInputs(t *testing.T) { - priorOutputEnv, priorFixture := outputEnv, testFixture - t.Cleanup(func() { outputEnv, testFixture = priorOutputEnv, priorFixture }) - outputEnv, testFixture = "", "" +// The fixture and the process overrides reach a service under test through +// InitRequest, but the exported runtime environment is composed inside Start, +// which a suite that never starts the origin does not reach. Writing it anyway +// produced a file missing the origin's own endpoints and dependency +// connections, with nothing saying so. +func TestVerifyOutputEnvReachesOriginRefusesAFileItCannotCompose(t *testing.T) { + priorOutputEnv := outputEnv + t.Cleanup(func() { outputEnv = priorOutputEnv }) // A flow that has not resolved START_STACK is what every other dependency // mode looks like to the guard. flow := &orchestration.Flow{} - if err := verifyOriginReceivesStartInputs(flow, "wiki/backend", "", solutionrun.RunInputs{}); err != nil { - t.Fatalf("a test with nothing Start-delivered was refused: %v", err) + outputEnv = "" + if err := verifyOutputEnvReachesOrigin(flow, "wiki/backend"); err != nil { + t.Fatalf("a test that asked for no exported environment was refused: %v", err) } - originOverrides := solutionrun.RunInputs{Overrides: map[string]map[string]string{ - "wiki/backend": {manifest.APIConsumesEnvironmentVariable: "documents:wiki/documents/api"}, - }} - err := verifyOriginReceivesStartInputs(flow, "wiki/backend", "", originOverrides) + outputEnv = "/tmp/codefly-test-env" + err := verifyOutputEnvReachesOrigin(flow, "wiki/backend") if err == nil { - t.Fatal("a solution whose CODEFLY__API_CONSUMES cannot reach the origin was allowed to report a green suite") - } - if !strings.Contains(err.Error(), manifest.APIConsumesEnvironmentVariable) { - t.Errorf("the refusal does not name the undeliverable variable: %v", err) - } - - // Overrides aimed at a DEPENDENCY are deliverable: dependencies do start. - dependencyOverrides := solutionrun.RunInputs{Overrides: map[string]map[string]string{ - "documents/api": {"CODEFLY__MODULE_REGISTRATION_SECRET": "deadbeef"}, - }} - if err := verifyOriginReceivesStartInputs(flow, "wiki/backend", "", dependencyOverrides); err != nil { - t.Fatalf("refused for an override that reaches its dependency normally: %v", err) + t.Fatal("--output-env accepted although the origin's runtime environment is only composed at Start") } - - outputEnv = "/tmp/codefly-test-env" - if err := verifyOriginReceivesStartInputs(flow, "wiki/backend", "", solutionrun.RunInputs{}); err == nil { - t.Fatal("--output-env accepted although the origin's runtime environment is only written at Start") + if !strings.Contains(err.Error(), "--output-env") { + t.Errorf("the refusal does not name the input it is about: %v", err) } } diff --git a/docs/commands.md b/docs/commands.md index 0165a026d..d7dbd9d56 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -302,11 +302,14 @@ behaves differently from `run`: | `--temporary-ports` | **On by default here**, where `run` defaults it off. Each invocation takes ephemeral ports plus a generated naming scope isolating its agents, containers and runtime state — overriding any scope the environment declares — so two concurrent `codefly test` runs in one workspace cannot collide. Pass `--temporary-ports=false` to keep the declared scope and deterministic names, or `--naming-scope` to name the scope yourself | A suite whose agent advertises `START_DEPENDENCIES` or `NONE` never starts the -service under test, so anything Codefly delivers at Start cannot reach it: -process overrides (`CODEFLY__API_CONSUMES`, the federation registration secrets) -and `--output-env`. Codefly refuses such an invocation rather than reporting a -green suite for a composition that was never wired, and warns when `--fixture` -reaches the dependencies but not the origin. +service under test. The fixture and the process overrides +(`CODEFLY__API_CONSUMES`, the federation registration secrets) still reach it: +Codefly delivers both on Init, which every service receives, and an agent takes +the first non-empty of the Init and Start values. `--output-env` is the one +input that cannot follow, because the exported environment is composed inside +Start — only there are the origin's endpoints and its dependencies' connections +final — so Codefly refuses that combination rather than writing a file that +silently omits them. ### `codefly test solution` diff --git a/docs/supported-matrix.md b/docs/supported-matrix.md index 2e6897a29..3f0f3bb30 100644 --- a/docs/supported-matrix.md +++ b/docs/supported-matrix.md @@ -8,7 +8,7 @@ exist, or claims a status the repository cannot back. Every row describes the CLI **built from this checkout** against the `github.com/codefly-dev/core` release line pinned in `go.mod` — the matrix -records the line (`v0.3.29`), not the pseudo-version, so a core bump within a +records the line (`v0.3.32`), not the pseudo-version, so a core bump within a line does not invalidate the claim. Version skew between a released CLI and a released agent is not, by itself, a compatibility failure — a row is about whether a real lifecycle was driven end to end. diff --git a/go.mod b/go.mod index 990fbd2c1..3cef9ce68 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/asottile/dockerfile v3.1.0+incompatible github.com/blang/semver v3.5.1+incompatible github.com/briandowns/spinner v1.23.2 - github.com/codefly-dev/core v0.3.29 + github.com/codefly-dev/core v0.3.32 github.com/codefly-dev/golor v0.1.3 github.com/codefly-dev/llm v0.1.7 github.com/codefly-dev/sdk-go v0.1.65 @@ -34,7 +34,7 @@ require ( golang.org/x/mod v0.41.0 golang.org/x/sync v0.23.0 golang.org/x/sys v0.48.0 - golang.org/x/term v0.45.0 + golang.org/x/term v0.46.0 google.golang.org/grpc v1.83.2 google.golang.org/protobuf v1.36.12 gopkg.in/yaml.v3 v3.0.1 @@ -206,7 +206,7 @@ require ( go.opentelemetry.io/otel v1.46.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0 // indirect - go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.45.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.46.0 // indirect go.opentelemetry.io/otel/metric v1.46.0 // indirect go.opentelemetry.io/otel/sdk v1.46.0 // indirect go.opentelemetry.io/otel/trace v1.46.0 // indirect @@ -214,11 +214,11 @@ require ( go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.6 // indirect - golang.org/x/crypto v0.55.0 // indirect + golang.org/x/crypto v0.57.0 // indirect golang.org/x/exp v0.0.0-20260820142414-ca536658362e // indirect - golang.org/x/net v0.58.0 // indirect + golang.org/x/net v0.59.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect - golang.org/x/text v0.41.0 // indirect + golang.org/x/text v0.42.0 // indirect golang.org/x/time v0.15.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 // indirect diff --git a/go.sum b/go.sum index 486b2b6a5..c277c7491 100644 --- a/go.sum +++ b/go.sum @@ -99,6 +99,14 @@ github.com/cloudflare/circl v1.6.5 h1:O64F26HEqNhznd/hrC5KZXVKYuKM2rx4deZDTc4ihQ github.com/cloudflare/circl v1.6.5/go.mod h1:h5LNyxAc5nTue9DS5jT+48en2PSDYt3zdGnz5OstK6c= github.com/codefly-dev/core v0.3.29 h1:S4jgxOt5cnPX1zfv1xPheskOjfY2UR5gVQeb+AP1DFY= github.com/codefly-dev/core v0.3.29/go.mod h1:guGVP8tyWEjpUBwdP588jXb/BwADsrzlhx7WtmZSn20= +github.com/codefly-dev/core v0.3.32-0.20260915125417-552ee9b9a8c4 h1:f5MOzmaKs54T5XuLxR2ToyMltf4NaJtOYFqmWRhPRFY= +github.com/codefly-dev/core v0.3.32-0.20260915125417-552ee9b9a8c4/go.mod h1:guGVP8tyWEjpUBwdP588jXb/BwADsrzlhx7WtmZSn20= +github.com/codefly-dev/core v0.3.32-0.20260915140418-a1b4974e497f h1:vvxFV2FUJBw+/HMyugal/Tm7Bi+OeLtvoL8OIVIOj5A= +github.com/codefly-dev/core v0.3.32-0.20260915140418-a1b4974e497f/go.mod h1:f5LY9gRu5lpBQ0tNKujP0q6MF9K8fUnbYvItuHaUWlY= +github.com/codefly-dev/core v0.3.32-0.20260915141941-69c123a8f18c h1:PB0H8LYtyeauIc3IzCjZiqlXhA/epaTtKJSXztGbsEw= +github.com/codefly-dev/core v0.3.32-0.20260915141941-69c123a8f18c/go.mod h1:f5LY9gRu5lpBQ0tNKujP0q6MF9K8fUnbYvItuHaUWlY= +github.com/codefly-dev/core v0.3.32 h1:zVgGAljUERRjHe5ONhI2uBuQg0eayZzkFSm0murp5NI= +github.com/codefly-dev/core v0.3.32/go.mod h1:f5LY9gRu5lpBQ0tNKujP0q6MF9K8fUnbYvItuHaUWlY= github.com/codefly-dev/golor v0.1.3 h1:xmo+ceyJFRYZdvpWE2fNd0jeaadp/Ibm1BnganiGKOc= github.com/codefly-dev/golor v0.1.3/go.mod h1:sl/u/K1l7J0Pr3xyVZp8fOJYQItKKst1No9JqgzLLoY= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= @@ -488,6 +496,8 @@ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3P go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.45.0 h1:lsA/S1bxgdbyFGkTj+3meEdJ6ADVU7QoFstV6MXgE68= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.45.0/go.mod h1:L7u+MirGoB1bjeLH66+xDykF4RC8C3RN7lIFpBiewUo= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.46.0 h1:KdRxPiAoMptR3vfWzvjjvutTsSiwbC2uG0496rzZNfo= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.46.0/go.mod h1:K/qSA+3G7Eovxi4K09wzrAgkWRnosS0DAOZeEpve7sM= go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8= go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o= go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI= @@ -513,6 +523,8 @@ golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5 golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/exp v0.0.0-20260820142414-ca536658362e h1:01Ju2A/fZKkci4zqx0eZxw//DnRYOnBiGJG14hFBhO8= golang.org/x/exp v0.0.0-20260820142414-ca536658362e/go.mod h1:zeBbvyFKDaLwa7CH/zI8KXt7gTl14SF7sO08Pl5jBCM= golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= @@ -522,6 +534,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= +golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= @@ -544,11 +558,15 @@ golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= +golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/pkg/conformance/matrix.json b/pkg/conformance/matrix.json index e4af4394b..4f0086be5 100644 --- a/pkg/conformance/matrix.json +++ b/pkg/conformance/matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "cli": "source", - "core": "v0.3.29", + "core": "v0.3.32", "rows": [ { "id": "linux-amd64-source", diff --git a/pkg/orchestration/build_plan.go b/pkg/orchestration/build_plan.go index 9f22a38e9..49cf4af2c 100644 --- a/pkg/orchestration/build_plan.go +++ b/pkg/orchestration/build_plan.go @@ -112,8 +112,11 @@ func (b *Builder) buildRecipe( // targeted single-service build returns to its caller. A non-pushed build // never lands in a registry, so there is no digest to capture. captureDigest := shouldPush && (b.world.Mode == SnapshotMode || b.world.CaptureImageDigest) + // Image evidence is owed by every recipe, not only the one a snapshot pins, + // so asking for it needs the digest of each pushed image too. + resolveEvidence := b.world.CollectImageSBOM var metadataFile string - if captureDigest { + if captureDigest || (shouldPush && resolveEvidence) { file, err := os.CreateTemp("", "codefly-build-metadata-*.json") if err != nil { return w.Wrapf(err, "cannot stage build metadata for %s", b.instance.Unique()) @@ -139,7 +142,7 @@ func (b *Builder) buildRecipe( w.Info("image build completed", wool.Field("image", recipe.GetImage()), wool.Field("duration", time.Since(started))) - if captureDigest { + if metadataFile != "" { digest, err := readPushedImageDigest(metadataFile) switch { case err != nil && b.world.Mode == SnapshotMode: @@ -148,11 +151,23 @@ func (b *Builder) buildRecipe( return w.Wrapf(err, "cannot resolve immutable image for %s", b.instance.Unique()) case err != nil: // The image is already pushed; the digest is only reported. Failing - // the build here would wrongly signal that the push failed. + // the build here would wrongly signal that the push failed. Evidence + // derivation refuses the unpinned subject on its own. w.Warn("built and pushed but could not resolve image digest", wool.ErrField(err)) default: - b.imageDigest = digest + if captureDigest { + b.imageDigest = digest + } + b.recordPushedImage(recipe, digest) + } + } + + if resolveEvidence && !shouldPush { + imageID, err := inspectLocalImageID(ctx, recipe.GetImage()) + if err != nil { + return w.Wrapf(err, "cannot resolve the loaded image of recipe %s for %s", recipe.GetName(), b.instance.Unique()) } + b.recordLoadedImage(recipe, imageID) } return nil } diff --git a/pkg/orchestration/build_sbom.go b/pkg/orchestration/build_sbom.go index 752063467..bc601ffbe 100644 --- a/pkg/orchestration/build_sbom.go +++ b/pkg/orchestration/build_sbom.go @@ -2,10 +2,14 @@ package orchestration import ( "context" + "fmt" + "os/exec" + "strings" coresbom "github.com/codefly-dev/core/agents/services/sbom" builderv0 "github.com/codefly-dev/core/generated/go/codefly/services/builder/v0" "github.com/codefly-dev/core/wool" + "google.golang.org/protobuf/proto" ) // collectImageEvidence requires a digest-bound CycloneDX inventory for every @@ -23,7 +27,10 @@ func (b *Builder) collectImageEvidence(ctx context.Context, plan *builderv0.Dock var expected []*builderv0.ImageSubject switch { case plan != nil: - expected = producedSubjects(service, plan, b.world.Push) + var err error + if expected, err = producedSubjects(service, plan, b.world.Push, b.resolvedImages); err != nil { + return w.Wrapf(err, "cannot derive image SBOM coverage for %s", service) + } case result != nil: expected = coresbom.ExpectedFromBuildResult(service, result) } @@ -35,34 +42,88 @@ func (b *Builder) collectImageEvidence(ctx context.Context, plan *builderv0.Dock if err != nil { return w.Wrapf(err, "cannot collect image SBOM for %s", service) } - if err := coresbom.ValidateCoverage(expected, response); err != nil { + if err := coresbom.ValidateCoverage(service, expected, response); err != nil { return w.Wrapf(err, "image SBOM coverage for %s", service) } b.imageEvidence = response.GetImages() return nil } -// producedSubjects narrows the recipe's declared coverage to the images this -// build actually made. A push writes every declared platform into one manifest -// list, so all of them are owed evidence. A local build loads a single platform -// per recipe — the same first one buildxArgs selects — and demanding evidence -// for the rest fails the build over images that were never built. -func producedSubjects(service string, plan *builderv0.DockerBuildPlan, push bool) []*builderv0.ImageSubject { - declared := coresbom.ExpectedFromBuildPlan(service, plan) +// producedSubjects derives the coverage owed by the images this build actually +// made, each pinned to the identity the build resolved for it. A subject +// carrying only a tag would bind evidence to whatever that tag serves rather +// than to the image that was built, so a recipe the build resolved nothing for +// is refused here rather than answered with evidence about something else. +func producedSubjects(service string, plan *builderv0.DockerBuildPlan, push bool, resolved []coresbom.ResolvedImage) ([]*builderv0.ImageSubject, error) { + return coresbom.ExpectedFromBuildPlan(service, producedPlan(plan, push), resolved) +} + +// producedPlan restricts the plan to what buildx was asked to produce. A push +// writes every declared platform into one manifest list, so all of them are +// owed evidence. A local build loads a single platform per recipe — the same +// first one buildxArgs selects — and demanding evidence for the rest fails the +// build over images that were never built. +func producedPlan(plan *builderv0.DockerBuildPlan, push bool) *builderv0.DockerBuildPlan { if push { - return declared + return plan } - loaded := map[string]string{} + recipes := make([]*builderv0.DockerBuildRecipe, 0, len(plan.GetRecipes())) for _, recipe := range plan.GetRecipes() { - if platforms := recipe.GetPlatforms(); len(platforms) > 0 { - loaded[recipe.GetName()] = platforms[0] + if len(recipe.GetPlatforms()) > 1 { + loaded, _ := proto.Clone(recipe).(*builderv0.DockerBuildRecipe) + loaded.Platforms = recipe.GetPlatforms()[:1] + recipe = loaded } + recipes = append(recipes, recipe) } - var produced []*builderv0.ImageSubject - for _, subject := range declared { - if platform, stated := loaded[subject.GetRole()]; !stated || subject.GetPlatform() == platform { - produced = append(produced, subject) - } + return &builderv0.DockerBuildPlan{Recipes: recipes} +} + +// recordPushedImage binds every platform of a pushed recipe to the manifest +// list the push produced: buildx writes all of them into one index, whose +// digest is the identity a scan resolves each platform's image out of. +func (b *Builder) recordPushedImage(recipe *builderv0.DockerBuildRecipe, digest string) { + for _, platform := range recipePlatforms(recipe) { + b.resolvedImages = append(b.resolvedImages, coresbom.ResolvedImage{ + Recipe: recipe.GetName(), + Platform: platform, + Digest: digest, + }) + } +} + +// recordLoadedImage binds the one platform a local build loads to the image ID +// the daemon holds. A never-pushed image has no registry manifest, so that ID +// is the only immutable identity evidence about it can be bound to. +func (b *Builder) recordLoadedImage(recipe *builderv0.DockerBuildRecipe, imageID string) { + b.resolvedImages = append(b.resolvedImages, coresbom.ResolvedImage{ + Recipe: recipe.GetName(), + Platform: recipePlatforms(recipe)[0], + Digest: imageID, + Source: coresbom.SourceDockerDaemon, + }) +} + +// recipePlatforms is the recipe's declared platforms, or the single unnamed one +// a recipe that declares none is built for. +func recipePlatforms(recipe *builderv0.DockerBuildRecipe) []string { + if platforms := recipe.GetPlatforms(); len(platforms) > 0 { + return platforms + } + return []string{""} +} + +// inspectLocalImageID resolves the image ID the daemon holds for a loaded +// image. inspectImageDigest reads RepoDigests, which an image that was never +// pushed does not have. +func inspectLocalImageID(ctx context.Context, image string) (string, error) { + output, err := exec.CommandContext(ctx, "docker", "image", "inspect", "--format", "{{.Id}}", image).Output() + if err != nil { + return "", fmt.Errorf("inspect %s image id: %w", image, err) + } + id := strings.TrimSpace(string(output)) + if !sha256Digest.MatchString(id) { + return "", fmt.Errorf("image %s resolves to %q, which is not a sha256 image id", image, id) } - return produced + return id, nil } diff --git a/pkg/orchestration/build_sbom_test.go b/pkg/orchestration/build_sbom_test.go index bf1205f05..22c2a6dbf 100644 --- a/pkg/orchestration/build_sbom_test.go +++ b/pkg/orchestration/build_sbom_test.go @@ -3,10 +3,16 @@ package orchestration import ( "testing" + coresbom "github.com/codefly-dev/core/agents/services/sbom" builderv0 "github.com/codefly-dev/core/generated/go/codefly/services/builder/v0" "github.com/stretchr/testify/require" ) +const ( + pushedIndexDigest = "sha256:1111111111111111111111111111111111111111111111111111111111111111" + loadedImageID = "sha256:2222222222222222222222222222222222222222222222222222222222222222" +) + func multiPlatformPlan() *builderv0.DockerBuildPlan { return &builderv0.DockerBuildPlan{Recipes: []*builderv0.DockerBuildRecipe{ {Name: "app", Image: "ghcr.io/org/app:v1", Platforms: []string{"linux/amd64", "linux/arm64"}}, @@ -14,8 +20,26 @@ func multiPlatformPlan() *builderv0.DockerBuildPlan { }} } +// A push writes every platform into one manifest list, so each of them resolves +// to that index digest. +func pushedImages() []coresbom.ResolvedImage { + return []coresbom.ResolvedImage{ + {Recipe: "app", Platform: "linux/amd64", Digest: pushedIndexDigest}, + {Recipe: "app", Platform: "linux/arm64", Digest: pushedIndexDigest}, + {Recipe: "migration", Platform: "linux/amd64", Digest: pushedIndexDigest}, + } +} + +func loadedImages() []coresbom.ResolvedImage { + return []coresbom.ResolvedImage{ + {Recipe: "app", Platform: "linux/amd64", Digest: loadedImageID, Source: coresbom.SourceDockerDaemon}, + {Recipe: "migration", Platform: "linux/amd64", Digest: loadedImageID, Source: coresbom.SourceDockerDaemon}, + } +} + func TestProducedSubjectsCoverEveryPlatformOfAPushedBuild(t *testing.T) { - subjects := producedSubjects("app/api", multiPlatformPlan(), true) + subjects, err := producedSubjects("app/api", multiPlatformPlan(), true, pushedImages()) + require.NoError(t, err) require.Len(t, subjects, 3, "a push writes every declared platform into one manifest list") platforms := map[string][]string{} @@ -24,13 +48,16 @@ func TestProducedSubjectsCoverEveryPlatformOfAPushedBuild(t *testing.T) { } require.Equal(t, []string{"linux/amd64", "linux/arm64"}, platforms["app"]) require.Equal(t, []string{"linux/amd64"}, platforms["migration"]) + require.Equal(t, "ghcr.io/org/app@"+pushedIndexDigest, subjects[0].GetReference(), + "a subject carrying only the recipe's tag binds evidence to whatever that tag serves") } // A local build loads one platform per recipe. Expecting the declared set here // demanded evidence for an image buildx was never asked to build, so every // multi-platform recipe failed its own build. func TestProducedSubjectsCoverOnlyTheLoadedPlatformOfALocalBuild(t *testing.T) { - subjects := producedSubjects("app/api", multiPlatformPlan(), false) + subjects, err := producedSubjects("app/api", multiPlatformPlan(), false, loadedImages()) + require.NoError(t, err) require.Len(t, subjects, 2, "only the first platform of each recipe is loaded") for _, subject := range subjects { @@ -38,6 +65,11 @@ func TestProducedSubjectsCoverOnlyTheLoadedPlatformOfALocalBuild(t *testing.T) { } roles := []string{subjects[0].GetRole(), subjects[1].GetRole()} require.ElementsMatch(t, []string{"app", "migration"}, roles, "every recipe still owes evidence") + + // A never-pushed image has no registry manifest, so it keeps the tag the + // daemon knows and binds to the local image ID instead. + require.Equal(t, "ghcr.io/org/app:v1", subjects[0].GetReference()) + require.Equal(t, loadedImageID, subjects[0].GetDigest()) } // A recipe that states no platform is built for the host's own, and the plan @@ -47,6 +79,34 @@ func TestProducedSubjectsKeepARecipeThatStatesNoPlatform(t *testing.T) { {Name: "app", Image: "ghcr.io/org/app:v1"}, }} - require.Len(t, producedSubjects("app/api", plan, false), 1) - require.Len(t, producedSubjects("app/api", plan, true), 1) + local, err := producedSubjects("app/api", plan, false, + []coresbom.ResolvedImage{{Recipe: "app", Digest: loadedImageID, Source: coresbom.SourceDockerDaemon}}) + require.NoError(t, err) + require.Len(t, local, 1) + + pushed, err := producedSubjects("app/api", plan, true, + []coresbom.ResolvedImage{{Recipe: "app", Digest: pushedIndexDigest}}) + require.NoError(t, err) + require.Len(t, pushed, 1) +} + +// Evidence is owed against the image the build produced, which only the digest +// the build resolved can name. Deriving a subject for a recipe nothing resolved +// would ask the agent to scan a floating tag and accept the answer as coverage. +func TestProducedSubjectsRefuseARecipeTheBuildResolvedNothingFor(t *testing.T) { + _, err := producedSubjects("app/api", multiPlatformPlan(), true, nil) + + require.Error(t, err) + require.Contains(t, err.Error(), "app") +} + +// The plan belongs to the agent that emitted it and is recorded as the durable +// build recipe; narrowing it to the loaded platform must not edit it in place. +func TestProducedSubjectsLeaveTheEmittedPlanUntouched(t *testing.T) { + plan := multiPlatformPlan() + + _, err := producedSubjects("app/api", plan, false, loadedImages()) + require.NoError(t, err) + + require.Equal(t, []string{"linux/amd64", "linux/arm64"}, plan.GetRecipes()[0].GetPlatforms()) } diff --git a/pkg/orchestration/builder.go b/pkg/orchestration/builder.go index d995d6806..4e7b3ee85 100644 --- a/pkg/orchestration/builder.go +++ b/pkg/orchestration/builder.go @@ -17,6 +17,7 @@ import ( "github.com/codefly-dev/cli/pkg/builder" dockerhelpers "github.com/codefly-dev/core/agents/helpers/docker" coreservices "github.com/codefly-dev/core/agents/services" + coresbom "github.com/codefly-dev/core/agents/services/sbom" "github.com/codefly-dev/core/services" builderv0 "github.com/codefly-dev/core/generated/go/codefly/services/builder/v0" @@ -49,7 +50,8 @@ type Builder struct { push bool imageDigest string - imageEvidence []*builderv0.ImageSBOM + imageEvidence []*builderv0.ImageSBOM + resolvedImages []coresbom.ResolvedImage syncResponse *builderv0.SyncResponse syncSkipped bool diff --git a/pkg/orchestration/runner.go b/pkg/orchestration/runner.go index e3efe1d76..ca8fb7483 100644 --- a/pkg/orchestration/runner.go +++ b/pkg/orchestration/runner.go @@ -332,6 +332,11 @@ func (runner *Runner) Init(ctx context.Context) (*OutputProperty, error) { wool.Field("project configurations", resources.MakeManyConfigurationSummary(workspaceConfigurations)), wool.Field("dependencies configurations", resources.MakeManyConfigurationSummary(dependenciesConfigurations))) + // Init is the only lifecycle call guaranteed to reach a service under test: + // a test policy replaces the origin's Start with a barrier or skips it + // altogether, so a fixture or an override delivered only at Start reaches + // every dependency and never the service the suite is about. Start still + // carries both, and the agent takes the first non-empty. req := &runtimev0.InitRequest{ RuntimeContext: runtimeContext, ProposedNetworkMappings: networkMappings, @@ -339,6 +344,8 @@ func (runner *Runner) Init(ctx context.Context) (*OutputProperty, error) { Configuration: conf, WorkspaceConfigurations: workspaceConfigurations, DependenciesConfigurations: dependenciesConfigurations, + Fixture: runner.fixture, + Overrides: runner.runtimeOverrides(), } err = resources.Validate(req) if err != nil { diff --git a/pkg/orchestration/runner_accepted_network_mappings_test.go b/pkg/orchestration/runner_accepted_network_mappings_test.go index 3eec5c896..256955567 100644 --- a/pkg/orchestration/runner_accepted_network_mappings_test.go +++ b/pkg/orchestration/runner_accepted_network_mappings_test.go @@ -47,6 +47,7 @@ const ( realAgentForeign = "foreign" // claims another service's endpoint realAgentUnknown = "unknown" // invents an endpoint that was never proposed realAgentDuplicate = "duplicate" // returns two native views for one endpoint + realAgentInputs = "inputs" // echoes back the invocation inputs Init carried ) // exposedConfigurationName marks the runtime configuration the agent exports on @@ -133,7 +134,7 @@ func (agent *realAgent) Init(_ context.Context, req *runtimev0.InitRequest) (*ru // A pre-contract agent answers READY without naming any mapping. return &runtimev0.InitResponse{Status: &runtimev0.InitStatus{State: runtimev0.InitStatus_READY}}, nil } - if agent.mode != realAgentEcho { + if agent.mode != realAgentEcho && agent.mode != realAgentInputs { var err error if mappings, err = boundMappings(mappings); err != nil { return nil, err @@ -155,20 +156,32 @@ func (agent *realAgent) Init(_ context.Context, req *runtimev0.InitRequest) (*ru network.Native(mappings[0].GetEndpoint(), 65000)) } return &runtimev0.InitResponse{ - Status: &runtimev0.InitStatus{State: runtimev0.InitStatus_READY}, - NetworkMappings: mappings, - RuntimeConfigurations: []*basev0.Configuration{{ - Origin: "web/gateway", - Infos: []*basev0.ConfigurationInformation{{ - Name: exposedConfigurationName, - ConfigurationValues: []*basev0.ConfigurationValue{ - {Key: "session", Value: "exposed"}, - }, - }}, - }}, + Status: &runtimev0.InitStatus{State: runtimev0.InitStatus_READY}, + NetworkMappings: mappings, + RuntimeConfigurations: exportedConfigurations(agent.mode, req), }, nil } +// exportedConfigurations is what the agent exports from its Init. The inputs +// mode echoes the invocation inputs the request carried, so the parent asserts +// on what actually crossed the process boundary rather than on its own intent. +func exportedConfigurations(mode string, req *runtimev0.InitRequest) []*basev0.Configuration { + values := []*basev0.ConfigurationValue{{Key: "session", Value: "exposed"}} + if mode == realAgentInputs { + values = []*basev0.ConfigurationValue{{Key: "fixture", Value: req.GetFixture()}} + for key, value := range req.GetOverrides() { + values = append(values, &basev0.ConfigurationValue{Key: key, Value: value}) + } + } + return []*basev0.Configuration{{ + Origin: "web/gateway", + Infos: []*basev0.ConfigurationInformation{{ + Name: exposedConfigurationName, + ConfigurationValues: values, + }}, + }} +} + func (agent *realAgent) Stop(context.Context, *runtimev0.StopRequest) (*runtimev0.StopResponse, error) { return &runtimev0.StopResponse{Status: &runtimev0.StopStatus{State: runtimev0.StopStatus_SUCCESS}}, nil } diff --git a/pkg/orchestration/runner_init_inputs_test.go b/pkg/orchestration/runner_init_inputs_test.go new file mode 100644 index 000000000..35a406233 --- /dev/null +++ b/pkg/orchestration/runner_init_inputs_test.go @@ -0,0 +1,61 @@ +package orchestration + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +// exposedValues reads back what the agent process exported from its Init, keyed +// by name. +func exposedValues(ctx context.Context, t *testing.T, world *World) map[string]string { + t.Helper() + shared, err := world.ConfigurationManager.GetSharedServiceConfiguration(ctx, "web/gateway") + require.NoError(t, err) + require.Len(t, shared, 1) + values := map[string]string{} + for _, info := range shared[0].GetInfos() { + for _, value := range info.GetConfigurationValues() { + values[value.GetKey()] = value.GetValue() + } + } + return values +} + +// A service under test is not always started: a START_DEPENDENCIES policy +// replaces its Start with a sequencing barrier and NONE skips Start entirely, +// so inputs carried only by StartRequest reach every dependency and never the +// service the suite is about. Init is the one call that always arrives, and +// both inputs are asserted here across a real agent process because the defect +// was precisely that they were put on the wrong request. +func TestInitCarriesTheFixtureAndOverridesToAServiceThatIsNeverStarted(t *testing.T) { + ctx := context.Background() + runner, world := gatewayRunner(t, startRealAgent(t, realAgentInputs)) + runner.WithFixture("dev-admin") + runner.WithOverrides(map[string]string{"CODEFLY__API_CONSUMES": "documents:wiki/documents/api"}) + + _, err := runner.Init(ctx) + require.NoError(t, err) + + observed := exposedValues(ctx, t, world) + require.Equal(t, "dev-admin", observed["fixture"], + "the agent's Init never saw the fixture the invocation selected") + require.Equal(t, "documents:wiki/documents/api", observed["CODEFLY__API_CONSUMES"], + "the agent's Init never saw the process overrides a solution entry federates with") +} + +// An invocation that selects neither must not stamp empty values on the +// service: an agent takes the first non-empty of the Init and Start values, and +// an empty string delivered as if it were a selection is not the same as none. +func TestInitCarriesNoFixtureOrOverridesWhenTheInvocationSelectsNone(t *testing.T) { + ctx := context.Background() + runner, world := gatewayRunner(t, startRealAgent(t, realAgentInputs)) + + _, err := runner.Init(ctx) + require.NoError(t, err) + + observed := exposedValues(ctx, t, world) + require.Equal(t, "", observed["fixture"]) + require.NotContains(t, observed, "CODEFLY__API_CONSUMES") +}