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
5 changes: 5 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,11 @@ build-darwin: build-embedded | $(BIN_DIR)
# Build all binaries
build-all: build

# Run without live reload (build once and run)
run: build
sudo setcap cap_net_admin,cap_net_bind_service=+eip $(BIN_DIR)/hypeman
$(BIN_DIR)/hypeman

# Run in development mode with hot reload
dev: dev-linux

Expand Down
36 changes: 29 additions & 7 deletions cmd/api/api/builds.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"strconv"

"github.com/kernel/hypeman/lib/builds"
"github.com/kernel/hypeman/lib/images"
"github.com/kernel/hypeman/lib/logger"
"github.com/kernel/hypeman/lib/oapi"
)
Expand Down Expand Up @@ -41,7 +42,7 @@ func (s *ApiService) CreateBuild(ctx context.Context, request oapi.CreateBuildRe

// Parse multipart form fields
var sourceData []byte
var baseImageDigest, cacheScope, dockerfile, globalCacheKey string
var baseImageDigest, cacheScope, dockerfile, globalCacheKey, imageName string
var timeoutSeconds int
var isAdminBuild bool
var secrets []builds.SecretRef
Expand Down Expand Up @@ -137,6 +138,15 @@ func (s *ApiService) CreateBuild(ctx context.Context, request oapi.CreateBuildRe
}, nil
}
globalCacheKey = string(data)
case "image_name":
data, err := io.ReadAll(part)
if err != nil {
return oapi.CreateBuild400JSONResponse{
Code: "invalid_request",
Message: "failed to read image_name field",
}, nil
}
imageName = string(data)
}
part.Close()
}
Expand All @@ -148,17 +158,29 @@ func (s *ApiService) CreateBuild(ctx context.Context, request oapi.CreateBuildRe
}, nil
}

// Validate image_name early so the user gets a fast 400 instead of
// a successful build that silently falls back to builds/{id}.
if imageName != "" {
if _, err := images.ParseNormalizedRef(imageName); err != nil {
return oapi.CreateBuild400JSONResponse{
Code: "invalid_request",
Message: fmt.Sprintf("invalid image_name: %v", err),
}, nil
}
}

// Note: Dockerfile validation happens in the builder agent.
// It will check if Dockerfile is in the source tarball or provided via dockerfile parameter.

// Build domain request
domainReq := builds.CreateBuildRequest{
BaseImageDigest: baseImageDigest,
CacheScope: cacheScope,
Dockerfile: dockerfile,
Secrets: secrets,
IsAdminBuild: isAdminBuild,
GlobalCacheKey: globalCacheKey,
BaseImageDigest: baseImageDigest,
CacheScope: cacheScope,
Dockerfile: dockerfile,
Secrets: secrets,
IsAdminBuild: isAdminBuild,
GlobalCacheKey: globalCacheKey,
ImageName: imageName,
}

