From 0f4acb554e5d3393be79536384fa9be8cbde19ba Mon Sep 17 00:00:00 2001 From: user Date: Tue, 17 Mar 2026 02:14:39 -0700 Subject: [PATCH 1/2] feat: add private Docker registry authentication for base images 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 --- README.md | 1 + .../007_add_registry_credentials.sql | 11 ++ internal/docker/auth_test.go | 96 ++++++++++++ internal/docker/client.go | 36 ++++- internal/handlers/app.go | 145 ++++++++++++++++-- internal/models/app.go | 5 + internal/models/models_test.go | 134 ++++++++++++++++ internal/models/registry_credential.go | 130 ++++++++++++++++ internal/server/routes.go | 5 + internal/service/deploy/deploy.go | 36 +++++ templates/app_detail.html | 63 ++++++++ 11 files changed, 649 insertions(+), 13 deletions(-) create mode 100644 internal/database/migrations/007_add_registry_credentials.sql create mode 100644 internal/docker/auth_test.go create mode 100644 internal/models/registry_credential.go diff --git a/README.md b/README.md index 91877d7..73a1d22 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ A simple self-hosted PaaS that auto-deploys Docker containers from Git repositor - Per-app UUID-based webhook URLs for Gitea integration - Branch filtering - only deploy on configured branch changes - Environment variables, labels, and volume mounts per app +- Private Docker registry authentication for base images - Docker builds via socket access - Notifications via ntfy and Slack-compatible webhooks - Simple server-rendered UI with Tailwind CSS diff --git a/internal/database/migrations/007_add_registry_credentials.sql b/internal/database/migrations/007_add_registry_credentials.sql new file mode 100644 index 0000000..637b8f8 --- /dev/null +++ b/internal/database/migrations/007_add_registry_credentials.sql @@ -0,0 +1,11 @@ +-- Add registry credentials for private Docker registry authentication during builds +CREATE TABLE registry_credentials ( + id INTEGER PRIMARY KEY, + app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE, + registry TEXT NOT NULL, + username TEXT NOT NULL, + password TEXT NOT NULL, + UNIQUE(app_id, registry) +); + +CREATE INDEX idx_registry_credentials_app_id ON registry_credentials(app_id); diff --git a/internal/docker/auth_test.go b/internal/docker/auth_test.go new file mode 100644 index 0000000..c0ddc76 --- /dev/null +++ b/internal/docker/auth_test.go @@ -0,0 +1,96 @@ +package docker //nolint:testpackage // tests unexported buildAuthConfigs + +import ( + "testing" +) + +func TestBuildAuthConfigsEmpty(t *testing.T) { + t.Parallel() + + result := buildAuthConfigs(nil) + if len(result) != 0 { + t.Errorf("expected empty map, got %d entries", len(result)) + } +} + +func TestBuildAuthConfigsSingle(t *testing.T) { + t.Parallel() + + auths := []RegistryAuth{ + { + Registry: "registry.example.com", + Username: "user", + Password: "pass", + }, + } + + result := buildAuthConfigs(auths) + + if len(result) != 1 { + t.Fatalf("expected 1 entry, got %d", len(result)) + } + + cfg, ok := result["registry.example.com"] + if !ok { + t.Fatal("expected registry.example.com key") + } + + if cfg.Username != "user" { + t.Errorf("expected username 'user', got %q", cfg.Username) + } + + if cfg.Password != "pass" { + t.Errorf("expected password 'pass', got %q", cfg.Password) + } + + if cfg.ServerAddress != "registry.example.com" { + t.Errorf("expected server address 'registry.example.com', got %q", cfg.ServerAddress) + } +} + +func TestBuildAuthConfigsMultiple(t *testing.T) { + t.Parallel() + + auths := []RegistryAuth{ + {Registry: "ghcr.io", Username: "ghuser", Password: "ghtoken"}, + {Registry: "docker.io", Username: "dkuser", Password: "dktoken"}, + } + + result := buildAuthConfigs(auths) + + if len(result) != 2 { + t.Fatalf("expected 2 entries, got %d", len(result)) + } + + ghcr := result["ghcr.io"] + if ghcr.Username != "ghuser" || ghcr.Password != "ghtoken" { + t.Errorf("unexpected ghcr.io config: %+v", ghcr) + } + + dkr := result["docker.io"] + if dkr.Username != "dkuser" || dkr.Password != "dktoken" { + t.Errorf("unexpected docker.io config: %+v", dkr) + } +} + +func TestRegistryAuthStruct(t *testing.T) { + t.Parallel() + + auth := RegistryAuth{ + Registry: "registry.example.com", + Username: "testuser", + Password: "testpass", + } + + if auth.Registry != "registry.example.com" { + t.Errorf("expected registry 'registry.example.com', got %q", auth.Registry) + } + + if auth.Username != "testuser" { + t.Errorf("expected username 'testuser', got %q", auth.Username) + } + + if auth.Password != "testpass" { + t.Errorf("expected password 'testpass', got %q", auth.Password) + } +} diff --git a/internal/docker/client.go b/internal/docker/client.go index 8d62266..e6ef3a3 100644 --- a/internal/docker/client.go +++ b/internal/docker/client.go @@ -20,6 +20,7 @@ import ( "github.com/docker/docker/api/types/image" "github.com/docker/docker/api/types/mount" "github.com/docker/docker/api/types/network" + "github.com/docker/docker/api/types/registry" "github.com/docker/docker/client" "github.com/docker/docker/pkg/archive" "github.com/docker/go-connections/nat" @@ -105,12 +106,20 @@ func (c *Client) IsConnected() bool { return c.docker != nil } +// RegistryAuth contains authentication credentials for a Docker registry. +type RegistryAuth struct { + Registry string + Username string + Password string //nolint:gosec // credential field required for registry auth +} + // BuildImageOptions contains options for building an image. type BuildImageOptions struct { ContextDir string DockerfilePath string Tags []string - LogWriter io.Writer // Optional writer for build output + LogWriter io.Writer // Optional writer for build output + RegistryAuths []RegistryAuth // Optional registry credentials for pulling private base images } // BuildImage builds a Docker image from a context directory. @@ -161,6 +170,21 @@ type PortMapping struct { Protocol string // "tcp" or "udp" } +// buildAuthConfigs converts RegistryAuth slices into Docker's AuthConfigs map. +func buildAuthConfigs(auths []RegistryAuth) map[string]registry.AuthConfig { + configs := make(map[string]registry.AuthConfig, len(auths)) + + for _, auth := range auths { + configs[auth.Registry] = registry.AuthConfig{ + Username: auth.Username, + Password: auth.Password, + ServerAddress: auth.Registry, + } + } + + return configs +} + // buildPortConfig converts port mappings to Docker port configuration. func buildPortConfig(ports []PortMapping) (nat.PortSet, nat.PortMap) { exposedPorts := make(nat.PortSet) @@ -513,12 +537,18 @@ func (c *Client) performBuild( }() // Build image - resp, err := c.docker.ImageBuild(ctx, tarArchive, dockertypes.ImageBuildOptions{ + buildOpts := dockertypes.ImageBuildOptions{ Dockerfile: opts.DockerfilePath, Tags: opts.Tags, Remove: true, NoCache: false, - }) + } + + if len(opts.RegistryAuths) > 0 { + buildOpts.AuthConfigs = buildAuthConfigs(opts.RegistryAuths) + } + + resp, err := c.docker.ImageBuild(ctx, tarArchive, buildOpts) if err != nil { return "", fmt.Errorf("failed to build image: %w", err) } diff --git a/internal/handlers/app.go b/internal/handlers/app.go index d55c27c..1a626a6 100644 --- a/internal/handlers/app.go +++ b/internal/handlers/app.go @@ -148,6 +148,7 @@ func (h *Handlers) HandleAppDetail() http.HandlerFunc { labels, _ := application.GetLabels(request.Context()) volumes, _ := application.GetVolumes(request.Context()) ports, _ := application.GetPorts(request.Context()) + registryCreds, _ := application.GetRegistryCredentials(request.Context()) deployments, _ := application.GetDeployments( request.Context(), recentDeploymentsLimit, @@ -163,16 +164,17 @@ func (h *Handlers) HandleAppDetail() http.HandlerFunc { deployKey := formatDeployKey(application.SSHPublicKey, application.CreatedAt, application.Name) data := h.addGlobals(map[string]any{ - "App": application, - "EnvVars": envVars, - "Labels": labels, - "Volumes": volumes, - "Ports": ports, - "Deployments": deployments, - "LatestDeployment": latestDeployment, - "WebhookURL": webhookURL, - "DeployKey": deployKey, - "Success": request.URL.Query().Get("success"), + "App": application, + "EnvVars": envVars, + "Labels": labels, + "Volumes": volumes, + "Ports": ports, + "RegistryCredentials": registryCreds, + "Deployments": deployments, + "LatestDeployment": latestDeployment, + "WebhookURL": webhookURL, + "DeployKey": deployKey, + "Success": request.URL.Query().Get("success"), }, request) h.renderTemplate(writer, tmpl, "app_detail.html", data) @@ -1382,3 +1384,126 @@ func formatDeployKey(pubKey string, createdAt time.Time, appName string) string return parts[0] + " " + parts[1] + " " + comment } + +// HandleRegistryCredentialAdd handles adding a registry credential. +func (h *Handlers) HandleRegistryCredentialAdd() http.HandlerFunc { + return func(writer http.ResponseWriter, request *http.Request) { + appID := chi.URLParam(request, "id") + + application, findErr := models.FindApp(request.Context(), h.db, appID) + if findErr != nil || application == nil { + http.NotFound(writer, request) + + return + } + + parseErr := request.ParseForm() + if parseErr != nil { + http.Error(writer, "Bad Request", http.StatusBadRequest) + + return + } + + registryURL := strings.TrimSpace(request.FormValue("registry")) + username := strings.TrimSpace(request.FormValue("username")) + password := request.FormValue("password") + + if registryURL == "" || username == "" || password == "" { + http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther) + + return + } + + cred := models.NewRegistryCredential(h.db) + cred.AppID = appID + cred.Registry = registryURL + cred.Username = username + cred.Password = password + + saveErr := cred.Save(request.Context()) + if saveErr != nil { + h.log.Error("failed to save registry credential", "error", saveErr) + } + + http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther) + } +} + +// HandleRegistryCredentialEdit handles editing an existing registry credential. +func (h *Handlers) HandleRegistryCredentialEdit() http.HandlerFunc { + return func(writer http.ResponseWriter, request *http.Request) { + appID := chi.URLParam(request, "id") + credIDStr := chi.URLParam(request, "credID") + + credID, parseErr := strconv.ParseInt(credIDStr, 10, 64) + if parseErr != nil { + http.NotFound(writer, request) + + return + } + + cred, findErr := models.FindRegistryCredential(request.Context(), h.db, credID) + if findErr != nil || cred == nil || cred.AppID != appID { + http.NotFound(writer, request) + + return + } + + formErr := request.ParseForm() + if formErr != nil { + http.Error(writer, "Bad Request", http.StatusBadRequest) + + return + } + + registryURL := strings.TrimSpace(request.FormValue("registry")) + username := strings.TrimSpace(request.FormValue("username")) + password := request.FormValue("password") + + if registryURL == "" || username == "" || password == "" { + http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther) + + return + } + + cred.Registry = registryURL + cred.Username = username + cred.Password = password + + saveErr := cred.Save(request.Context()) + if saveErr != nil { + h.log.Error("failed to update registry credential", "error", saveErr) + } + + http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther) + } +} + +// HandleRegistryCredentialDelete handles deleting a registry credential. +func (h *Handlers) HandleRegistryCredentialDelete() http.HandlerFunc { + return func(writer http.ResponseWriter, request *http.Request) { + appID := chi.URLParam(request, "id") + credIDStr := chi.URLParam(request, "credID") + + credID, parseErr := strconv.ParseInt(credIDStr, 10, 64) + if parseErr != nil { + http.NotFound(writer, request) + + return + } + + cred, findErr := models.FindRegistryCredential(request.Context(), h.db, credID) + if findErr != nil || cred == nil || cred.AppID != appID { + http.NotFound(writer, request) + + return + } + + deleteErr := cred.Delete(request.Context()) + if deleteErr != nil { + h.log.Error("failed to delete registry credential", "error", deleteErr) + } + + http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther) + } +} diff --git a/internal/models/app.go b/internal/models/app.go index bda1b14..61d5b6d 100644 --- a/internal/models/app.go +++ b/internal/models/app.go @@ -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 diff --git a/internal/models/models_test.go b/internal/models/models_test.go index 2d894b5..0819bee 100644 --- a/internal/models/models_test.go +++ b/internal/models/models_test.go @@ -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) }) } diff --git a/internal/models/registry_credential.go b/internal/models/registry_credential.go new file mode 100644 index 0000000..92629ec --- /dev/null +++ b/internal/models/registry_credential.go @@ -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() +} diff --git a/internal/server/routes.go b/internal/server/routes.go index ebddba9..dfad742 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -98,6 +98,11 @@ func (s *Server) SetupRoutes() { // Ports r.Post("/apps/{id}/ports", s.handlers.HandlePortAdd()) r.Post("/apps/{id}/ports/{portID}/delete", s.handlers.HandlePortDelete()) + + // Registry Credentials + r.Post("/apps/{id}/registry-credentials", s.handlers.HandleRegistryCredentialAdd()) + r.Post("/apps/{id}/registry-credentials/{credID}/edit", s.handlers.HandleRegistryCredentialEdit()) + r.Post("/apps/{id}/registry-credentials/{credID}/delete", s.handlers.HandleRegistryCredentialDelete()) }) }) diff --git a/internal/service/deploy/deploy.go b/internal/service/deploy/deploy.go index 4b78e29..1999b59 100644 --- a/internal/service/deploy/deploy.go +++ b/internal/service/deploy/deploy.go @@ -830,6 +830,13 @@ func (svc *Service) buildImage( logWriter := newDeploymentLogWriter(ctx, deployment) defer logWriter.Close() + // Fetch registry credentials for private base images + registryAuths, err := svc.buildRegistryAuths(ctx, app) + if err != nil { + svc.log.Warn("failed to fetch registry credentials", "error", err, "app", app.Name) + // Continue without auth — public images will still work + } + // BuildImage creates a tar archive from the local filesystem, // so it needs the container path where files exist, not the host path. imageID, err := svc.docker.BuildImage(ctx, docker.BuildImageOptions{ @@ -837,6 +844,7 @@ func (svc *Service) buildImage( DockerfilePath: app.DockerfilePath, Tags: []string{imageTag}, LogWriter: logWriter, + RegistryAuths: registryAuths, }) if err != nil { svc.notify.NotifyBuildFailed(ctx, app, deployment, err) @@ -1229,6 +1237,34 @@ func (svc *Service) failDeployment( // writeLogsToFile writes the deployment logs to a file on disk. // Structure: DataDir/logs///__.log.txt +// buildRegistryAuths fetches registry credentials for an app and converts them +// to Docker RegistryAuth objects for use during image builds. +func (svc *Service) buildRegistryAuths( + ctx context.Context, + app *models.App, +) ([]docker.RegistryAuth, error) { + creds, err := app.GetRegistryCredentials(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get registry credentials: %w", err) + } + + if len(creds) == 0 { + return nil, nil + } + + auths := make([]docker.RegistryAuth, 0, len(creds)) + + for _, cred := range creds { + auths = append(auths, docker.RegistryAuth{ + Registry: cred.Registry, + Username: cred.Username, + Password: cred.Password, + }) + } + + return auths, nil +} + func (svc *Service) writeLogsToFile(app *models.App, deployment *models.Deployment) { if !deployment.Logs.Valid || deployment.Logs.String == "" { return diff --git a/templates/app_detail.html b/templates/app_detail.html index b80ad87..fda549d 100644 --- a/templates/app_detail.html +++ b/templates/app_detail.html @@ -154,6 +154,69 @@ + +
+

