diff --git a/cmd/api/api/builds.go b/cmd/api/api/builds.go index 8f6f931..c3b8550 100644 --- a/cmd/api/api/builds.go +++ b/cmd/api/api/builds.go @@ -41,8 +41,9 @@ func (s *ApiService) CreateBuild(ctx context.Context, request oapi.CreateBuildRe // Parse multipart form fields var sourceData []byte - var baseImageDigest, cacheScope, dockerfile string + var baseImageDigest, cacheScope, dockerfile, globalCacheKey string var timeoutSeconds int + var isAdminBuild bool var secrets []builds.SecretRef for { @@ -118,6 +119,24 @@ func (s *ApiService) CreateBuild(ctx context.Context, request oapi.CreateBuildRe Message: "secrets must be a JSON array of {\"id\": \"...\", \"env_var\": \"...\"} objects", }, nil } + case "is_admin_build": + data, err := io.ReadAll(part) + if err != nil { + return oapi.CreateBuild400JSONResponse{ + Code: "invalid_request", + Message: "failed to read is_admin_build field", + }, nil + } + isAdminBuild = string(data) == "true" || string(data) == "1" + case "global_cache_key": + data, err := io.ReadAll(part) + if err != nil { + return oapi.CreateBuild400JSONResponse{ + Code: "invalid_request", + Message: "failed to read global_cache_key field", + }, nil + } + globalCacheKey = string(data) } part.Close() } @@ -134,10 +153,12 @@ func (s *ApiService) CreateBuild(ctx context.Context, request oapi.CreateBuildRe // Build domain request domainReq := builds.CreateBuildRequest{ - BaseImageDigest: baseImageDigest, - CacheScope: cacheScope, - Dockerfile: dockerfile, - Secrets: secrets, + BaseImageDigest: baseImageDigest, + CacheScope: cacheScope, + Dockerfile: dockerfile, + Secrets: secrets, + IsAdminBuild: isAdminBuild, + GlobalCacheKey: globalCacheKey, } // Apply timeout if provided diff --git a/lib/builds/builder_agent/main.go b/lib/builds/builder_agent/main.go index 0c645c6..843aca2 100644 --- a/lib/builds/builder_agent/main.go +++ b/lib/builds/builder_agent/main.go @@ -37,17 +37,19 @@ const ( // BuildConfig matches the BuildConfig type from lib/builds/types.go type BuildConfig struct { - JobID string `json:"job_id"` - BaseImageDigest string `json:"base_image_digest,omitempty"` - RegistryURL string `json:"registry_url"` - RegistryToken string `json:"registry_token,omitempty"` - CacheScope string `json:"cache_scope,omitempty"` - SourcePath string `json:"source_path"` - Dockerfile string `json:"dockerfile,omitempty"` - BuildArgs map[string]string `json:"build_args,omitempty"` - Secrets []SecretRef `json:"secrets,omitempty"` - TimeoutSeconds int `json:"timeout_seconds"` - NetworkMode string `json:"network_mode"` + JobID string `json:"job_id"` + BaseImageDigest string `json:"base_image_digest,omitempty"` + RegistryURL string `json:"registry_url"` + RegistryToken string `json:"registry_token,omitempty"` + CacheScope string `json:"cache_scope,omitempty"` + SourcePath string `json:"source_path"` + Dockerfile string `json:"dockerfile,omitempty"` + BuildArgs map[string]string `json:"build_args,omitempty"` + Secrets []SecretRef `json:"secrets,omitempty"` + TimeoutSeconds int `json:"timeout_seconds"` + NetworkMode string `json:"network_mode"` + IsAdminBuild bool `json:"is_admin_build,omitempty"` + GlobalCacheKey string `json:"global_cache_key,omitempty"` } // SecretRef references a secret to inject during build @@ -547,11 +549,40 @@ func runBuild(ctx context.Context, config *BuildConfig, logWriter io.Writer) (st "--metadata-file", "/tmp/build-metadata.json", } - // Add cache if scope is set - if config.CacheScope != "" { - cacheRef := fmt.Sprintf("%s/cache/%s", config.RegistryURL, config.CacheScope) - args = append(args, "--import-cache", fmt.Sprintf("type=registry,ref=%s,registry.insecure=true", cacheRef)) - args = append(args, "--export-cache", fmt.Sprintf("type=registry,ref=%s,mode=max,registry.insecure=true", cacheRef)) + // Two-tier cache implementation: + // 1. Import from global cache (if runtime specified) - always read-only for regular builds + // 2. Import from tenant cache (if cache scope specified) + // 3. Export to appropriate target based on build type + + // Import from global cache (read-only for regular builds, read-write for admin builds) + if config.GlobalCacheKey != "" { + globalCacheRef := fmt.Sprintf("%s/cache/global/%s", config.RegistryURL, config.GlobalCacheKey) + args = append(args, "--import-cache", fmt.Sprintf("type=registry,ref=%s,registry.insecure=true", globalCacheRef)) + log.Printf("Importing from global cache: %s", globalCacheRef) + } + + // For regular builds, also import from tenant cache if scope is set + if !config.IsAdminBuild && config.CacheScope != "" { + tenantCacheRef := fmt.Sprintf("%s/cache/%s", config.RegistryURL, config.CacheScope) + args = append(args, "--import-cache", fmt.Sprintf("type=registry,ref=%s,registry.insecure=true", tenantCacheRef)) + log.Printf("Importing from tenant cache: %s", tenantCacheRef) + } + + // Export cache based on build type + if config.IsAdminBuild { + // Admin build: export to global cache + if config.GlobalCacheKey != "" { + globalCacheRef := fmt.Sprintf("%s/cache/global/%s", config.RegistryURL, config.GlobalCacheKey) + args = append(args, "--export-cache", fmt.Sprintf("type=registry,ref=%s,mode=max,registry.insecure=true", globalCacheRef)) + log.Printf("Exporting to global cache (admin build): %s", globalCacheRef) + } + } else { + // Regular build: export to tenant cache + if config.CacheScope != "" { + tenantCacheRef := fmt.Sprintf("%s/cache/%s", config.RegistryURL, config.CacheScope) + args = append(args, "--export-cache", fmt.Sprintf("type=registry,ref=%s,mode=max,registry.insecure=true", tenantCacheRef)) + log.Printf("Exporting to tenant cache: %s", tenantCacheRef) + } } // Add secret mounts diff --git a/lib/builds/manager.go b/lib/builds/manager.go index 72e1b0a..6d6e744 100644 --- a/lib/builds/manager.go +++ b/lib/builds/manager.go @@ -191,16 +191,45 @@ func (m *manager) CreateBuild(ctx context.Context, req CreateBuildRequest, sourc } // Generate scoped registry token for this build - // Token grants push access to the build output repo and cache repo - allowedRepos := []string{fmt.Sprintf("builds/%s", id)} - if req.CacheScope != "" { - allowedRepos = append(allowedRepos, fmt.Sprintf("cache/%s", req.CacheScope)) - } + // 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} tokenTTL := time.Duration(policy.TimeoutSeconds) * time.Second if tokenTTL < 30*time.Minute { tokenTTL = 30 * time.Minute // Minimum 30 minutes } - registryToken, err := m.tokenGenerator.GeneratePushToken(id, allowedRepos, tokenTTL) + + repoAccess := []RepoPermission{ + {Repo: fmt.Sprintf("builds/%s", id), Scope: "push"}, + } + + if req.IsAdminBuild { + // Admin build: push access to global cache + if req.GlobalCacheKey != "" { + repoAccess = append(repoAccess, RepoPermission{ + Repo: fmt.Sprintf("cache/global/%s", req.GlobalCacheKey), + Scope: "push", + }) + } + } else { + // Regular tenant build + // Pull access to global cache (if runtime specified) + if req.GlobalCacheKey != "" { + repoAccess = append(repoAccess, RepoPermission{ + Repo: fmt.Sprintf("cache/global/%s", req.GlobalCacheKey), + Scope: "pull", + }) + } + // Push access to tenant cache (if cache scope specified) + if req.CacheScope != "" { + repoAccess = append(repoAccess, RepoPermission{ + Repo: fmt.Sprintf("cache/%s", req.CacheScope), + Scope: "push", + }) + } + } + + registryToken, err := m.tokenGenerator.GenerateToken(id, repoAccess, tokenTTL) if err != nil { deleteBuild(m.paths, id) return nil, fmt.Errorf("generate registry token: %w", err) @@ -208,17 +237,19 @@ func (m *manager) CreateBuild(ctx context.Context, req CreateBuildRequest, sourc // Write build config for the builder agent buildConfig := &BuildConfig{ - JobID: id, - BaseImageDigest: req.BaseImageDigest, - RegistryURL: m.config.RegistryURL, - RegistryToken: registryToken, - CacheScope: req.CacheScope, - SourcePath: "/src", - Dockerfile: req.Dockerfile, - BuildArgs: req.BuildArgs, - Secrets: req.Secrets, - TimeoutSeconds: policy.TimeoutSeconds, - NetworkMode: policy.NetworkMode, + JobID: id, + BaseImageDigest: req.BaseImageDigest, + RegistryURL: m.config.RegistryURL, + RegistryToken: registryToken, + CacheScope: req.CacheScope, + SourcePath: "/src", + Dockerfile: req.Dockerfile, + BuildArgs: req.BuildArgs, + Secrets: req.Secrets, + TimeoutSeconds: policy.TimeoutSeconds, + NetworkMode: policy.NetworkMode, + IsAdminBuild: req.IsAdminBuild, + GlobalCacheKey: req.GlobalCacheKey, } if err := writeBuildConfig(m.paths, id, buildConfig); err != nil { deleteBuild(m.paths, id) @@ -1046,14 +1077,37 @@ func (m *manager) refreshBuildToken(buildID string, req *CreateBuildRequest) err tokenTTL = 30 * time.Minute // Minimum 30 minutes } - // Generate allowed repos list - allowedRepos := []string{fmt.Sprintf("builds/%s", buildID)} - if req.CacheScope != "" { - allowedRepos = append(allowedRepos, fmt.Sprintf("cache/%s", req.CacheScope)) + // Generate per-repo access list (same logic as CreateBuild) + repoAccess := []RepoPermission{ + {Repo: fmt.Sprintf("builds/%s", buildID), Scope: "push"}, + } + + if req.IsAdminBuild { + // Admin build: push access to global cache + if req.GlobalCacheKey != "" { + repoAccess = append(repoAccess, RepoPermission{ + Repo: fmt.Sprintf("cache/global/%s", req.GlobalCacheKey), + Scope: "push", + }) + } + } else { + // Regular tenant build + if req.GlobalCacheKey != "" { + repoAccess = append(repoAccess, RepoPermission{ + Repo: fmt.Sprintf("cache/global/%s", req.GlobalCacheKey), + Scope: "pull", + }) + } + if req.CacheScope != "" { + repoAccess = append(repoAccess, RepoPermission{ + Repo: fmt.Sprintf("cache/%s", req.CacheScope), + Scope: "push", + }) + } } // Generate fresh registry token - registryToken, err := m.tokenGenerator.GeneratePushToken(buildID, allowedRepos, tokenTTL) + registryToken, err := m.tokenGenerator.GenerateToken(buildID, repoAccess, tokenTTL) if err != nil { return fmt.Errorf("generate registry token: %w", err) } diff --git a/lib/builds/registry_token.go b/lib/builds/registry_token.go index 6c8cf44..3a2f8ab 100644 --- a/lib/builds/registry_token.go +++ b/lib/builds/registry_token.go @@ -8,19 +8,35 @@ import ( "github.com/golang-jwt/jwt/v5" ) +// RepoPermission defines access permissions for a specific repository. +// This enables fine-grained control where different repos can have different scopes. +type RepoPermission struct { + // Repo is the repository path (e.g., "builds/abc123", "cache/tenant-x", "cache/global/node") + Repo string `json:"repo"` + + // Scope is the access scope for this repo: "pull" for read-only, "push" for read+write + Scope string `json:"scope"` +} + // RegistryTokenClaims contains the claims for a scoped registry access token. -// These tokens are issued to builder VMs to grant limited push access to specific repositories. +// These tokens are issued to builder VMs to grant limited access to specific repositories. type RegistryTokenClaims struct { jwt.RegisteredClaims // BuildID is the build job identifier for audit purposes BuildID string `json:"build_id"` - // Repositories is the list of allowed repository paths (e.g., ["builds/abc123", "cache/tenant-x"]) - Repositories []string `json:"repos"` + // RepoAccess defines per-repository access permissions (new two-tier format) + // If present, this takes precedence over the legacy Repositories/Scope fields + RepoAccess []RepoPermission `json:"repo_access,omitempty"` - // Scope is the access scope: "push" for write access, "pull" for read-only - Scope string `json:"scope"` + // Repositories is the list of allowed repository paths (legacy format, kept for backward compat) + // Deprecated: Use RepoAccess for new tokens + Repositories []string `json:"repos,omitempty"` + + // Scope is the access scope (legacy format, kept for backward compat) + // Deprecated: Use RepoAccess for new tokens + Scope string `json:"scope,omitempty"` } // RegistryTokenGenerator creates scoped registry access tokens @@ -37,6 +53,7 @@ func NewRegistryTokenGenerator(secret string) *RegistryTokenGenerator { // GeneratePushToken creates a short-lived token granting push access to specific repositories. // The token expires after the specified duration (typically matching the build timeout). +// Deprecated: Use GenerateToken for new code that needs per-repo scopes. func (g *RegistryTokenGenerator) GeneratePushToken(buildID string, repos []string, ttl time.Duration) (string, error) { if buildID == "" { return "", fmt.Errorf("build ID is required") @@ -62,6 +79,33 @@ func (g *RegistryTokenGenerator) GeneratePushToken(buildID string, repos []strin return token.SignedString(g.secret) } +// GenerateToken creates a short-lived token with per-repository access permissions. +// This supports the two-tier cache model where different repos can have different scopes. +// For example: pull on cache/global/*, push on cache/{tenant} +func (g *RegistryTokenGenerator) GenerateToken(buildID string, repoAccess []RepoPermission, ttl time.Duration) (string, error) { + if buildID == "" { + return "", fmt.Errorf("build ID is required") + } + if len(repoAccess) == 0 { + return "", fmt.Errorf("at least one repository permission is required") + } + + now := time.Now() + claims := RegistryTokenClaims{ + RegisteredClaims: jwt.RegisteredClaims{ + Subject: "builder-" + buildID, + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(ttl)), + Issuer: "hypeman", + }, + BuildID: buildID, + RepoAccess: repoAccess, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(g.secret) +} + // ValidateToken parses and validates a registry token, returning the claims if valid. func (g *RegistryTokenGenerator) ValidateToken(tokenString string) (*RegistryTokenClaims, error) { token, err := jwt.ParseWithClaims(tokenString, &RegistryTokenClaims{}, func(token *jwt.Token) (interface{}, error) { @@ -86,6 +130,17 @@ func (g *RegistryTokenGenerator) ValidateToken(tokenString string) (*RegistryTok // IsRepositoryAllowed checks if the given repository path is allowed by the token claims. func (c *RegistryTokenClaims) IsRepositoryAllowed(repo string) bool { + // Check new per-repo access format first + if len(c.RepoAccess) > 0 { + for _, perm := range c.RepoAccess { + if perm.Repo == repo { + return true + } + } + return false + } + + // Fall back to legacy format for _, allowed := range c.Repositories { if allowed == repo { return true @@ -94,13 +149,72 @@ func (c *RegistryTokenClaims) IsRepositoryAllowed(repo string) bool { return false } -// IsPushAllowed returns true if the token grants push (write) access. +// GetRepoScope returns the scope for a specific repository. +// Returns empty string if the repository is not allowed. +func (c *RegistryTokenClaims) GetRepoScope(repo string) string { + // Check new per-repo access format first + if len(c.RepoAccess) > 0 { + for _, perm := range c.RepoAccess { + if perm.Repo == repo { + return perm.Scope + } + } + return "" + } + + // Fall back to legacy format - all repos have the same scope + for _, allowed := range c.Repositories { + if allowed == repo { + return c.Scope + } + } + return "" +} + +// IsPushAllowedForRepo returns true if the token grants push (write) access to the given repo. +func (c *RegistryTokenClaims) IsPushAllowedForRepo(repo string) bool { + scope := c.GetRepoScope(repo) + return scope == "push" +} + +// IsPullAllowedForRepo returns true if the token grants pull (read) access to the given repo. +// Push scope also implicitly grants pull access. +func (c *RegistryTokenClaims) IsPullAllowedForRepo(repo string) bool { + scope := c.GetRepoScope(repo) + return scope == "push" || scope == "pull" +} + +// IsPushAllowed returns true if the token grants push (write) access to any repo. +// Deprecated: Use IsPushAllowedForRepo for per-repo scope checking. func (c *RegistryTokenClaims) IsPushAllowed() bool { + // Check new per-repo access format first + if len(c.RepoAccess) > 0 { + for _, perm := range c.RepoAccess { + if perm.Scope == "push" { + return true + } + } + return false + } + + // Fall back to legacy format return c.Scope == "push" } // IsPullAllowed returns true if the token grants pull (read) access. // Push tokens also implicitly grant pull access. +// Deprecated: Use IsPullAllowedForRepo for per-repo scope checking. func (c *RegistryTokenClaims) IsPullAllowed() bool { + // Check new per-repo access format first + if len(c.RepoAccess) > 0 { + for _, perm := range c.RepoAccess { + if perm.Scope == "push" || perm.Scope == "pull" { + return true + } + } + return false + } + + // Fall back to legacy format return c.Scope == "push" || c.Scope == "pull" } diff --git a/lib/builds/registry_token_test.go b/lib/builds/registry_token_test.go index 1231e07..dcb36bb 100644 --- a/lib/builds/registry_token_test.go +++ b/lib/builds/registry_token_test.go @@ -109,3 +109,123 @@ func TestRegistryTokenClaims_IsPushAllowed(t *testing.T) { assert.True(t, claims.IsPullAllowed()) }) } + +func TestRegistryTokenGenerator_GenerateToken(t *testing.T) { + generator := NewRegistryTokenGenerator("test-secret-key") + + t.Run("valid token with per-repo permissions", func(t *testing.T) { + repoAccess := []RepoPermission{ + {Repo: "builds/build-123", Scope: "push"}, + {Repo: "cache/global/node", Scope: "pull"}, + {Repo: "cache/tenant-x", Scope: "push"}, + } + token, err := generator.GenerateToken("build-123", repoAccess, 30*time.Minute) + require.NoError(t, err) + assert.NotEmpty(t, token) + + // Validate the token + claims, err := generator.ValidateToken(token) + require.NoError(t, err) + assert.Equal(t, "build-123", claims.BuildID) + assert.Equal(t, repoAccess, claims.RepoAccess) + assert.Equal(t, "builder-build-123", claims.Subject) + assert.Equal(t, "hypeman", claims.Issuer) + }) + + t.Run("empty build ID", func(t *testing.T) { + _, err := generator.GenerateToken("", []RepoPermission{{Repo: "builds/build-123", Scope: "push"}}, 30*time.Minute) + require.Error(t, err) + assert.Contains(t, err.Error(), "build ID is required") + }) + + t.Run("empty repo access", func(t *testing.T) { + _, err := generator.GenerateToken("build-123", []RepoPermission{}, 30*time.Minute) + require.Error(t, err) + assert.Contains(t, err.Error(), "at least one repository permission is required") + }) +} + +func TestRegistryTokenClaims_RepoAccess(t *testing.T) { + // Test claims with new per-repo access format + claims := &RegistryTokenClaims{ + RepoAccess: []RepoPermission{ + {Repo: "builds/abc123", Scope: "push"}, + {Repo: "cache/global/node", Scope: "pull"}, + {Repo: "cache/tenant-x", Scope: "push"}, + }, + } + + t.Run("IsRepositoryAllowed with RepoAccess", func(t *testing.T) { + assert.True(t, claims.IsRepositoryAllowed("builds/abc123")) + assert.True(t, claims.IsRepositoryAllowed("cache/global/node")) + assert.True(t, claims.IsRepositoryAllowed("cache/tenant-x")) + assert.False(t, claims.IsRepositoryAllowed("builds/other")) + assert.False(t, claims.IsRepositoryAllowed("cache/global/python")) + }) + + t.Run("GetRepoScope", func(t *testing.T) { + assert.Equal(t, "push", claims.GetRepoScope("builds/abc123")) + assert.Equal(t, "pull", claims.GetRepoScope("cache/global/node")) + assert.Equal(t, "push", claims.GetRepoScope("cache/tenant-x")) + assert.Equal(t, "", claims.GetRepoScope("builds/other")) + }) + + t.Run("IsPushAllowedForRepo", func(t *testing.T) { + assert.True(t, claims.IsPushAllowedForRepo("builds/abc123")) + assert.False(t, claims.IsPushAllowedForRepo("cache/global/node")) + assert.True(t, claims.IsPushAllowedForRepo("cache/tenant-x")) + assert.False(t, claims.IsPushAllowedForRepo("builds/other")) + }) + + t.Run("IsPullAllowedForRepo", func(t *testing.T) { + assert.True(t, claims.IsPullAllowedForRepo("builds/abc123")) // push implies pull + assert.True(t, claims.IsPullAllowedForRepo("cache/global/node")) + assert.True(t, claims.IsPullAllowedForRepo("cache/tenant-x")) // push implies pull + assert.False(t, claims.IsPullAllowedForRepo("builds/other")) + }) + + t.Run("IsPushAllowed with mixed scopes", func(t *testing.T) { + assert.True(t, claims.IsPushAllowed()) // At least one repo has push + }) + + t.Run("IsPullAllowed with mixed scopes", func(t *testing.T) { + assert.True(t, claims.IsPullAllowed()) + }) +} + +func TestRegistryTokenClaims_RepoAccessPullOnly(t *testing.T) { + // Test claims with only pull access + claims := &RegistryTokenClaims{ + RepoAccess: []RepoPermission{ + {Repo: "cache/global/node", Scope: "pull"}, + }, + } + + t.Run("IsPushAllowed returns false for pull-only token", func(t *testing.T) { + assert.False(t, claims.IsPushAllowed()) + }) + + t.Run("IsPullAllowed returns true for pull-only token", func(t *testing.T) { + assert.True(t, claims.IsPullAllowed()) + }) +} + +func TestRegistryTokenClaims_LegacyFallback(t *testing.T) { + // Test that legacy format still works when RepoAccess is empty + claims := &RegistryTokenClaims{ + Repositories: []string{"builds/abc123", "cache/tenant-x"}, + Scope: "push", + } + + t.Run("IsRepositoryAllowed uses legacy format", func(t *testing.T) { + assert.True(t, claims.IsRepositoryAllowed("builds/abc123")) + assert.True(t, claims.IsRepositoryAllowed("cache/tenant-x")) + assert.False(t, claims.IsRepositoryAllowed("builds/other")) + }) + + t.Run("GetRepoScope uses legacy format", func(t *testing.T) { + assert.Equal(t, "push", claims.GetRepoScope("builds/abc123")) + assert.Equal(t, "push", claims.GetRepoScope("cache/tenant-x")) + assert.Equal(t, "", claims.GetRepoScope("builds/other")) + }) +} diff --git a/lib/builds/types.go b/lib/builds/types.go index 0649bcf..49c4fd0 100644 --- a/lib/builds/types.go +++ b/lib/builds/types.go @@ -53,6 +53,15 @@ type CreateBuildRequest struct { // Secrets are secret references to inject during build Secrets []SecretRef `json:"secrets,omitempty"` + + // IsAdminBuild grants push access to global cache (operator-only). + // Regular tenant builds only get pull access to global cache. + IsAdminBuild bool `json:"is_admin_build,omitempty"` + + // GlobalCacheKey is the global cache identifier (e.g., "node", "python", "ubuntu", "browser"). + // 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"` } // BuildPolicy defines resource limits and network policy for a build @@ -138,6 +147,12 @@ type BuildConfig struct { // NetworkMode is "isolated" or "egress" NetworkMode string `json:"network_mode"` + + // IsAdminBuild indicates this is an admin build with push access to global cache + IsAdminBuild bool `json:"is_admin_build,omitempty"` + + // GlobalCacheKey is the global cache identifier (e.g., "node", "python", "ubuntu", "browser") + GlobalCacheKey string `json:"global_cache_key,omitempty"` } // BuildEvent represents a typed SSE event for build streaming diff --git a/openapi.yaml b/openapi.yaml index e39a0e6..bd85c46 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2319,6 +2319,17 @@ paths: JSON array of secret references to inject during build. Each object has "id" (required) for use with --mount=type=secret,id=... Example: [{"id": "npm_token"}, {"id": "github_token"}] + is_admin_build: + type: string + description: | + 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. + global_cache_key: + type: string + description: | + Global cache identifier (e.g., "node", "python", "ubuntu", "browser"). + When specified, the build will import from cache/global/{key}. + Admin builds will also export to this location. responses: 202: description: Build created and queued