Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
71 changes: 37 additions & 34 deletions backend/internal/models/container_registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,48 +7,51 @@ 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 {
return "container_registries"
}

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"`
}
113 changes: 97 additions & 16 deletions backend/internal/services/container_registry_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -194,6 +200,15 @@ func (s *ContainerRegistryService) UpdateRegistry(ctx context.Context, id string
utils.UpdateIfChanged(&registry.Insecure, req.Insecure)
utils.UpdateIfChanged(&registry.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
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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] {
Expand Down
117 changes: 117 additions & 0 deletions backend/internal/services/container_registry_service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
17 changes: 9 additions & 8 deletions backend/internal/services/environment_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading