diff --git a/backend/internal/models/container_registry.go b/backend/internal/models/container_registry.go index 5495d01822..0600d0eed9 100644 --- a/backend/internal/models/container_registry.go +++ b/backend/internal/models/container_registry.go @@ -7,20 +7,21 @@ import ( type ContainerRegistry struct { BaseModel - URL string `json:"url" sortable:"true"` - Username string `json:"username" sortable:"true"` - Token string `json:"token"` - Description *string `json:"description,omitempty" sortable:"true"` - Insecure bool `json:"insecure" sortable:"true"` - Enabled bool `json:"enabled" sortable:"true"` - RegistryType string `json:"registryType" sortable:"true"` - AWSAccessKeyID string `json:"awsAccessKeyId"` - AWSSecretAccessKey string `json:"awsSecretAccessKey"` - AWSRegion string `json:"awsRegion"` - ECRToken string `json:"ecrToken"` - ECRTokenGeneratedAt *time.Time `json:"ecrTokenGeneratedAt"` - CreatedAt time.Time `json:"createdAt" sortable:"true"` - UpdatedAt time.Time `json:"updatedAt" sortable:"true"` + URL string `json:"url" sortable:"true"` + Username string `json:"username" sortable:"true"` + Token string `json:"token"` + Description *string `json:"description,omitempty" sortable:"true"` + Insecure bool `json:"insecure" sortable:"true"` + Enabled bool `json:"enabled" sortable:"true"` + RegistryType string `json:"registryType" sortable:"true"` + RepositoryNames StringSlice `json:"repositoryNames" gorm:"column:repository_names;type:text;not null;default:'[]'"` + AWSAccessKeyID string `json:"awsAccessKeyId"` + AWSSecretAccessKey string `json:"awsSecretAccessKey"` + AWSRegion string `json:"awsRegion"` + ECRToken string `json:"ecrToken"` + ECRTokenGeneratedAt *time.Time `json:"ecrTokenGeneratedAt"` + CreatedAt time.Time `json:"createdAt" sortable:"true"` + UpdatedAt time.Time `json:"updatedAt" sortable:"true"` } func (ContainerRegistry) TableName() string { @@ -28,27 +29,29 @@ func (ContainerRegistry) TableName() string { } type CreateContainerRegistryRequest struct { - URL string `json:"url" binding:"required"` - Username string `json:"username"` - Token string `json:"token"` - Description *string `json:"description"` - Insecure *bool `json:"insecure"` - Enabled *bool `json:"enabled"` - RegistryType string `json:"registryType"` - AWSAccessKeyID string `json:"awsAccessKeyId"` - AWSSecretAccessKey string `json:"awsSecretAccessKey"` - AWSRegion string `json:"awsRegion"` + URL string `json:"url" binding:"required"` + Username string `json:"username"` + Token string `json:"token"` + Description *string `json:"description"` + Insecure *bool `json:"insecure"` + Enabled *bool `json:"enabled"` + RegistryType string `json:"registryType"` + RepositoryNames []string `json:"repositoryNames"` + AWSAccessKeyID string `json:"awsAccessKeyId"` + AWSSecretAccessKey string `json:"awsSecretAccessKey"` + AWSRegion string `json:"awsRegion"` } type UpdateContainerRegistryRequest struct { - URL *string `json:"url"` - Username *string `json:"username"` - Token *string `json:"token"` - Description *string `json:"description"` - Insecure *bool `json:"insecure"` - Enabled *bool `json:"enabled"` - RegistryType *string `json:"registryType"` - AWSAccessKeyID *string `json:"awsAccessKeyId"` - AWSSecretAccessKey *string `json:"awsSecretAccessKey"` - AWSRegion *string `json:"awsRegion"` + URL *string `json:"url"` + Username *string `json:"username"` + Token *string `json:"token"` + Description *string `json:"description"` + Insecure *bool `json:"insecure"` + Enabled *bool `json:"enabled"` + RegistryType *string `json:"registryType"` + RepositoryNames *[]string `json:"repositoryNames"` + AWSAccessKeyID *string `json:"awsAccessKeyId"` + AWSSecretAccessKey *string `json:"awsSecretAccessKey"` + AWSRegion *string `json:"awsRegion"` } diff --git a/backend/internal/services/container_registry_service.go b/backend/internal/services/container_registry_service.go index 335a960d49..61e1b82e5a 100644 --- a/backend/internal/services/container_registry_service.go +++ b/backend/internal/services/container_registry_service.go @@ -12,6 +12,7 @@ import ( "time" "github.com/cenkalti/backoff/v5" + ref "github.com/distribution/reference" "golang.org/x/sync/singleflight" cerrdefs "github.com/containerd/errdefs" @@ -132,15 +133,20 @@ func (s *ContainerRegistryService) CreateRegistry(ctx context.Context, req model if err != nil { return nil, err } + repositoryNames, err := normalizeRepositoryNames(req.RepositoryNames) + if err != nil { + return nil, err + } registry := &models.ContainerRegistry{ - URL: req.URL, - Description: req.Description, - Insecure: req.Insecure != nil && *req.Insecure, - Enabled: req.Enabled == nil || *req.Enabled, - RegistryType: registryType, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), + URL: req.URL, + Description: req.Description, + Insecure: req.Insecure != nil && *req.Insecure, + Enabled: req.Enabled == nil || *req.Enabled, + RegistryType: registryType, + RepositoryNames: repositoryNames, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), } if registryType == registryTypeECR { @@ -194,6 +200,15 @@ func (s *ContainerRegistryService) UpdateRegistry(ctx context.Context, id string utils.UpdateIfChanged(®istry.Insecure, req.Insecure) utils.UpdateIfChanged(®istry.Enabled, req.Enabled) + // RepositoryNames: nil pointer means "don't touch"; empty slice means "clear". + if req.RepositoryNames != nil { + repositoryNames, err := normalizeRepositoryNames(*req.RepositoryNames) + if err != nil { + return nil, err + } + registry.RepositoryNames = repositoryNames + } + if err := s.applyRegistryTypeUpdateInternal(registry, req.RegistryType); err != nil { return nil, err } @@ -1142,6 +1157,17 @@ func (s *ContainerRegistryService) checkRegistryNeedsUpdateInternal(item contain needsUpdate = utils.UpdateIfChanged(&existing.Insecure, item.Insecure) || needsUpdate needsUpdate = utils.UpdateIfChanged(&existing.Enabled, item.Enabled) || needsUpdate + // Sync repository names from the manager. normalizeRepositoryNames ensures + // a deterministic representation so the comparison below is meaningful. + normalizedRepoNames, err := normalizeRepositoryNames(item.RepositoryNames) + if err != nil { + return false, err + } + if !stringSlicesEqualInternal(existing.RepositoryNames, normalizedRepoNames) { + existing.RepositoryNames = normalizedRepoNames + needsUpdate = true + } + // Clear stale credentials when registry type changes during sync if newType != existing.RegistryType { if newType == registryTypeECR { @@ -1200,20 +1226,25 @@ func (s *ContainerRegistryService) createNewRegistryInternal(ctx context.Context if err != nil { return err } + repositoryNames, err := normalizeRepositoryNames(item.RepositoryNames) + if err != nil { + return err + } newRegistry := &models.ContainerRegistry{ BaseModel: models.BaseModel{ ID: item.ID, }, - URL: item.URL, - Description: item.Description, - Insecure: item.Insecure, - Enabled: item.Enabled, - RegistryType: registryType, - AWSAccessKeyID: item.AWSAccessKeyID, - AWSRegion: item.AWSRegion, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), + URL: item.URL, + Description: item.Description, + Insecure: item.Insecure, + Enabled: item.Enabled, + RegistryType: registryType, + RepositoryNames: repositoryNames, + AWSAccessKeyID: item.AWSAccessKeyID, + AWSRegion: item.AWSRegion, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), } if registryType == registryTypeGeneric { @@ -1256,6 +1287,56 @@ func normalizeRegistryTypeInternal(value string) (string, error) { } } +// normalizeRepositoryNames trims each entry, filters out empty strings, and +// deduplicates while preserving first-occurrence order. Every non-empty entry +// must be a valid Docker repository path prefix. It returns a non-nil slice +// (possibly empty) so that GORM always serializes to a JSON array. +func normalizeRepositoryNames(raw []string) (models.StringSlice, error) { + if len(raw) == 0 { + return models.StringSlice{}, nil + } + + seen := make(map[string]bool, len(raw)) + result := make(models.StringSlice, 0, len(raw)) + for _, entry := range raw { + trimmed := strings.TrimSpace(entry) + if trimmed == "" { + continue + } + if _, err := ref.ParseNormalizedNamed("registry.invalid/" + trimmed + "/placeholder:latest"); err != nil { + return nil, &models.ValidationError{ + Field: "repositoryNames", + Message: fmt.Sprintf("invalid repository namespace %q", trimmed), + } + } + if seen[trimmed] { + continue + } + seen[trimmed] = true + result = append(result, trimmed) + } + return result, nil +} + +// stringSlicesEqualInternal reports whether two string slices contain the +// same elements in the same order. Nil and empty slices are treated as equal +// to simplify the sync comparison where a missing field is equivalent to an +// empty list. +func stringSlicesEqualInternal(a, b []string) bool { + if len(a) == 0 && len(b) == 0 { + return true + } + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + func (s *ContainerRegistryService) deleteUnsyncedInternal(ctx context.Context, existingMap map[string]*models.ContainerRegistry, syncedIDs map[string]bool) error { for id := range existingMap { if !syncedIDs[id] { diff --git a/backend/internal/services/container_registry_service_test.go b/backend/internal/services/container_registry_service_test.go index 6a793549a1..ae1177b9cf 100644 --- a/backend/internal/services/container_registry_service_test.go +++ b/backend/internal/services/container_registry_service_test.go @@ -883,3 +883,120 @@ func TestContainerRegistryService_InspectImageDigest_PreservesAnonymousUnauthori assert.Contains(t, err.Error(), "status: 401") assert.Contains(t, err.Error(), "failed to load enabled registries") } + +func TestContainerRegistryService_CreateRegistry_PersistsRepositoryNames(t *testing.T) { + _, db := setupImageServiceAuthTest(t) + svc := NewContainerRegistryService(db, nil, nil) + + reg, err := svc.CreateRegistry(context.Background(), models.CreateContainerRegistryRequest{ + URL: "https://registry.example.com", + Username: "my-user", + Token: "my-token", + RepositoryNames: []string{"team", "team/platform"}, + }) + require.NoError(t, err) + assert.Equal(t, models.StringSlice{"team", "team/platform"}, reg.RepositoryNames) + + // Verify the value was persisted to the database. + var fetched models.ContainerRegistry + require.NoError(t, db.WithContext(context.Background()).First(&fetched, "id = ?", reg.ID).Error) + assert.Equal(t, models.StringSlice{"team", "team/platform"}, fetched.RepositoryNames) +} + +func TestContainerRegistryService_CreateRegistry_NormalizesRepositoryNames(t *testing.T) { + _, db := setupImageServiceAuthTest(t) + svc := NewContainerRegistryService(db, nil, nil) + + reg, err := svc.CreateRegistry(context.Background(), models.CreateContainerRegistryRequest{ + URL: "https://registry.example.com", + Username: "my-user", + Token: "my-token", + RepositoryNames: []string{ + " team ", + "", + "team", + "team/platform", + " team ", + }, + }) + require.NoError(t, err) + + // Entries should be trimmed, empties filtered, and duplicates removed + // while preserving first-occurrence order. + assert.Equal(t, models.StringSlice{"team", "team/platform"}, reg.RepositoryNames) + + var fetched models.ContainerRegistry + require.NoError(t, db.WithContext(context.Background()).First(&fetched, "id = ?", reg.ID).Error) + assert.Equal(t, models.StringSlice{"team", "team/platform"}, fetched.RepositoryNames) +} + +func TestContainerRegistryService_CreateRegistry_RejectsInvalidRepositoryNames(t *testing.T) { + _, db := setupImageServiceAuthTest(t) + svc := NewContainerRegistryService(db, nil, nil) + + for _, repositoryName := range []string{"/team", "team/", "team:latest", "team name"} { + t.Run(repositoryName, func(t *testing.T) { + _, err := svc.CreateRegistry(context.Background(), models.CreateContainerRegistryRequest{ + URL: "https://registry.example.com", + Username: "my-user", + Token: "my-token", + RepositoryNames: []string{repositoryName}, + }) + require.Error(t, err) + var validationErr *models.ValidationError + require.ErrorAs(t, err, &validationErr) + assert.Equal(t, "repositoryNames", validationErr.Field) + }) + } +} + +func TestContainerRegistryService_UpdateRegistry_ClearsRepositoryNamesWithEmptySlice(t *testing.T) { + _, db := setupImageServiceAuthTest(t) + svc := NewContainerRegistryService(db, nil, nil) + + reg, err := svc.CreateRegistry(context.Background(), models.CreateContainerRegistryRequest{ + URL: "https://registry.example.com", + Username: "my-user", + Token: "my-token", + RepositoryNames: []string{"team", "team/platform"}, + }) + require.NoError(t, err) + require.Len(t, reg.RepositoryNames, 2) + + emptySlice := []string{} + updated, err := svc.UpdateRegistry(context.Background(), reg.ID, models.UpdateContainerRegistryRequest{ + RepositoryNames: &emptySlice, + }) + require.NoError(t, err) + assert.Empty(t, updated.RepositoryNames) + + var fetched models.ContainerRegistry + require.NoError(t, db.WithContext(context.Background()).First(&fetched, "id = ?", reg.ID).Error) + assert.Empty(t, fetched.RepositoryNames) +} + +func TestContainerRegistryService_UpdateRegistry_KeepsRepositoryNamesWithNilPointer(t *testing.T) { + _, db := setupImageServiceAuthTest(t) + svc := NewContainerRegistryService(db, nil, nil) + + reg, err := svc.CreateRegistry(context.Background(), models.CreateContainerRegistryRequest{ + URL: "https://registry.example.com", + Username: "my-user", + Token: "my-token", + RepositoryNames: []string{"team", "team/platform"}, + }) + require.NoError(t, err) + require.Len(t, reg.RepositoryNames, 2) + + // Update a different field while leaving RepositoryNames as nil. + updated, err := svc.UpdateRegistry(context.Background(), reg.ID, models.UpdateContainerRegistryRequest{ + Username: new("updated-user"), + }) + require.NoError(t, err) + assert.Equal(t, "updated-user", updated.Username) + assert.Equal(t, models.StringSlice{"team", "team/platform"}, updated.RepositoryNames) + + var fetched models.ContainerRegistry + require.NoError(t, db.WithContext(context.Background()).First(&fetched, "id = ?", reg.ID).Error) + assert.Equal(t, models.StringSlice{"team", "team/platform"}, fetched.RepositoryNames) +} diff --git a/backend/internal/services/environment_service.go b/backend/internal/services/environment_service.go index eb9b6f0988..08e6adba8a 100644 --- a/backend/internal/services/environment_service.go +++ b/backend/internal/services/environment_service.go @@ -1871,14 +1871,15 @@ func (s *EnvironmentService) SyncRegistriesToEnvironment(ctx context.Context, en } syncItem := containerregistry.Sync{ - ID: reg.ID, - URL: reg.URL, - Description: reg.Description, - Insecure: reg.Insecure, - Enabled: reg.Enabled, - RegistryType: registryType, - CreatedAt: reg.CreatedAt, - UpdatedAt: reg.UpdatedAt, + ID: reg.ID, + URL: reg.URL, + Description: reg.Description, + Insecure: reg.Insecure, + Enabled: reg.Enabled, + RegistryType: registryType, + RepositoryNames: reg.RepositoryNames, + CreatedAt: reg.CreatedAt, + UpdatedAt: reg.UpdatedAt, } if registryType == registryTypeECR { diff --git a/backend/resources/migrations/postgres/064_add_container_registry_repository_names.sql b/backend/resources/migrations/postgres/064_add_container_registry_repository_names.sql new file mode 100644 index 0000000000..29167ab7f6 --- /dev/null +++ b/backend/resources/migrations/postgres/064_add_container_registry_repository_names.sql @@ -0,0 +1,5 @@ +-- +goose Up +ALTER TABLE container_registries ADD COLUMN IF NOT EXISTS repository_names TEXT NOT NULL DEFAULT '[]'; + +-- +goose Down +ALTER TABLE container_registries DROP COLUMN IF EXISTS repository_names; diff --git a/backend/resources/migrations/sqlite/064_add_container_registry_repository_names.sql b/backend/resources/migrations/sqlite/064_add_container_registry_repository_names.sql new file mode 100644 index 0000000000..e05f3755a7 --- /dev/null +++ b/backend/resources/migrations/sqlite/064_add_container_registry_repository_names.sql @@ -0,0 +1,5 @@ +-- +goose Up +ALTER TABLE container_registries ADD COLUMN repository_names TEXT NOT NULL DEFAULT '[]'; + +-- +goose Down +ALTER TABLE container_registries DROP COLUMN repository_names; diff --git a/frontend/messages/en.json b/frontend/messages/en.json index ed840712c6..a37e8c261f 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -1962,6 +1962,9 @@ "registries_aws_secret_access_key": "AWS Secret Access Key", "registries_aws_region": "AWS Region", "registries_aws_region_placeholder": "e.g. us-east-1", + "registries_repository_names": "Repository Names", + "registries_repository_names_placeholder": "team/api\nteam/platform/api", + "registries_repository_names_description": "One complete repository name per line. Used when pushing images from the build page.", "registry_docker_hub_url": "docker.io", "registry_github_url": "ghcr.io", "registry_google_url": "gcr.io", @@ -3000,6 +3003,19 @@ "image_tags_placeholder": "my-image:latest", "image_tags_description": "Comma-separated list of tags", "image_tags_required": "At least one tag is required", + "build_push_registry_label": "Registry", + "build_push_registry_placeholder": "Select a registry", + "build_push_registry_none": "No enabled registries. Configure one in Registry settings.", + "build_push_registry_permission": "No permission to list registries. Ask an administrator for access.", + "build_push_repository_label": "Repository name", + "build_push_repository_placeholder": "Select a repository name", + "build_push_repository_empty": "No repository names configured for this registry. Add them in Registry settings.", + "build_push_tag_label": "Tag", + "build_push_tag_placeholder": "1.0.0", + "build_push_reference_label": "Image reference", + "build_push_registry_required": "Select a registry", + "build_push_repository_required": "Select a repository name", + "build_push_tag_required": "Tag is required", "dockerfile": "Dockerfile", "dockerfile_description": "Path to Dockerfile (relative to context)", "target_label": "Target", diff --git a/frontend/src/lib/components/sheets/container-registry-sheet.svelte b/frontend/src/lib/components/sheets/container-registry-sheet.svelte index 6362ac8657..c89115568e 100644 --- a/frontend/src/lib/components/sheets/container-registry-sheet.svelte +++ b/frontend/src/lib/components/sheets/container-registry-sheet.svelte @@ -37,6 +37,7 @@ username: z.string().optional(), token: z.string().optional(), description: z.string().optional(), + repositoryNames: z.string().optional(), insecure: z.boolean().default(false), enabled: z.boolean().default(true), awsAccessKeyId: z.string().optional(), @@ -90,6 +91,7 @@ username: open && registryToEdit ? registryToEdit.username : '', token: '', description: open && registryToEdit ? registryToEdit.description || '' : '', + repositoryNames: open && registryToEdit ? (registryToEdit.repositoryNames?.join('\n') ?? '') : '', insecure: open && registryToEdit ? (registryToEdit.insecure ?? false) : false, enabled: open && registryToEdit ? (registryToEdit.enabled ?? true) : true, awsAccessKeyId: open && registryToEdit ? (registryToEdit.awsAccessKeyId ?? '') : '', @@ -104,7 +106,13 @@ function handleSubmit() { const data = form.validate(); if (!data) return; - onSubmit({ registry: data, isEditMode }); + const repositoryNames = data.repositoryNames + ? data.repositoryNames + .split('\n') + .map((s) => s.trim()) + .filter((s) => s.length > 0) + : []; + onSubmit({ registry: { ...data, repositoryNames }, isEditMode }); } function handleOpenChange(newOpenState: boolean) { @@ -177,6 +185,14 @@ placeholder={m.registries_description_placeholder()} bind:input={$inputs.description} /> + (formSchema, { dockerfile: '', tags: '', + registryId: '', + repositoryName: '', + pushTag: '', target: '', buildArgs: '', labels: '', @@ -186,6 +193,63 @@ })); const buildHistoryItems = $derived>(buildHistoryQuery.data ?? EMPTY_BUILD_HISTORY); + const resolvedProvider = $derived(depotAvailable ? $inputs.provider.value : 'local'); + const isPushMode = $derived(resolvedProvider === 'depot' ? true : $inputs.push.value); + + const registriesQuery = createQuery(() => ({ + queryKey: queryKeys.containerRegistries.list({ + pagination: { page: 1, limit: 100 }, + sort: { column: 'url', direction: 'asc' } + }), + enabled: isPushMode, + queryFn: () => + containerRegistryService.getRegistries({ + pagination: { page: 1, limit: 100 }, + sort: { column: 'url', direction: 'asc' } + }) + })); + + const registryOptions = $derived.by(() => { + const opts: { label: string; value: string; description?: string }[] = []; + const regs = registriesQuery.data?.data ?? []; + for (const r of regs) { + if (!r.enabled) continue; + const type = r.registryType === 'ecr' ? 'ECR' : 'Generic'; + opts.push({ + label: `${type}: ${r.url}`, + value: r.id, + description: r.registryType === 'ecr' && r.awsRegion ? `AWS ${r.awsRegion}` : undefined + }); + } + return opts; + }); + + const selectedRegistry = $derived((registriesQuery.data?.data ?? []).find((r) => r.id === $inputs.registryId.value)); + + const repositoryOptions = $derived((selectedRegistry?.repositoryNames ?? []).map((name) => ({ label: name, value: name }))); + + function normalizeRegistryHost(url: string): string { + return url.replace(/^https?:\/\//, '').replace(/\/+$/, ''); + } + + const fullImageReference = $derived.by(() => { + if (!isPushMode || !selectedRegistry) return ''; + const repositoryName = $inputs.repositoryName.value.trim(); + const tag = $inputs.pushTag.value.trim(); + if (!repositoryName || !tag) return ''; + const host = normalizeRegistryHost(selectedRegistry.url); + return `${host}/${repositoryName}:${tag}`; + }); + + // Clear the repository name when registry changes to prevent cross-registry mismatches. + let lastRegistryId = $state($inputs.registryId.value); + $effect(() => { + const current = $inputs.registryId.value; + if (current !== lastRegistryId) { + lastRegistryId = current; + $inputs.repositoryName.value = ''; + } + }); let buildHistoryQueryLastError: string | null = null; @@ -783,7 +847,41 @@ buildStatusText = m.starting_build(); appendLog(m.using_context({ context: contextDir })); - const tags = parseTags(data.tags); + const resolvedProvider = depotAvailable ? data.provider : 'local'; + const push = resolvedProvider === 'depot' ? true : data.push; + const load = resolvedProvider === 'depot' ? false : data.load; + + let tags: string[]; + if (push) { + const reg = (registriesQuery.data?.data ?? []).find((r) => r.id === data.registryId); + if (!reg) { + toast.error(m.build_push_registry_required()); + isBuilding = false; + return; + } + const repositoryName = data.repositoryName.trim(); + const tag = data.pushTag.trim(); + if (!repositoryName) { + toast.error(m.build_push_repository_required()); + isBuilding = false; + return; + } + if (!tag) { + toast.error(m.build_push_tag_required()); + isBuilding = false; + return; + } + const host = normalizeRegistryHost(reg.url); + tags = [`${host}/${repositoryName}:${tag}`]; + } else { + tags = parseTags(data.tags); + if (tags.length === 0) { + toast.error(m.image_tags_required()); + isBuilding = false; + return; + } + } + const parsedCacheTo = parseList(data.cacheTo || ''); const parsedEntitlements = parseList(data.entitlements || ''); const parsedPlatforms = parsePlatforms(data.platforms || ''); @@ -791,9 +889,6 @@ const parsedUlimits = parseBuildArgs(data.ulimits || ''); const parsedShmSize = parseOptionalBytes(data.shmSize || ''); - const resolvedProvider = depotAvailable ? data.provider : 'local'; - const push = resolvedProvider === 'depot' ? true : data.push; - const load = resolvedProvider === 'depot' ? false : data.load; const network = data.network?.trim() || undefined; const isolation = data.isolation?.trim() || undefined; @@ -1312,7 +1407,17 @@ - + - + {:else} diff --git a/frontend/src/routes/(app)/images/builds/components/build-config-panel.svelte b/frontend/src/routes/(app)/images/builds/components/build-config-panel.svelte index 042f9aad4e..b62a8eddca 100644 --- a/frontend/src/routes/(app)/images/builds/components/build-config-panel.svelte +++ b/frontend/src/routes/(app)/images/builds/components/build-config-panel.svelte @@ -2,19 +2,32 @@ import * as Collapsible from '$lib/components/ui/collapsible/index.js'; import { ArrowDownIcon } from '$lib/icons'; import FormInput from '$lib/components/form/form-input.svelte'; + import SelectWithLabel from '$lib/components/form/select-with-label.svelte'; import { preventDefault } from '$lib/utils/settings'; import { m } from '$lib/paraglide/messages'; import type { BuildFormInputsStore } from './build-form.types'; + type RegistryOption = { label: string; value: string; description?: string }; + let { inputs, provider, showAdvanced = $bindable(false), + isPushMode = false, + registryOptions = [], + repositoryOptions = [], + fullImageReference = '', + registryLoadError = null, onSubmit }: { inputs: BuildFormInputsStore; provider: 'local' | 'depot'; showAdvanced?: boolean; + isPushMode?: boolean; + registryOptions?: RegistryOption[]; + repositoryOptions?: RegistryOption[]; + fullImageReference?: string; + registryLoadError?: { message?: string } | null; onSubmit?: () => void; } = $props(); @@ -25,13 +38,59 @@
onSubmit?.())} class="space-y-7">
- + {#if isPushMode} + + {#if registryLoadError} +

{m.build_push_registry_permission()}

+ {:else if registryOptions.length === 0} +

{m.build_push_registry_none()}

+ {/if} + + + {#if $inputs.registryId.value && repositoryOptions.length === 0 && !registryLoadError} +

{m.build_push_repository_empty()}

+ {/if} + + + + {#if fullImageReference} +
+ + {m.build_push_reference_label()} + +
+ {fullImageReference} +
+
+ {/if} + {:else} + + {/if} ; tags: FormInput; + registryId: FormInput; + repositoryName: FormInput; + pushTag: FormInput; target: FormInput; buildArgs: FormInput; labels: FormInput; diff --git a/types/containerregistry/container_registry.go b/types/containerregistry/container_registry.go index 7388e89d2d..3f079e5b01 100644 --- a/types/containerregistry/container_registry.go +++ b/types/containerregistry/container_registry.go @@ -39,6 +39,12 @@ type ContainerRegistry struct { // Required: true RegistryType string `json:"registryType"` + // RepositoryNames is the list of pre-configured repository namespace + // strings available for selection when pushing images from the build page. + // + // Required: false + RepositoryNames []string `json:"repositoryNames"` + // AWSAccessKeyID is the AWS Access Key ID for ECR registries. // // Required: false @@ -185,6 +191,12 @@ type Sync struct { // Required: true RegistryType string `json:"registryType"` + // RepositoryNames is the list of pre-configured repository namespace + // strings available for selection when pushing images from the build page. + // + // Required: false + RepositoryNames []string `json:"repositoryNames"` + // AWSAccessKeyID is the AWS Access Key ID for ECR registries. // // Required: false