Skip to content

engine: add Engine.Build for image-only builds#82

Merged
bilby91 merged 3 commits into
mainfrom
engine/build
May 23, 2026
Merged

engine: add Engine.Build for image-only builds#82
bilby91 merged 3 commits into
mainfrom
engine/build

Conversation

@bilby91
Copy link
Copy Markdown
Member

@bilby91 bilby91 commented May 22, 2026

Closes #78.

`Engine.Build` resolves a workspace's devcontainer.json and produces the final container image — base image plus the feature pipeline — without creating or running a container. Unblocks a future CLI `build` subcommand and matches the embedding-relevant subset of upstream `devcontainer build`.

API

```go
type BuildOptions struct {
LocalWorkspaceFolder string
ConfigPath string
LocalEnv map[string]string
ImageName string // single tag; ignored for pure image source
NoCache bool
PullPolicy PullPolicy
Platform string
CacheFrom []string // appended to devcontainer.json's build.cacheFrom
Events chan<- events.Event
}

type BuildResult struct {
ImageID string
}

func (e *Engine) Build(ctx context.Context, opts BuildOptions) (*BuildResult, error)
```

Scope decisions

  • `updateRemoteUserUID` is deliberately skipped. Build output is meant to be portable (push to a registry, reuse across hosts); UID reconcile bakes the calling host's UID into the image. Use `Engine.Up` if you need a UID-reconciled local image.
  • Compose sources are refused with a typed error — matches upstream `devcontainer build`.
  • Image source with no features short-circuits: pull and return the ref unchanged. `ImageName` is ignored in that case (the runtime has no `TagImage` primitive yet — tracked as a follow-up).
  • Single `ImageName` tag (not `[]string`). Multi-tag, `--push`, `--cache-to` need `BuildSpec` extensions on both backends (docker + applecontainer Swift bridge). First pass ships what's already in the runtime surface.

Implementation

Factors through the existing `prepareBaseImage` + `layerFeatures` helpers via a new unexported `buildOverride` threaded on `UpOptions`. `Engine.Up` leaves it nil; `Engine.Build` populates:

  • `FinalTag` — applied to the last `BuildImage` call
  • `BaseIsFinal` — true when `BuildSource` has no features, so the base build IS the final step
  • `NoCache`, `Platform`, `ExtraCacheFrom` — applied to every `BuildImage` call in the chain

Test plan

  • `go test ./...` — unit tests green
  • `go test -tags=integration -run TestEngineBuild` — all 5 integration tests pass (~28s against real Docker)
    • image-source no-features short-circuit
    • build-source + local feature + custom `ImageName`
    • build-source no-features (`ImageName` on base)
    • compose source → typed refusal
    • no container created (image-only invariant)
  • `golangci-lint run` — 0 issues

