diff --git a/agent.codefly.yaml b/agent.codefly.yaml index c8df78d..c81eec9 100644 --- a/agent.codefly.yaml +++ b/agent.codefly.yaml @@ -1,4 +1,4 @@ publisher: codefly.dev kind: codefly:service name: go-grpc -version: 0.1.38 +version: 0.1.39 diff --git a/builder_recipe_test.go b/builder_recipe_test.go index 609236d..f842656 100644 --- a/builder_recipe_test.go +++ b/builder_recipe_test.go @@ -3,20 +3,16 @@ package main import ( "context" "io/fs" - "net" "os" "path/filepath" "testing" "github.com/codefly-dev/core/agents/services" - basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" builderv0 "github.com/codefly-dev/core/generated/go/codefly/services/builder/v0" "github.com/codefly-dev/core/resources" golanghelpers "github.com/codefly-dev/core/runners/golang" "github.com/codefly-dev/core/templates" "github.com/stretchr/testify/require" - "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" ) // renderBuilderTree renders the real builder templates into dir, mirroring what @@ -84,42 +80,9 @@ func TestRecipeBuildPlanRejectsUnrenderedTree(t *testing.T) { func TestBuildRecipeOverGRPC(t *testing.T) { ctx := context.Background() - tmpDir := t.TempDir() - - service := &resources.Service{Name: "svc", Version: "0.0.0"} - require.NoError(t, service.SaveAtDir(ctx, filepath.Join(tmpDir, "mod/svc"))) - service.WithModule("mod") - require.NoError(t, (&resources.Module{Name: "mod"}).SaveToDir(ctx, filepath.Join(tmpDir, "mod"))) - - identity := &basev0.ServiceIdentity{ - Name: service.Name, - Version: service.Version, - Module: "mod", - Workspace: "test", - WorkspacePath: tmpDir, - RelativeToWorkspace: "mod/svc", - } - - builder := NewBuilder(NewService()) - // CreationMode short-circuits endpoint discovery, which the recipe path does - // not need — it renders templates and inventories the tree, never compiling. - _, err := builder.Load(ctx, &builderv0.LoadRequest{Identity: identity, CreationMode: &builderv0.CreationMode{}}) - require.NoError(t, err) - - listener, err := net.Listen("tcp", "127.0.0.1:0") - require.NoError(t, err) - server := grpc.NewServer() - builderv0.RegisterBuilderServer(server, builder) - go func() { - if err := server.Serve(listener); err != nil { - t.Errorf("serve builder: %v", err) - } - }() - t.Cleanup(server.Stop) - conn, err := grpc.NewClient(listener.Addr().String(), grpc.WithTransportCredentials(insecure.NewCredentials())) - require.NoError(t, err) - t.Cleanup(func() { require.NoError(t, conn.Close()) }) - client := services.NewBuilderAgentClient(conn) + identity := goGrpcServiceFixture(t) + tmpDir := identity.GetWorkspacePath() + client, builder := startBuilderAgent(t, identity) t.Setenv("PATH", t.TempDir()) // Core's client probes this before dispatching a build that names an explicit diff --git a/builder_sbom_coverage_test.go b/builder_sbom_coverage_test.go new file mode 100644 index 0000000..acd6598 --- /dev/null +++ b/builder_sbom_coverage_test.go @@ -0,0 +1,194 @@ +package main + +import ( + "context" + "errors" + "net" + "os" + "path/filepath" + "testing" + + "github.com/codefly-dev/core/agents/services" + "github.com/codefly-dev/core/agents/services/sbom" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + builderv0 "github.com/codefly-dev/core/generated/go/codefly/services/builder/v0" + "github.com/codefly-dev/core/resources" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +// fixtureGoVersion is the go directive written into fixture modules. It is +// deliberately not this agent's GoVersion: the fixtures assert nothing about the +// toolchain, and naming a patch release newer than the one running the tests +// makes `go list` download a toolchain before it will answer. +const fixtureGoVersion = "1.21" + +// goGrpcServiceFixture writes the on-disk service a Builder.Load accepts, plus a +// self-contained Go module at the source location so the source inventory +// resolves without reaching the network. +func goGrpcServiceFixture(t *testing.T) *basev0.ServiceIdentity { + t.Helper() + + ctx := context.Background() + workspacePath := t.TempDir() + serviceDir := filepath.Join(workspacePath, "mod/svc") + + service := &resources.Service{Name: "svc", Version: "0.0.0"} + require.NoError(t, service.SaveAtDir(ctx, serviceDir)) + service.WithModule("mod") + require.NoError(t, (&resources.Module{Name: "mod"}).SaveToDir(ctx, filepath.Join(workspacePath, "mod"))) + + codeDir := filepath.Join(serviceDir, "code") + require.NoError(t, os.MkdirAll(codeDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(codeDir, "go.mod"), []byte("module example.com/svc\n\ngo "+fixtureGoVersion+"\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(codeDir, "main.go"), []byte("package main\n\nfunc main() {}\n"), 0o644)) + + return &basev0.ServiceIdentity{ + Name: service.Name, + Version: service.Version, + Module: "mod", + Workspace: "test", + WorkspacePath: workspacePath, + RelativeToWorkspace: "mod/svc", + } +} + +// startBuilderAgent serves this specialization's Builder over gRPC and returns a +// client plus the Builder itself, for callers that need agent state the wire +// does not expose. Build is go-grpc's own and SBOM is inherited from the generic +// Go builder, so only the wire surface proves the composed agent answers both. +func startBuilderAgent(t *testing.T, identity *basev0.ServiceIdentity) (*services.BuilderAgent, *Builder) { + t.Helper() + + builder := NewBuilder(NewService()) + // CreationMode short-circuits endpoint discovery, which neither the recipe + // nor the inventory path needs — they render templates and read the module + // graph, never compiling. + _, err := builder.Load(context.Background(), &builderv0.LoadRequest{Identity: identity, CreationMode: &builderv0.CreationMode{}}) + require.NoError(t, err) + + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + server := grpc.NewServer() + builderv0.RegisterBuilderServer(server, builder) + + // Serve's result is handed back through a channel and reported during + // cleanup rather than logged from the goroutine: a t.Errorf that lands after + // the test function returns panics the whole package instead of failing one + // test. Cleanups run last-registered-first, so the client below is closed + // before the server is stopped. + served := make(chan error, 1) + go func() { served <- server.Serve(listener) }() + t.Cleanup(func() { + server.Stop() + if err := <-served; err != nil && !errors.Is(err, grpc.ErrServerStopped) { + t.Errorf("serve builder: %v", err) + } + }) + + conn, err := grpc.NewClient(listener.Addr().String(), grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, conn.Close()) }) + return services.NewBuilderAgentClient(conn), builder +} + +// buildRecipePlan drives this specialization's own Build and returns the plan it +// emits. The image subjects a caller must cover are derived from this plan, so +// coverage is measured against what go-grpc actually ships rather than a +// restatement of it. +func buildRecipePlan(ctx context.Context, t *testing.T, client *services.BuilderAgent, identity *basev0.ServiceIdentity) *builderv0.DockerBuildPlan { + t.Helper() + + resp, err := client.Build(ctx, &builderv0.BuildRequest{ + OutputDirectory: filepath.Join(identity.GetWorkspacePath(), identity.GetRelativeToWorkspace(), "builder"), + BuildContext: &builderv0.BuildContext{Kind: &builderv0.BuildContext_DockerBuildContext{ + DockerBuildContext: &builderv0.DockerBuildContext{DockerRepository: "registry.example.com"}, + }}, + }) + require.NoError(t, err) + require.Equal(t, builderv0.BuildStatus_SUCCESS, resp.GetState().GetState(), resp.GetState().GetMessage()) + + plan := resp.GetResult().GetDockerBuildPlan() + require.NotNil(t, plan) + return plan +} + +// TestSourceInventoryRemainsAvailable guards the half of the contract that was +// already here: image coverage is separate evidence, and adopting it must not +// cost the Go source inventory. The document's root component is asserted +// because a fixture module with no dependencies yields an empty component list, +// so a checksum alone would also be produced by an inventory that resolved +// nothing at all. +func TestSourceInventoryRemainsAvailable(t *testing.T) { + ctx := context.Background() + identity := goGrpcServiceFixture(t) + client, _ := startBuilderAgent(t, identity) + + resp, err := client.SBOM(ctx, &builderv0.SBOMRequest{}) + require.NoError(t, err) + require.Equal(t, builderv0.SBOMStatus_COMPLETE, resp.GetState().GetState(), resp.GetState().GetMessage()) + require.Equal(t, builderv0.SBOMScope_SBOM_SCOPE_SOURCE, resp.GetScope()) + require.NotEmpty(t, resp.GetSha256()) + require.Equal(t, "pkg:golang/example.com/svc", resp.GetBom().GetMetadata().GetComponent().GetPurl()) +} + +// TestImageScopeWithoutSubjectsIsAPreconditionFailure proves the inherited +// image-scope path is reachable through the composed agent. This specialization +// overrides Load and Build and embeds the generic builder for the rest, so only +// the wire surface distinguishes an image-scope request that reaches the shared +// implementation from one that silently falls back to a source inventory — which +// is exactly what this agent did before the contract was adopted. +func TestImageScopeWithoutSubjectsIsAPreconditionFailure(t *testing.T) { + ctx := context.Background() + identity := goGrpcServiceFixture(t) + client, _ := startBuilderAgent(t, identity) + + resp, err := client.SBOM(ctx, &builderv0.SBOMRequest{Scope: builderv0.SBOMScope_SBOM_SCOPE_IMAGE}) + require.NoError(t, err) + require.Equal(t, builderv0.SBOMStatus_ERROR, resp.GetState().GetState()) + require.Equal(t, builderv0.SBOMScope_SBOM_SCOPE_IMAGE, resp.GetScope()) + require.Equal(t, basev0.FailureCode_FAILURE_CODE_PRECONDITION_FAILED, resp.GetState().GetFailure().GetCode()) +} + +// TestPlanDerivedSubjectsAreRefusedUntilTheyCarryADigest closes the loop between +// this specialization's recipe and the evidence contract. The recipe names the +// image the caller has yet to build, so subjects derived from the plan carry a +// tag and no digest. Handing them straight back must not yield coverage for +// whatever that tag resolves to when the scan runs. +// +// It also pins the shape of that subject set: one per shipped platform, since +// evidence for one architecture of a multi-architecture image is not coverage of +// the other, and a recipe narrowed to a single platform would silently shrink +// what a caller is required to produce. +func TestPlanDerivedSubjectsAreRefusedUntilTheyCarryADigest(t *testing.T) { + ctx := context.Background() + identity := goGrpcServiceFixture(t) + client, _ := startBuilderAgent(t, identity) + + plan := buildRecipePlan(ctx, t, client, identity) + require.Len(t, plan.GetRecipes(), 1) + recipe := plan.GetRecipes()[0] + require.NotEmpty(t, recipe.GetPlatforms()) + + unique := resources.ServiceUnique(identity.GetModule(), identity.GetName()) + expected := sbom.ExpectedFromBuildPlan(unique, plan) + + require.Len(t, expected, len(recipe.GetPlatforms())) + for i, platform := range recipe.GetPlatforms() { + require.Equal(t, platform, expected[i].GetPlatform()) + require.Equal(t, recipe.GetName(), expected[i].GetRole()) + require.Equal(t, recipe.GetImage(), expected[i].GetReference()) + require.Equal(t, unique, expected[i].GetService()) + require.Empty(t, expected[i].GetDigest()) + } + + resp, err := client.SBOM(ctx, &builderv0.SBOMRequest{ + Scope: builderv0.SBOMScope_SBOM_SCOPE_IMAGE, + Subjects: expected, + }) + require.NoError(t, err) + require.Equal(t, builderv0.SBOMStatus_ERROR, resp.GetState().GetState()) + require.Equal(t, builderv0.SBOMScope_SBOM_SCOPE_IMAGE, resp.GetScope()) + require.Error(t, sbom.ValidateCoverage(expected, resp)) +} diff --git a/go.mod b/go.mod index 57e59a6..d5047ef 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( buf.build/go/protovalidate v1.4.0 github.com/bufbuild/protocompile v0.14.1 github.com/codefly-dev/core v0.3.29 - github.com/codefly-dev/service-go v0.0.48 + github.com/codefly-dev/service-go v0.0.49 github.com/stretchr/testify v1.12.1 golang.org/x/mod v0.40.0 golang.org/x/tools v0.49.0 diff --git a/go.sum b/go.sum index 6a9c92a..52cdf39 100644 --- a/go.sum +++ b/go.sum @@ -49,8 +49,8 @@ github.com/codefly-dev/core v0.3.29 h1:S4jgxOt5cnPX1zfv1xPheskOjfY2UR5gVQeb+AP1D github.com/codefly-dev/core v0.3.29/go.mod h1:guGVP8tyWEjpUBwdP588jXb/BwADsrzlhx7WtmZSn20= 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/service-go v0.0.48 h1:sUvAbLx4dBGveBjL2s7hRslDlSHGVqkAt65uZyBIdxI= -github.com/codefly-dev/service-go v0.0.48/go.mod h1:jcU+du5HaYDplpTQ1Ej1XXgkCwpJPlJEb82Smb7ogMM= +github.com/codefly-dev/service-go v0.0.49 h1:rh9WhsZkGgsgx9CNqZJ0yXln8jbXoRfalj+GzlePgUo= +github.com/codefly-dev/service-go v0.0.49/go.mod h1:jcU+du5HaYDplpTQ1Ej1XXgkCwpJPlJEb82Smb7ogMM= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=