From 0f4acb554e5d3393be79536384fa9be8cbde19ba Mon Sep 17 00:00:00 2001 From: user Date: Tue, 17 Mar 2026 02:14:39 -0700 Subject: [PATCH] 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