Follow-ups (won't block this PR)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Devcontainer image build workflow with configurable options: image retagging, no-cache, platform pinning, pull policy, and cache sources.
    • Emits structured build events including config resolution and warnings.
    • Supports feature layering onto base images and applies requested image tags (retagging when base is final).
  • Bug Fixes

    • Clear refusal behavior for unsupported compose-based workspaces; image-only builds do not leave containers.

Review Change Stack

Closes #78.

Engine.Build resolves a workspace's devcontainer.json and produces the
final container image (base image plus feature pipeline) without
creating or running a container. Unblocks a future CLI \`build\`
subcommand and matches the embedding-relevant subset of upstream
\`devcontainer build\`.

Scope decisions (agreed on the issue):

- updateRemoteUserUID is deliberately skipped. The output is meant to
  be portable (push to a registry, reuse across hosts); UID reconcile
  bakes the calling host's UID into the image.
- Compose sources are refused with a typed error — matching upstream.
- Image source with no features short-circuits: pull and return the
  ref unchanged. ImageName is ignored in that case (the runtime has
  no TagImage primitive yet — tracked as a follow-up).
- Single ImageName tag (not []string). Multi-tag, --push, --cache-to
  require BuildSpec extensions on both backends; first-pass ships
  what we already support.

Implementation factors through the existing prepareBaseImage +
layerFeatures helpers via a new unexported buildOverride threaded
on UpOptions. Engine.Up sets it nil; Engine.Build populates FinalTag,
BaseIsFinal (so BuildSource-no-features tags the base directly),
NoCache, Platform, and ExtraCacheFrom.

Integration tests cover: image-source short-circuit, build+features
happy path with ImageName, build-no-features ImageName-on-base,
compose refusal, and a no-container-left-behind assertion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 22, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 7e518262-38f9-41e2-97e7-c246c80302b4

📥 Commits

Reviewing files that changed from the base of the PR and between 43ff074 and c7fb2cd.

📒 Files selected for processing (1)
  • build.go

📝 Walkthrough

Walkthrough

Engine.Build exposes a public API to build devcontainer images without creating containers: it resolves devcontainer configs, emits structured events, rejects compose sources, reuses Up’s base/feature build pipeline via an internal override, and returns the final image reference.

Changes

Engine.Build for image-only builds

Layer / File(s) Summary
Public API: BuildOptions, BuildResult, and Engine.Build
build.go
ErrComposeSourceUnsupported, BuildOptions, and BuildResult added; Engine.Build validates inputs, resolves config, emits ConfigResolved/ConfigWarning events, rejects compose sources, computes base-is-final, delegates to prepareBaseImage/layerFeatures, and retags when required.
Internal override infrastructure in Up
up.go
Adds buildOverride and an override *buildOverride field on UpOptions. prepareBaseImage and layerFeatures read the override to apply FinalTag, BaseIsFinal, NoCache, Platform, and ExtraCacheFrom into runtime.BuildSpec.
Integration tests
test/integration/build_test.go
Adds tests for image-only builds (no ImageName and retag-to-ImageName), Dockerfile builds with/without features verifying ImageID and local image presence, compose-source refusal checked with errors.Is(..., ErrComposeSourceUnsupported), and asserting no container is created for image-only builds.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 I hop and tag the images bright,
No containers left in sight,
Features layered, warnings told,
Compose refused, the tag is sold,
A gentle build — all snug and light.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main change: adding a new Engine.Build API for image-only builds without container creation.
Linked Issues check ✅ Passed The implementation fulfills the core requirements: exposes Build API with BuildOptions/BuildResult, reuses the resolve-feature-pipeline-image-build path, refuses compose sources, short-circuits pure image sources, supports ImageName tagging, and enables CLI build subcommand workflows.
Out of Scope Changes check ✅ Passed All changes are scoped to implementing Engine.Build: new build.go with Build method and types, updated build_test.go with integration tests, and internal plumbing in up.go to thread build parameters; no unrelated modifications detected.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch engine/build

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
test/integration/build_test.go (1)

49-88: ⚡ Quick win

Add a regression test for “features requested but no feature build needed”.

Current coverage misses the path where requested features are already present in base metadata, causing layerFeatures to no-op. A test here should assert BuildOptions.ImageName is still honored for build sources.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/integration/build_test.go` around lines 49 - 88, Add a regression test
that covers the path where requested features are already present in the base
metadata so no feature build occurs: in TestEngineBuild_BuildSource_WithFeature
(or a new test named similarly) use writeBuildSourceWorkspace (or create a
workspace whose devcontainer metadata lists the requested features) then call
eng.Build(ctx, devcontainer.BuildOptions{LocalWorkspaceFolder: ws, ImageName:
customTag}) and assert res.ImageID == customTag, then verify the image exists
via rt.InspectImage(ctx, res.ImageID) and finally clean up with rt.RemoveImage;
this ensures eng.Build honors BuildOptions.ImageName even when layerFeatures
no-ops.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@build.go`:
- Around line 110-112: Replace the plain string error returned from Engine.Build
when cfg.Source is a *config.ComposeSource with a typed sentinel error (e.g.,
package-level var ErrComposeSourceNotSupported = errors.New("compose-source
devcontainers are not supported")) so callers can reliably match with errors.Is;
update the Engine.Build return to return that sentinel (and optionally wrap it
with fmt.Errorf only if adding context, using errors.Is-compatible wrapping) and
ensure the symbol names to change are Engine.Build, cfg.Source, and
config.ComposeSource.

In `@up.go`:
- Around line 895-897: The code currently overwrites a caller-provided ImageName
when layerFeatures short-circuits and returns baseImage because
opts.override.BaseIsFinal && opts.override.FinalTag unconditionally sets tag;
change the logic so FinalTag is applied only when we are not using a
short-circuited baseImage (i.e., when layerFeatures did real work) or when no
caller ImageName was supplied. Locate the layerFeatures call and the block that
sets tag using opts.override.BaseIsFinal and opts.override.FinalTag and add a
guard that preserves baseImage.ImageName / opts.override.ImageName (or only
applies FinalTag when ImageName is empty or plan.HasWork()==true) so the caller
ImageName is retained when the feature pipeline is a no-op.

---

Nitpick comments:
In `@test/integration/build_test.go`:
- Around line 49-88: Add a regression test that covers the path where requested
features are already present in the base metadata so no feature build occurs: in
TestEngineBuild_BuildSource_WithFeature (or a new test named similarly) use
writeBuildSourceWorkspace (or create a workspace whose devcontainer metadata
lists the requested features) then call eng.Build(ctx,
devcontainer.BuildOptions{LocalWorkspaceFolder: ws, ImageName: customTag}) and
assert res.ImageID == customTag, then verify the image exists via
rt.InspectImage(ctx, res.ImageID) and finally clean up with rt.RemoveImage; this
ensures eng.Build honors BuildOptions.ImageName even when layerFeatures no-ops.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3f9be298-4a30-4966-8b7a-f9f77d730eb3

📥 Commits

Reviewing files that changed from the base of the PR and between 046f7f3 and b4df803.

📒 Files selected for processing (3)
  • build.go
  • test/integration/build_test.go
  • up.go

Comment thread build.go
Comment thread up.go
CodeRabbit caught two real issues:

1. Compose-source refusal used a plain string error. Replace with a
   package-level sentinel ErrComposeSourceUnsupported wrapped via %w,
   so callers can match with errors.Is. Update the integration test
   to assert via errors.Is.

2. ImageName was lost when the feature pipeline short-circuited —
   features declared but every one already pre-baked into the base
   image. layerFeatures returns baseImage unchanged in that case, but
   BaseIsFinal was false (because len(features) > 0), so the base
   build kept its auto tag. Same gap surfaced as the documented
   "ignored for pure image source" caveat.

   Fix: after layerFeatures, if finalImage == baseImage and the
   caller asked for a specific ImageName, run a thin `FROM <base>`
   Dockerfile build via a new retagImage helper. Covers both the
   pure-image-source case and the features-all-pre-baked case. The
   "ImageName ignored" caveat goes away.

   This is a workaround for the absent runtime.TagImage primitive
   (still tracked as a follow-up); a TagImage would let us avoid the
   extra build.

Tests updated:
- TestEngineBuild_ImageSource_NoFeatures_NoImageName — unchanged path
- TestEngineBuild_ImageSource_RetagToImageName — new, exercises the
  retag step
- TestEngineBuild_ComposeRefused — now asserts errors.Is rather than
  substring matching

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@build.go`:
- Around line 193-208: The short-circuit retag path calls e.runtime.BuildImage
with runtime.BuildSpec but omits propagating BuildOptions.NoCache, so builds may
reuse cache incorrectly; update the call site that constructs runtime.BuildSpec
(used in e.runtime.BuildImage) to set NoCache from the incoming BuildOptions
(e.g., pass override.NoCache or the BuildOptions instance) so the
runtime.BuildImage invocation honors NoCache; reference the BuildOptions.NoCache
flag and the runtime.BuildSpec passed into e.runtime.BuildImage to locate and
add the missing NoCache field.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 157042e9-efb9-4564-9bc3-b05a614f6f5e

📥 Commits

Reviewing files that changed from the base of the PR and between b4df803 and 43ff074.

📒 Files selected for processing (2)
  • build.go
  • test/integration/build_test.go

Comment thread build.go
CodeRabbit catch on PR #82: retagImage read Platform and ExtraCacheFrom
from buildOverride but dropped NoCache, so Engine.Build(..., NoCache:
true) could still reuse a cached FROM-only layer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@bilby91 bilby91 merged commit 3b9ff57 into main May 23, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

engine: add Engine.Build for image-only builds (unblocks CLI build)

1 participant