// Apply timeout if provided
Expand Down
16 changes: 8 additions & 8 deletions cmd/api/api/registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,8 @@ func TestRegistryPushAndConvert(t *testing.T) {
t.Log("Push successful!")

// Wait for image to be converted
// Include serverHost since our registry now stores images with the full host
imageName := serverHost + "/test/alpine@" + digest.String()
// Registry stores images under their short repo name (without host prefix)
imageName := "test/alpine@" + digest.String()
imgResp := waitForImageReady(t, svc, imageName, 60*time.Second)
assert.NotNil(t, imgResp.SizeBytes, "ready image should have size")
}
Expand Down Expand Up @@ -125,8 +125,8 @@ func TestRegistryPushAndCreateInstance(t *testing.T) {
require.NoError(t, err)

// Wait for image to be ready
// Include serverHost since our registry now stores images with the full host
imageName := serverHost + "/test/alpine@" + digest.String()
// Registry stores images under their short repo name (without host prefix)
imageName := "test/alpine@" + digest.String()
waitForImageReady(t, svc, imageName, 60*time.Second)

// Create instance with pushed image
Expand Down Expand Up @@ -364,8 +364,8 @@ func TestRegistryTagPush(t *testing.T) {
t.Log("Push successful!")

// The image should be registered with the computed digest, not the tag
// Include serverHost since our registry now stores images with the full host
imageName := serverHost + "/tag-test/alpine@" + digest.String()
// Registry stores images under their short repo name (without host prefix)
imageName := "tag-test/alpine@" + digest.String()
waitForImageReady(t, svc, imageName, 60*time.Second)

// Verify image appears in ListImages (GET /images)
Expand Down Expand Up @@ -418,8 +418,8 @@ func TestRegistryDockerV2ManifestConversion(t *testing.T) {

// Wait for image to be converted
// The server converts Docker v2 to OCI format internally, resulting in a different digest
// Include serverHost since our registry now stores images with the full host
imgResp := waitForImageReady(t, svc, serverHost+"/dockerv2-test/alpine:v1", 60*time.Second)
// Registry stores images under their short repo name (without host prefix)
imgResp := waitForImageReady(t, svc, "dockerv2-test/alpine:v1", 60*time.Second)
assert.NotNil(t, imgResp.SizeBytes, "ready image should have size")
assert.NotEmpty(t, imgResp.Digest, "image should have digest")
}
Expand Down
66 changes: 49 additions & 17 deletions lib/builds/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,11 @@ import (
"github.com/google/go-containerregistry/pkg/v1/empty"
"github.com/google/go-containerregistry/pkg/v1/layout"
"github.com/google/go-containerregistry/pkg/v1/tarball"
"github.com/nrednav/cuid2"
"github.com/kernel/hypeman/lib/images"
"github.com/kernel/hypeman/lib/instances"
"github.com/kernel/hypeman/lib/paths"
"github.com/kernel/hypeman/lib/volumes"
"github.com/nrednav/cuid2"
"go.opentelemetry.io/otel/metric"
)

Expand Down Expand Up @@ -195,7 +195,7 @@ func (m *manager) Start(ctx context.Context) error {
func (m *manager) ensureBuilderImage(ctx context.Context) {
defer m.builderReady.Store(true)

if m.config.BuilderImage != "" {
if m.config.BuilderImage != "" && m.config.BuilderImage != "none" {
// Explicit builder image configured - check if already available
if _, err := m.imageManager.GetImage(ctx, m.config.BuilderImage); err == nil {
m.logger.Info("builder image already available", "image", m.config.BuilderImage)
Expand Down Expand Up @@ -296,8 +296,8 @@ func (m *manager) buildBuilderFromDockerfile(ctx context.Context) (string, error
if err != nil {
return "", fmt.Errorf("get image digest: %w", err)
}
digest := digestHash.String() // "sha256:abc123..."
digestHex := digestHash.Hex // "abc123..."
digest := digestHash.String() // "sha256:abc123..."
digestHex := digestHash.Hex // "abc123..."

// Write directly to the shared OCI layout cache.
// This is the same cache used by the image manager's OCI client, so when
Expand Down Expand Up @@ -409,13 +409,17 @@ func (m *manager) CreateBuild(ctx context.Context, req CreateBuildRequest, sourc
// Token grants per-repo access based on build type:
// - Regular builds: push to builds/{id}, push to cache/{tenant}, pull from cache/global/{runtime}
// - Admin builds: push to builds/{id}, push to cache/global/{runtime}
// - When ImageName is set, the server re-tags the image after the build completes
tokenTTL := time.Duration(policy.TimeoutSeconds) * time.Second
if tokenTTL < 30*time.Minute {
tokenTTL = 30 * time.Minute // Minimum 30 minutes
}

// The builder always pushes to builds/{id}. When image_name is set, the
// server re-tags the image after the push completes.
buildRepo := fmt.Sprintf("builds/%s", id)
repoAccess := []RepoPermission{
{Repo: fmt.Sprintf("builds/%s", id), Scope: "push"},
{Repo: buildRepo, Scope: "push"},
}

// If the Dockerfile uses base images from the internal registry, grant pull access
Expand Down Expand Up @@ -499,6 +503,7 @@ func (m *manager) CreateBuild(ctx context.Context, req CreateBuildRequest, sourc
NetworkMode: policy.NetworkMode,
IsAdminBuild: req.IsAdminBuild,
GlobalCacheKey: req.GlobalCacheKey,
ImageName: req.ImageName,
}
if err := writeBuildConfig(m.paths, id, buildConfig); err != nil {
deleteBuild(m.paths, id)
Expand Down Expand Up @@ -585,14 +590,13 @@ func (m *manager) runBuild(ctx context.Context, id string, req CreateBuildReques
}

m.logger.Info("build succeeded", "id", id, "digest", result.ImageDigest, "duration", duration)
registryHost := stripRegistryScheme(m.config.RegistryURL)
imageRef := fmt.Sprintf("%s/builds/%s", registryHost, id)

// Wait for image to be ready before reporting build as complete.
// Wait for build image to be ready before reporting build as complete.
// The builder always pushes to builds/{id}, so that's what we wait for.
// This fixes the race condition (KERNEL-863) where build reports "ready"
// but image conversion hasn't finished yet.
// Use buildCtx to respect the build timeout during image wait.
if err := m.waitForImageReady(buildCtx, id); err != nil {
buildRepo := fmt.Sprintf("builds/%s", id)
if err := m.waitForImageReady(buildCtx, buildRepo); err != nil {
// Recalculate duration to include image wait time
duration = time.Since(start)
durationMS = duration.Milliseconds()
Expand All @@ -605,6 +609,34 @@ func (m *manager) runBuild(ctx context.Context, id string, req CreateBuildReques
return
}

// If image_name is set, re-tag the image so it's accessible by that name.
// The builder pushed to builds/{id} but the user wants it as image_name.
imageRef := buildRepo
if req.ImageName != "" {
ref, err := images.ParseNormalizedRef(req.ImageName)
if err != nil {
m.logger.Warn("failed to parse image_name", "build_id", id, "image_name", req.ImageName, "error", err)
} else {
repo := ref.Repository()
tag := ref.Tag()
if tag == "" {
tag = "latest"
}
taggedRef := repo + ":" + tag
if _, err := m.imageManager.ImportLocalImage(buildCtx, repo, tag, result.ImageDigest); err != nil {
m.logger.Warn("failed to re-tag image", "build_id", id, "image_name", req.ImageName, "error", err)
// imageRef stays as buildRepo — the image is still accessible via builds/{id}
} else {
m.logger.Info("re-tagged build image", "build_id", id, "from", buildRepo, "to", taggedRef)
imageRef = req.ImageName // Only set custom name after successful re-tag
// Wait for the re-tagged image to be ready (use full tagged ref)
if err := m.waitForImageReady(buildCtx, taggedRef); err != nil {
m.logger.Warn("re-tagged image conversion timed out", "build_id", id, "image_name", req.ImageName, "error", err)
}
}
}
}

// Recalculate duration to include image wait time for accurate reporting
duration = time.Since(start)
durationMS = duration.Milliseconds()
Expand Down Expand Up @@ -917,17 +949,16 @@ func (m *manager) updateBuildComplete(id string, status string, digest *string,
}

// waitForImageReady polls the image manager until the build's image is ready.
// imageRef should be the short repo name (e.g., "builds/abc123" or "myapp")
// matching what triggerConversion stores in the image manager.
// This ensures that when a build reports "ready", the image is actually usable
// for instance creation (fixes KERNEL-863 race condition).
func (m *manager) waitForImageReady(ctx context.Context, id string) error {
registryHost := stripRegistryScheme(m.config.RegistryURL)
imageRef := fmt.Sprintf("%s/builds/%s", registryHost, id)

func (m *manager) waitForImageReady(ctx context.Context, imageRef string) error {
// Poll for up to 60 seconds (image conversion is typically fast)
const maxAttempts = 120
const pollInterval = 500 * time.Millisecond

m.logger.Debug("waiting for image to be ready", "id", id, "image_ref", imageRef)
m.logger.Debug("waiting for image to be ready", "image_ref", imageRef)

for attempt := 0; attempt < maxAttempts; attempt++ {
select {
Expand All @@ -940,7 +971,7 @@ func (m *manager) waitForImageReady(ctx context.Context, id string) error {
if err == nil {
switch img.Status {
case images.StatusReady:
m.logger.Debug("image is ready", "id", id, "image_ref", imageRef, "attempts", attempt+1)
m.logger.Debug("image is ready", "image_ref", imageRef, "attempts", attempt+1)
return nil
case images.StatusFailed:
return fmt.Errorf("image conversion failed")
Expand Down Expand Up @@ -1299,8 +1330,9 @@ func (m *manager) refreshBuildToken(buildID string, req *CreateBuildRequest) err
}

// Generate per-repo access list (same logic as CreateBuild)
buildRepo := fmt.Sprintf("builds/%s", buildID)
repoAccess := []RepoPermission{
{Repo: fmt.Sprintf("builds/%s", buildID), Scope: "push"},
{Repo: buildRepo, Scope: "push"},
}

// If the Dockerfile uses base images from the internal registry, grant pull access
Expand Down
19 changes: 10 additions & 9 deletions lib/builds/race_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,13 @@ func TestWaitForImageReady_Success(t *testing.T) {
ctx := context.Background()
buildID := "test-build-123"

// Set the image to ready in the mock
imageRef := mgr.config.RegistryURL + "/builds/" + buildID
// Set the image to ready in the mock using the same ref format as production:
// builds/{id} is what runBuild passes to waitForImageReady
imageRef := "builds/" + buildID
imageMgr.SetImageReady(imageRef)

// waitForImageReady should succeed immediately
err := mgr.waitForImageReady(ctx, buildID)
err := mgr.waitForImageReady(ctx, imageRef)
require.NoError(t, err)
}

Expand All @@ -93,7 +94,7 @@ func TestWaitForImageReady_WaitsForConversion(t *testing.T) {

ctx := context.Background()
buildID := "test-build-456"
imageRef := mgr.config.RegistryURL + "/builds/" + buildID
imageRef := "builds/" + buildID

// Start with image in pending status
imageMgr.images[imageRef] = &images.Image{
Expand All @@ -111,7 +112,7 @@ func TestWaitForImageReady_WaitsForConversion(t *testing.T) {

// waitForImageReady should poll and eventually succeed
start := time.Now()
err := mgr.waitForImageReady(ctx, buildID)
err := mgr.waitForImageReady(ctx, imageRef)
elapsed := time.Since(start)

require.NoError(t, err)
Expand All @@ -127,7 +128,7 @@ func TestWaitForImageReady_ContextCancelled(t *testing.T) {
defer cancel()

buildID := "test-build-789"
imageRef := mgr.config.RegistryURL + "/builds/" + buildID
imageRef := "builds/" + buildID

// Image stays in pending status forever
imageMgr.images[imageRef] = &images.Image{
Expand All @@ -136,7 +137,7 @@ func TestWaitForImageReady_ContextCancelled(t *testing.T) {
}

// waitForImageReady should return context error
err := mgr.waitForImageReady(ctx, buildID)
err := mgr.waitForImageReady(ctx, imageRef)
require.Error(t, err)
assert.ErrorIs(t, err, context.DeadlineExceeded)
}
Expand All @@ -148,7 +149,7 @@ func TestWaitForImageReady_Failed(t *testing.T) {

ctx := context.Background()
buildID := "test-build-failed"
imageRef := mgr.config.RegistryURL + "/builds/" + buildID
imageRef := "builds/" + buildID

// Image is in failed status
imageMgr.images[imageRef] = &images.Image{
Expand All @@ -157,7 +158,7 @@ func TestWaitForImageReady_Failed(t *testing.T) {
}

// waitForImageReady should return error immediately
err := mgr.waitForImageReady(ctx, buildID)
err := mgr.waitForImageReady(ctx, imageRef)
require.Error(t, err)
assert.Contains(t, err.Error(), "image conversion failed")
}
Expand Down
7 changes: 7 additions & 0 deletions lib/builds/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ type CreateBuildRequest struct {
// Used with IsAdminBuild to target cache/global/{key}.
// Regular builds import from cache/global/{key} with pull-only access.
GlobalCacheKey string `json:"global_cache_key,omitempty"`

// ImageName optionally sets a custom image name for the build output.
// When set, the image is pushed to {registry}/{image_name} instead of {registry}/builds/{id}.
ImageName string `json:"image_name,omitempty"`
}

// BuildPolicy defines resource limits and network policy for a build
Expand Down Expand Up @@ -159,6 +163,9 @@ type BuildConfig struct {

// GlobalCacheKey is the global cache identifier (e.g., "node", "python", "ubuntu", "browser")
GlobalCacheKey string `json:"global_cache_key,omitempty"`

// ImageName optionally sets a custom image name for the build output.
ImageName string `json:"image_name,omitempty"`
}

// BuildEvent represents a typed SSE event for build streaming
Expand Down
5 changes: 2 additions & 3 deletions lib/images/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -359,18 +359,17 @@ func (m *manager) GetImage(ctx context.Context, name string) (*Image, error) {
repository := ref.Repository()

var digestHex string

if ref.IsDigest() {
// Direct digest lookup
digestHex = ref.DigestHex()
} else {
// Tag lookup - resolve symlink
tag := ref.Tag()

digestHex, err = resolveTag(m.paths, repository, tag)
d, err := resolveTag(m.paths, repository, tag)
if err != nil {
return nil, err
}
digestHex = d
}

meta, err := readMetadata(m.paths, repository, digestHex)
Expand Down
Loading