Registry Credentials

+

Authenticate to private Docker registries when pulling base images during builds.

+ {{if .RegistryCredentials}} +
+ + + + + + + + + + + {{range .RegistryCredentials}} + + + + + + + + {{end}} + +
RegistryUsernamePasswordActions
+
+ {{end}} +
+ {{ .CSRFField }} + + + + +
+
+

Docker Labels

-- 2.49.1 From 9627942573321a0ac3c393a204d7ba4bea5a7650 Mon Sep 17 00:00:00 2001 From: clawbot Date: Tue, 17 Mar 2026 02:39:38 -0700 Subject: [PATCH 2/2] fix: move writeLogsToFile doc comment to correct position The buildRegistryAuths function was inserted between the writeLogsToFile doc comment and the writeLogsToFile function body, causing Go to treat the writeLogsToFile comment as part of buildRegistryAuths godoc. Move the writeLogsToFile comment to sit directly above its function, and keep only the buildRegistryAuths comment above buildRegistryAuths. --- internal/service/deploy/deploy.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/service/deploy/deploy.go b/internal/service/deploy/deploy.go index 1999b59..1abd436 100644 --- a/internal/service/deploy/deploy.go +++ b/internal/service/deploy/deploy.go @@ -1235,8 +1235,6 @@ func (svc *Service) failDeployment( _ = app.Save(ctx) } -// writeLogsToFile writes the deployment logs to a file on disk. -// Structure: DataDir/logs///__.log.txt // buildRegistryAuths fetches registry credentials for an app and converts them // to Docker RegistryAuth objects for use during image builds. func (svc *Service) buildRegistryAuths( @@ -1265,6 +1263,8 @@ func (svc *Service) buildRegistryAuths( return auths, nil } +// writeLogsToFile writes the deployment logs to a file on disk. +// Structure: DataDir/logs///__.log.txt func (svc *Service) writeLogsToFile(app *models.App, deployment *models.Deployment) { if !deployment.Logs.Valid || deployment.Logs.String == "" { return -- 2.49.1