Skip to content
Merged
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 agent.codefly.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
publisher: codefly.dev
kind: codefly:service
name: go-grpc
version: 0.1.38
version: 0.1.39
43 changes: 3 additions & 40 deletions builder_recipe_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
194 changes: 194 additions & 0 deletions builder_sbom_coverage_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
Loading