diff --git a/Makefile b/Makefile index 53e92d72..a3aa1ccc 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/cmd/api/api/builds.go b/cmd/api/api/builds.go index c3b8550f..a9e67a14 100644 --- a/cmd/api/api/builds.go +++ b/cmd/api/api/builds.go @@ -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" ) @@ -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 @@ -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() } @@ -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 diff --git a/cmd/api/api/registry_test.go b/cmd/api/api/registry_test.go index 50eb1f07..afc2c2db 100644 --- a/cmd/api/api/registry_test.go +++ b/cmd/api/api/registry_test.go @@ -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") } @@ -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 @@ -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) @@ -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") } diff --git a/lib/builds/manager.go b/lib/builds/manager.go index 960b70ca..77252ef4 100644 --- a/lib/builds/manager.go +++ b/lib/builds/manager.go @@ -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" ) @@ -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) @@ -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 @@ -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 @@ -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) @@ -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() @@ -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() @@ -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 { @@ -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") @@ -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 diff --git a/lib/builds/race_test.go b/lib/builds/race_test.go index 9d31243d..748a9699 100644 --- a/lib/builds/race_test.go +++ b/lib/builds/race_test.go @@ -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) } @@ -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{ @@ -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) @@ -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{ @@ -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) } @@ -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{ @@ -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") } diff --git a/lib/builds/types.go b/lib/builds/types.go index 247154b9..3fde87cf 100644 --- a/lib/builds/types.go +++ b/lib/builds/types.go @@ -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 @@ -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 diff --git a/lib/images/manager.go b/lib/images/manager.go index c423bf34..1ccfadb0 100644 --- a/lib/images/manager.go +++ b/lib/images/manager.go @@ -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) diff --git a/lib/oapi/oapi.go b/lib/oapi/oapi.go index a389712e..6ce10226 100644 --- a/lib/oapi/oapi.go +++ b/lib/oapi/oapi.go @@ -853,6 +853,10 @@ type CreateBuildMultipartBody struct { // Admin builds will also export to this location. GlobalCacheKey *string `json:"global_cache_key,omitempty"` + // ImageName 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"` + // IsAdminBuild Set to "true" to grant push access to global cache (operator-only). // Admin builds can populate the shared global cache that all tenant builds read from. IsAdminBuild *string `json:"is_admin_build,omitempty"` @@ -10787,57 +10791,58 @@ var swaggerSpec = []string{ "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", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/lib/providers/providers.go b/lib/providers/providers.go index 3ea7cf8b..1963fcb5 100644 --- a/lib/providers/providers.go +++ b/lib/providers/providers.go @@ -6,6 +6,7 @@ import ( "log/slog" "os" "strconv" + "strings" "time" "github.com/c2h5oh/datasize" @@ -251,10 +252,31 @@ func ProvideBuildManager(p *paths.Paths, cfg *config.Config, instanceManager ins log.Info("registry CA certificate loaded", "file", cfg.RegistryCACertFile) } + // Rewrite localhost in RegistryURL to the subnet gateway IP so builder VMs + // (which run in their own network namespace) can reach the host registry. + // Inside a VM, "localhost" refers to the VM itself, not the host. + registryURL := cfg.RegistryURL + if registryURL == "" { + registryURL = "localhost:8080" + } + if strings.HasPrefix(registryURL, "localhost:") || strings.HasPrefix(registryURL, "127.0.0.1:") { + gateway := cfg.SubnetGateway + if gateway == "" { + var err error + gateway, err = network.DeriveGateway(cfg.SubnetCIDR) + if err != nil { + return nil, fmt.Errorf("derive gateway for registry URL rewrite: %w", err) + } + } + port := strings.SplitN(registryURL, ":", 2)[1] + registryURL = gateway + ":" + port + log.Info("rewrote registry URL for builder VMs", "original", cfg.RegistryURL, "rewritten", registryURL) + } + buildConfig := builds.Config{ MaxConcurrentBuilds: cfg.MaxConcurrentSourceBuilds, BuilderImage: cfg.BuilderImage, - RegistryURL: cfg.RegistryURL, + RegistryURL: registryURL, RegistryInsecure: cfg.RegistryInsecure, RegistryCACert: registryCACert, DefaultTimeout: cfg.BuildTimeout, @@ -268,9 +290,6 @@ func ProvideBuildManager(p *paths.Paths, cfg *config.Config, instanceManager ins if buildConfig.BuilderImage == "" { buildConfig.BuilderImage = "hypeman/builder:latest" } - if buildConfig.RegistryURL == "" { - buildConfig.RegistryURL = "localhost:8080" - } if buildConfig.DefaultTimeout == 0 { buildConfig.DefaultTimeout = 600 } diff --git a/lib/registry/registry.go b/lib/registry/registry.go index 651baf9c..ea81d32a 100644 --- a/lib/registry/registry.go +++ b/lib/registry/registry.go @@ -70,14 +70,6 @@ func (r *Registry) Handler() http.Handler { pathRepo := matches[1] reference := matches[2] - // Include the host to form the full repository path - // This preserves the registry host (e.g., "10.102.0.1:8083/builds/xxx") - // instead of normalizing to docker.io - fullRepo := pathRepo - if req.Host != "" { - fullRepo = req.Host + "/" + pathRepo - } - body, err := io.ReadAll(req.Body) req.Body.Close() if err != nil { @@ -102,7 +94,11 @@ func (r *Registry) Handler() http.Handler { r.handler.ServeHTTP(wrapper, req) if wrapper.statusCode == http.StatusCreated { - go r.triggerConversion(fullRepo, reference, digest) + // Use pathRepo (without registry host prefix) so pushed images + // are stored under their short name. This ensures consistency: + // `hypeman push myapp` stores as "docker.io/library/myapp:latest" + // which matches what `hypeman run myapp` looks up. + go r.triggerConversion(pathRepo, reference, digest) } return } diff --git a/openapi.yaml b/openapi.yaml index ca918ede..f60727fd 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2421,6 +2421,11 @@ paths: timeout_seconds: type: integer description: Build timeout (default 600) + image_name: + type: string + description: | + Custom image name for the build output. When set, the image is pushed + to {registry}/{image_name} instead of {registry}/builds/{id}. secrets: type: string description: |