Skip to content

Commit bb33898

Browse files
fix: resolve the image scanner source instead of assuming a registry (#35)
Builder.SBOM hardcoded sbom.SourceRegistry, so an image the CLI built with --load and never pushed failed resolution: `docker buildx imagetools inspect` reports "pull access denied" for an image sitting in the local daemon, fully scannable. Neither SBOMRequest nor ImageSubject carries a scanner source, so the agent has to resolve it. The registry is tried first, so a stale local copy cannot shadow what was actually published, and the daemon answers only for a reference the registry cannot serve; when neither resolves, both causes are reported rather than just the registry's. The end-to-end test hid this. It called core's SBOMImages helper directly with SourceDockerDaemon, bypassing Builder.SBOM and using the opposite source, so no test ever drove the shipped method to a complete response and the shipped configuration had never produced a successful scan anywhere. It now goes through Builder.SBOM, and was confirmed to fail with the original "pull access denied" when the fallback is removed. A failed `uv lock` or `docker build` in that test called t.Skipf, so a broken Dockerfile template would have skipped to a green run rather than failing the job the test exists to protect. Both are t.Fatalf now; the tooling guard above them has already established that the tools are installed. Adds a source-scope routing test, which nothing covered: an inverted scope comparison would have answered every source request with image evidence. Reverts the testdata/recipe* gitignore entry as unrelated to this change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent fe453a6 commit bb33898

3 files changed

Lines changed: 75 additions & 8 deletions

File tree

‎.gitignore‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ __pycache__
88
dist/
99
testdata/container*
1010
testdata/native*
11-
testdata/recipe*
1211
service-python-fastapi
1312
go.work
1413
go.work.sum

‎builder.go‎

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"embed"
66
"errors"
7+
"fmt"
78
"os"
89

910
"github.com/codefly-dev/core/agents/communicate"
@@ -204,16 +205,48 @@ func (s *Builder) Build(ctx context.Context, req *builderv0.BuildRequest) (*buil
204205
//
205206
// Image scope goes to core's shared scanner. This agent emits a build recipe
206207
// and never runs buildx, so the digests only exist on the caller's side and
207-
// arrive as explicit subjects. SBOMImages answers empty subjects with a
208-
// precondition failure of its own: the service does ship an image, so
209-
// neither UNSUPPORTED nor a no-image reason would be true.
208+
// arrive as explicit subjects.
210209
func (s *Builder) SBOM(ctx context.Context, req *builderv0.SBOMRequest) (*builderv0.SBOMResponse, error) {
211210
defer s.Wool.Catch()
212211
if req.GetScope() != builderv0.SBOMScope_SBOM_SCOPE_IMAGE {
213212
return s.Builder.SBOM(ctx, req)
214213
}
215214
ctx = s.Wool.Inject(ctx)
216-
return s.Base.Builder.SBOMImages(ctx, req.GetSubjects(), sbom.SourceRegistry)
215+
return s.imageSBOM(ctx, req.GetSubjects())
216+
}
217+
218+
// imageSBOM inventories each subject, resolving where the image actually lives.
219+
//
220+
// Neither SBOMRequest nor ImageSubject carries a scanner source, and both
221+
// sources are real for this agent: release paths push, so the registry
222+
// resolves them, while `codefly ci build` builds with --load and leaves an
223+
// image whose only identity is its local image ID. Assuming either one strands
224+
// the other — a registry lookup of a --load image fails with "pull access
225+
// denied" for an image sitting in the daemon, fully scannable.
226+
//
227+
// The registry is tried first so a stale local copy can never shadow what was
228+
// actually published; the daemon answers only for a reference the registry
229+
// cannot serve. When neither resolves, both causes are reported, because the
230+
// registry failure on its own does not explain why the daemon had nothing
231+
// either. Empty subjects are the caller's precondition failure, not a
232+
// resolution failure, so they never reach a scanner.
233+
func (s *Builder) imageSBOM(ctx context.Context, subjects []*builderv0.ImageSubject) (*builderv0.SBOMResponse, error) {
234+
if len(subjects) == 0 {
235+
return s.Base.Builder.SBOMImageSubjectsRequired()
236+
}
237+
registry, err := s.Base.Builder.SBOMImages(ctx, subjects, sbom.SourceRegistry)
238+
if err != nil || registry.GetState().GetState() != builderv0.SBOMStatus_ERROR {
239+
return registry, err
240+
}
241+
daemon, err := s.Base.Builder.SBOMImages(ctx, subjects, sbom.SourceDockerDaemon)
242+
if err != nil {
243+
return nil, err
244+
}
245+
if daemon.GetState().GetState() == builderv0.SBOMStatus_ERROR {
246+
return s.Base.Builder.SBOMImageError(fmt.Errorf("registry: %s; docker daemon: %s",
247+
registry.GetState().GetMessage(), daemon.GetState().GetMessage()))
248+
}
249+
return daemon, nil
217250
}
218251

219252
// Upgrade bumps Python dependencies in requirements.txt (pip list

‎image_sbom_test.go‎

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,31 @@ func TestImageSBOMWithoutSubjectsIsPreconditionFailure(t *testing.T) {
5858
require.Empty(t, resp.GetImages())
5959
}
6060

61+
// TestSourceScopeIsNotRoutedToImageEvidence guards the scope condition itself.
62+
// Builder.SBOM now sits in front of the inherited source inventory, so an
63+
// inverted comparison would answer every source request with image evidence
64+
// and nothing else in this repository would notice.
65+
//
66+
// It asserts the response shape rather than its contents so it holds with or
67+
// without uv installed: a source answer never carries image scope, image
68+
// inventories, or a no-image reason, whether the inventory succeeded or failed.
69+
func TestSourceScopeIsNotRoutedToImageEvidence(t *testing.T) {
70+
builder, _ := createdBuilder(t)
71+
72+
for _, scope := range []builderv0.SBOMScope{
73+
builderv0.SBOMScope_SBOM_SCOPE_UNSPECIFIED,
74+
builderv0.SBOMScope_SBOM_SCOPE_SOURCE,
75+
} {
76+
t.Run(scope.String(), func(t *testing.T) {
77+
resp, err := builder.SBOM(t.Context(), &builderv0.SBOMRequest{Scope: scope})
78+
require.NoError(t, err)
79+
require.NotEqual(t, builderv0.SBOMScope_SBOM_SCOPE_IMAGE, resp.GetScope())
80+
require.Empty(t, resp.GetImages())
81+
require.Equal(t, builderv0.NoImageReason_NO_IMAGE_REASON_UNSPECIFIED, resp.GetNoImageReason())
82+
})
83+
}
84+
}
85+
6186
// TestImageSBOMExpectationsMatchTheEmittedRecipe verifies the specialization's
6287
// actual build-recipe path against the coverage helper the fleet grades every
6388
// agent with: one subject per shipped platform, carrying the recipe's role and
@@ -169,7 +194,9 @@ func TestImageSBOMInventoriesTheBuiltImage(t *testing.T) {
169194
lock := exec.CommandContext(ctx, "uv", "lock")
170195
lock.Dir = filepath.Join(builder.Location, "code")
171196
if out, err := lock.CombinedOutput(); err != nil {
172-
t.Skipf("uv lock failed, the image cannot be built: %v\n%s", err, out)
197+
// The tooling guard above already passed, so uv is installed: a failure
198+
// here is a broken scaffold or lockfile, not an unequipped host.
199+
t.Fatalf("uv lock failed, the image cannot be built: %v\n%s", err, out)
173200
}
174201

175202
tag := fmt.Sprintf("codefly-fastapi-sbom-test:%d", time.Now().UnixMilli())
@@ -179,19 +206,27 @@ func TestImageSBOMInventoriesTheBuiltImage(t *testing.T) {
179206
"-f", filepath.Join(outputDir, plan.GetRecipes()[0].GetDockerfile()),
180207
"-t", tag, builder.Location)
181208
if out, err := build.CombinedOutput(); err != nil {
182-
t.Skipf("docker build failed, the image cannot be scanned: %v\n%s", err, out)
209+
// Likewise docker is installed, so this is a broken Dockerfile template
210+
// or recipe. Skipping would turn the regression this test exists to
211+
// catch into a green run.
212+
t.Fatalf("docker build failed, the image cannot be scanned: %v\n%s", err, out)
183213
}
184214
t.Cleanup(func() { _ = exec.Command("docker", "rmi", "-f", tag).Run() })
185215

186216
// The image was loaded into the local daemon and never pushed, so its
187217
// immutable identity is the local image ID. The platform is left unstated:
188218
// the daemon holds exactly one, and it is read back rather than asserted.
219+
//
220+
// This drives Builder.SBOM, the method the CLI actually calls, rather than
221+
// core's helper. Scope routing and scanner-source resolution are what this
222+
// repository owns, and a test calling the helper directly passed with
223+
// either of them broken.
189224
subject := &builderv0.ImageSubject{
190225
Reference: tag,
191226
Role: plan.GetRecipes()[0].GetName(),
192227
Service: builder.Identity.Name,
193228
}
194-
resp, err := builder.Base.Builder.SBOMImages(ctx, []*builderv0.ImageSubject{subject}, sbom.SourceDockerDaemon)
229+
resp, err := builder.SBOM(ctx, imageSBOMRequest(subject))
195230
require.NoError(t, err)
196231
require.Equal(t, builderv0.SBOMStatus_COMPLETE, resp.GetState().GetState(), resp.GetState().GetMessage())
197232
require.Equal(t, builderv0.SBOMScope_SBOM_SCOPE_IMAGE, resp.GetScope())

0 commit comments

Comments
 (0)