feat: add private Docker registry authentication for base images
All checks were successful
Check / check (pull_request) Successful in 3m34s
All checks were successful
Check / check (pull_request) Successful in 3m34s
Add per-app registry credentials that are passed to Docker during image builds, allowing apps to use base images from private registries. - New registry_credentials table (migration 007) - RegistryCredential model with full CRUD operations - Docker client passes AuthConfigs to ImageBuild when credentials exist - Deploy service fetches app registry credentials before builds - Web UI section for managing registry credentials (add/edit/delete) - Comprehensive unit tests for model and auth config builder - README updated to list the feature
This commit is contained in:
@@ -119,6 +119,11 @@ func (a *App) GetWebhookEvents(
|
||||
return FindWebhookEventsByAppID(ctx, a.db, a.ID, limit)
|
||||
}
|
||||
|
||||
// GetRegistryCredentials returns all registry credentials for the app.
|
||||
func (a *App) GetRegistryCredentials(ctx context.Context) ([]*RegistryCredential, error) {
|
||||
return FindRegistryCredentialsByAppID(ctx, a.db, a.ID)
|
||||
}
|
||||
|
||||
func (a *App) exists(ctx context.Context) bool {
|
||||
if a.ID == "" {
|
||||
return false
|
||||
|
||||
@@ -23,6 +23,7 @@ const (
|
||||
testBranch = "main"
|
||||
testValue = "value"
|
||||
testEventType = "push"
|
||||
testUser = "user"
|
||||
)
|
||||
|
||||
func setupTestDB(t *testing.T) (*database.Database, func()) {
|
||||
@@ -704,6 +705,127 @@ func TestAppGetWebhookEvents(t *testing.T) {
|
||||
assert.Len(t, events, 1)
|
||||
}
|
||||
|
||||
// RegistryCredential Tests.
|
||||
|
||||
func TestRegistryCredentialCreateAndFind(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testDB, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
app := createTestApp(t, testDB)
|
||||
|
||||
cred := models.NewRegistryCredential(testDB)
|
||||
cred.AppID = app.ID
|
||||
cred.Registry = "registry.example.com"
|
||||
cred.Username = "myuser"
|
||||
cred.Password = "mypassword"
|
||||
|
||||
err := cred.Save(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.NotZero(t, cred.ID)
|
||||
|
||||
creds, err := models.FindRegistryCredentialsByAppID(
|
||||
context.Background(), testDB, app.ID,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, creds, 1)
|
||||
assert.Equal(t, "registry.example.com", creds[0].Registry)
|
||||
assert.Equal(t, "myuser", creds[0].Username)
|
||||
assert.Equal(t, "mypassword", creds[0].Password)
|
||||
}
|
||||
|
||||
func TestRegistryCredentialUpdate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testDB, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
app := createTestApp(t, testDB)
|
||||
|
||||
cred := models.NewRegistryCredential(testDB)
|
||||
cred.AppID = app.ID
|
||||
cred.Registry = "old.registry.com"
|
||||
cred.Username = "olduser"
|
||||
cred.Password = "oldpass"
|
||||
|
||||
err := cred.Save(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
cred.Registry = "new.registry.com"
|
||||
cred.Username = "newuser"
|
||||
cred.Password = "newpass"
|
||||
|
||||
err = cred.Save(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
found, err := models.FindRegistryCredential(context.Background(), testDB, cred.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, found)
|
||||
assert.Equal(t, "new.registry.com", found.Registry)
|
||||
assert.Equal(t, "newuser", found.Username)
|
||||
assert.Equal(t, "newpass", found.Password)
|
||||
}
|
||||
|
||||
func TestRegistryCredentialDelete(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testDB, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
app := createTestApp(t, testDB)
|
||||
|
||||
cred := models.NewRegistryCredential(testDB)
|
||||
cred.AppID = app.ID
|
||||
cred.Registry = "delete.registry.com"
|
||||
cred.Username = testUser
|
||||
cred.Password = "pass"
|
||||
|
||||
err := cred.Save(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
err = cred.Delete(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
creds, err := models.FindRegistryCredentialsByAppID(
|
||||
context.Background(), testDB, app.ID,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, creds)
|
||||
}
|
||||
|
||||
func TestRegistryCredentialFindByIDNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testDB, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
found, err := models.FindRegistryCredential(context.Background(), testDB, 99999)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, found)
|
||||
}
|
||||
|
||||
func TestAppGetRegistryCredentials(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testDB, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
app := createTestApp(t, testDB)
|
||||
|
||||
cred := models.NewRegistryCredential(testDB)
|
||||
cred.AppID = app.ID
|
||||
cred.Registry = "ghcr.io"
|
||||
cred.Username = testUser
|
||||
cred.Password = "token"
|
||||
_ = cred.Save(context.Background())
|
||||
|
||||
creds, err := app.GetRegistryCredentials(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, creds, 1)
|
||||
assert.Equal(t, "ghcr.io", creds[0].Registry)
|
||||
}
|
||||
|
||||
// Cascade Delete Tests.
|
||||
|
||||
//nolint:funlen // Test function with many assertions - acceptable for integration tests
|
||||
@@ -749,6 +871,13 @@ func TestCascadeDelete(t *testing.T) {
|
||||
deploy.Status = models.DeploymentStatusSuccess
|
||||
_ = deploy.Save(context.Background())
|
||||
|
||||
regCred := models.NewRegistryCredential(testDB)
|
||||
regCred.AppID = app.ID
|
||||
regCred.Registry = "registry.example.com"
|
||||
regCred.Username = testUser
|
||||
regCred.Password = "pass"
|
||||
_ = regCred.Save(context.Background())
|
||||
|
||||
// Delete app.
|
||||
err := app.Delete(context.Background())
|
||||
require.NoError(t, err)
|
||||
@@ -778,6 +907,11 @@ func TestCascadeDelete(t *testing.T) {
|
||||
context.Background(), testDB, app.ID, 10,
|
||||
)
|
||||
assert.Empty(t, deployments)
|
||||
|
||||
regCreds, _ := models.FindRegistryCredentialsByAppID(
|
||||
context.Background(), testDB, app.ID,
|
||||
)
|
||||
assert.Empty(t, regCreds)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
130
internal/models/registry_credential.go
Normal file
130
internal/models/registry_credential.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"sneak.berlin/go/upaas/internal/database"
|
||||
)
|
||||
|
||||
// RegistryCredential represents authentication credentials for a private Docker registry.
|
||||
type RegistryCredential struct {
|
||||
db *database.Database
|
||||
|
||||
ID int64
|
||||
AppID string
|
||||
Registry string
|
||||
Username string
|
||||
Password string //nolint:gosec // credential field required for registry auth
|
||||
}
|
||||
|
||||
// NewRegistryCredential creates a new RegistryCredential with a database reference.
|
||||
func NewRegistryCredential(db *database.Database) *RegistryCredential {
|
||||
return &RegistryCredential{db: db}
|
||||
}
|
||||
|
||||
// Save inserts or updates the registry credential in the database.
|
||||
func (r *RegistryCredential) Save(ctx context.Context) error {
|
||||
if r.ID == 0 {
|
||||
return r.insert(ctx)
|
||||
}
|
||||
|
||||
return r.update(ctx)
|
||||
}
|
||||
|
||||
// Delete removes the registry credential from the database.
|
||||
func (r *RegistryCredential) Delete(ctx context.Context) error {
|
||||
_, err := r.db.Exec(ctx, "DELETE FROM registry_credentials WHERE id = ?", r.ID)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *RegistryCredential) insert(ctx context.Context) error {
|
||||
query := "INSERT INTO registry_credentials (app_id, registry, username, password) VALUES (?, ?, ?, ?)"
|
||||
|
||||
result, err := r.db.Exec(ctx, query, r.AppID, r.Registry, r.Username, r.Password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
r.ID = id
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *RegistryCredential) update(ctx context.Context) error {
|
||||
query := "UPDATE registry_credentials SET registry = ?, username = ?, password = ? WHERE id = ?"
|
||||
|
||||
_, err := r.db.Exec(ctx, query, r.Registry, r.Username, r.Password, r.ID)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// FindRegistryCredential finds a registry credential by ID.
|
||||
//
|
||||
//nolint:nilnil // returning nil,nil is idiomatic for "not found" in Active Record
|
||||
func FindRegistryCredential(
|
||||
ctx context.Context,
|
||||
db *database.Database,
|
||||
id int64,
|
||||
) (*RegistryCredential, error) {
|
||||
cred := NewRegistryCredential(db)
|
||||
|
||||
row := db.QueryRow(ctx,
|
||||
"SELECT id, app_id, registry, username, password FROM registry_credentials WHERE id = ?",
|
||||
id,
|
||||
)
|
||||
|
||||
err := row.Scan(&cred.ID, &cred.AppID, &cred.Registry, &cred.Username, &cred.Password)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("scanning registry credential: %w", err)
|
||||
}
|
||||
|
||||
return cred, nil
|
||||
}
|
||||
|
||||
// FindRegistryCredentialsByAppID finds all registry credentials for an app.
|
||||
func FindRegistryCredentialsByAppID(
|
||||
ctx context.Context,
|
||||
db *database.Database,
|
||||
appID string,
|
||||
) ([]*RegistryCredential, error) {
|
||||
query := `
|
||||
SELECT id, app_id, registry, username, password FROM registry_credentials
|
||||
WHERE app_id = ? ORDER BY registry`
|
||||
|
||||
rows, err := db.Query(ctx, query, appID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("querying registry credentials by app: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var creds []*RegistryCredential
|
||||
|
||||
for rows.Next() {
|
||||
cred := NewRegistryCredential(db)
|
||||
|
||||
scanErr := rows.Scan(
|
||||
&cred.ID, &cred.AppID, &cred.Registry, &cred.Username, &cred.Password,
|
||||
)
|
||||
if scanErr != nil {
|
||||
return nil, scanErr
|
||||
}
|
||||
|
||||
creds = append(creds, cred)
|
||||
}
|
||||
|
||||
return creds, rows.Err()
|
||||
}
|
||||
Reference in New Issue
Block a user