From cd33bed1a3eddf046fcb6b7110b8bc3657bc0db5 Mon Sep 17 00:00:00 2001 From: nfebe Date: Thu, 16 Apr 2026 23:03:23 +0100 Subject: [PATCH] fix(credentials): Use correct registry & per-service credentials Image pulls were always authenticating against Docker Hub regardless of which registry the image was hosted on. Now the credential's registry type is resolved to its URL before login. Deployments with images from multiple registries can now map different credentials to individual services via service_credentials. Start, restart, rebuild, and pull operations all authenticate before invoking docker compose. Signed-off-by: nfebe --- internal/api/server.go | 183 +++++++++++++++++------ internal/credentials/authconfig.go | 109 ++++++++++++++ internal/credentials/docker.go | 73 ++++----- internal/credentials/manager.go | 57 ++++++- internal/credentials/manager_test.go | 214 +++++++++++++++++++++++++-- internal/docker/compose.go | 68 +++++---- internal/docker/manager.go | 16 +- pkg/models/deployment.go | 3 +- pkg/models/registry.go | 1 + 9 files changed, 592 insertions(+), 132 deletions(-) create mode 100644 internal/credentials/authconfig.go diff --git a/internal/api/server.go b/internal/api/server.go index 441fa2a..3c76278 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -22,9 +22,9 @@ import ( composetypes "github.com/compose-spec/compose-go/v2/types" "github.com/flatrun/agent/internal/audit" "github.com/flatrun/agent/internal/auth" - "github.com/flatrun/agent/internal/cluster" "github.com/flatrun/agent/internal/backup" "github.com/flatrun/agent/internal/certs" + "github.com/flatrun/agent/internal/cluster" "github.com/flatrun/agent/internal/credentials" "github.com/flatrun/agent/internal/database" "github.com/flatrun/agent/internal/dns" @@ -35,8 +35,8 @@ import ( "github.com/flatrun/agent/internal/proxy" "github.com/flatrun/agent/internal/scheduler" "github.com/flatrun/agent/internal/security" - "github.com/flatrun/agent/internal/ssl" "github.com/flatrun/agent/internal/setup" + "github.com/flatrun/agent/internal/ssl" "github.com/flatrun/agent/internal/system" "github.com/flatrun/agent/internal/traffic" "github.com/flatrun/agent/pkg/config" @@ -649,7 +649,6 @@ func (s *Server) Start() error { return s.server.ListenAndServe() } - func (s *Server) Stop() error { if s.certRenewer != nil { s.certRenewer.Stop() @@ -786,12 +785,15 @@ func (s *Server) createDeployment(c *gin.Context) { ExistingDatabaseContainer string `json:"existing_database_container,omitempty"` Databases []DatabaseConfigRequest `json:"databases,omitempty"` RegistryCredential *struct { - CredentialID string `json:"credential_id,omitempty"` - Username string `json:"username,omitempty"` - Password string `json:"password,omitempty"` - SaveCredential bool `json:"save_credential,omitempty"` - CredentialName string `json:"credential_name,omitempty"` + CredentialID string `json:"credential_id,omitempty"` + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` + SaveCredential bool `json:"save_credential,omitempty"` + CredentialName string `json:"credential_name,omitempty"` + RegistryTypeSlug string `json:"registry_type_slug,omitempty"` + RegistryURL string `json:"registry_url,omitempty"` } `json:"registry_credential,omitempty"` + ServiceCredentials map[string]string `json:"service_credentials,omitempty"` } if err := c.ShouldBindJSON(&req); err != nil { @@ -942,9 +944,8 @@ func (s *Server) createDeployment(c *gin.Context) { var registryLoginError string var credentialID string + var username, password string if req.RegistryCredential != nil { - var username, password string - if req.RegistryCredential.CredentialID != "" { credentialID = req.RegistryCredential.CredentialID cred, err := s.credentialsManager.GetCredential(req.RegistryCredential.CredentialID) @@ -960,9 +961,17 @@ func (s *Server) createDeployment(c *gin.Context) { password = req.RegistryCredential.Password if req.RegistryCredential.SaveCredential && req.RegistryCredential.CredentialName != "" { + registryTypeSlug := req.RegistryCredential.RegistryTypeSlug + if registryTypeSlug == "" { + registryTypeSlug = s.inferRegistryTypeFromCompose(req.ComposeContent) + } + if registryTypeSlug == "" { + registryTypeSlug = "docker-hub" + } newCred, err := s.credentialsManager.CreateCredential( req.RegistryCredential.CredentialName, - "docker-hub", + registryTypeSlug, + req.RegistryCredential.RegistryURL, username, password, "", @@ -976,25 +985,51 @@ func (s *Server) createDeployment(c *gin.Context) { } } - if username != "" && password != "" && registryLoginError == "" { - if err := credentials.DockerLogin("", username, password); err != nil { - registryLoginError = err.Error() - log.Printf("Warning: registry login failed: %v", err) - } - } } - if credentialID != "" && req.Metadata != nil { - req.Metadata.CredentialID = credentialID + if req.Metadata != nil && (credentialID != "" || len(req.ServiceCredentials) > 0) { + if credentialID != "" { + req.Metadata.CredentialID = credentialID + } + if len(req.ServiceCredentials) > 0 { + req.Metadata.ServiceCredentials = req.ServiceCredentials + } if err := s.manager.SaveMetadata(req.Name, req.Metadata); err != nil { - log.Printf("Warning: failed to update metadata with credential ID: %v", err) + log.Printf("Warning: failed to update metadata: %v", err) } } var startOutput string var startError string if req.AutoStart { - output, err := s.manager.StartDeployment(req.Name) + var credIDs []string + if credentialID != "" { + credIDs = append(credIDs, credentialID) + } + for _, id := range req.ServiceCredentials { + credIDs = append(credIDs, id) + } + var extras []credentials.AuthEntry + if credentialID == "" && username != "" && password != "" { + inlineRegistry := "" + if req.RegistryCredential != nil { + inlineRegistry = req.RegistryCredential.RegistryURL + } + if inlineRegistry == "" { + inlineRegistry = s.inferRegistryHostFromCompose(req.ComposeContent) + } + extras = append(extras, credentials.AuthEntry{ + Registry: inlineRegistry, + Username: username, + Password: password, + }) + } + authCfg, err := s.credentialsManager.BuildAuthConfig(credIDs, extras...) + if err != nil { + log.Printf("Warning: failed to build docker auth config: %v", err) + } + output, err := s.manager.StartDeployment(req.Name, docker.WithDockerConfig(authCfg.Dir())) + authCfg.Close() startOutput = output if err != nil { startError = err.Error() @@ -1612,7 +1647,10 @@ func (s *Server) deleteDeployment(c *gin.Context) { func (s *Server) startDeployment(c *gin.Context) { name := c.Param("name") - output, err := s.manager.StartDeployment(name) + auth, opts := s.deploymentAuthOptions(name) + defer auth.Close() + + output, err := s.manager.StartDeployment(name, opts...) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "error": err.Error(), @@ -1650,7 +1688,10 @@ func (s *Server) stopDeployment(c *gin.Context) { func (s *Server) restartDeployment(c *gin.Context) { name := c.Param("name") - output, err := s.manager.RestartDeployment(name) + auth, opts := s.deploymentAuthOptions(name) + defer auth.Close() + + output, err := s.manager.RestartDeployment(name, opts...) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "error": err.Error(), @@ -1669,7 +1710,10 @@ func (s *Server) restartDeployment(c *gin.Context) { func (s *Server) rebuildDeployment(c *gin.Context) { name := c.Param("name") - output, err := s.manager.RebuildDeployment(name) + auth, opts := s.deploymentAuthOptions(name) + defer auth.Close() + + output, err := s.manager.RebuildDeployment(name, opts...) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "error": err.Error(), @@ -1693,26 +1737,17 @@ func (s *Server) pullDeploymentImage(c *gin.Context) { } _ = c.ShouldBindJSON(&req) - deployment, err := s.manager.GetDeployment(name) - if err != nil { + if _, err := s.manager.GetDeployment(name); err != nil { c.JSON(http.StatusNotFound, gin.H{ "error": "Deployment not found: " + err.Error(), }) return } - if deployment.Metadata != nil && deployment.Metadata.CredentialID != "" { - cred, err := s.credentialsManager.GetCredential(deployment.Metadata.CredentialID) - if err != nil { - log.Printf("Warning: failed to load credential %s for pull: %v", deployment.Metadata.CredentialID, err) - } else { - if err := credentials.DockerLogin("", cred.Username, cred.Password); err != nil { - log.Printf("Warning: registry login failed for pull: %v", err) - } - } - } + auth, opts := s.deploymentAuthOptions(name) + defer auth.Close() - output, err := s.manager.PullDeployment(name, req.OnlyLatest) + output, err := s.manager.PullDeployment(name, req.OnlyLatest, opts...) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "error": err.Error(), @@ -3003,6 +3038,43 @@ type composeNetwork struct { Name string `yaml:"name"` } +func (s *Server) inferRegistryTypeFromCompose(content string) string { + var compose composeFile + if err := yaml.Unmarshal([]byte(content), &compose); err != nil { + return "" + } + for _, svc := range compose.Services { + if svc.Image == "" { + continue + } + if slug := s.credentialsManager.RegistryTypeForImage(svc.Image); slug != "" { + return slug + } + } + return "" +} + +func (s *Server) inferRegistryHostFromCompose(content string) string { + var compose composeFile + if err := yaml.Unmarshal([]byte(content), &compose); err != nil { + return "" + } + for _, svc := range compose.Services { + if svc.Image == "" { + continue + } + parts := strings.SplitN(svc.Image, "/", 2) + if len(parts) != 2 { + continue + } + host := parts[0] + if strings.Contains(host, ".") || strings.Contains(host, ":") || host == "localhost" { + return host + } + } + return "" +} + func (s *Server) validateComposeContent(content, _ string) error { var compose composeFile if err := yaml.Unmarshal([]byte(content), &compose); err != nil { @@ -4612,7 +4684,6 @@ func toTitleCase(s string) string { return strings.Join(words, " ") } - func (s *Server) listDeploymentFiles(c *gin.Context) { name := c.Param("name") path := c.DefaultQuery("path", "/") @@ -5454,6 +5525,7 @@ func (s *Server) createCredential(c *gin.Context) { var req struct { Name string `json:"name" binding:"required"` RegistryTypeSlug string `json:"registry_type_slug" binding:"required"` + RegistryURL string `json:"registry_url"` Username string `json:"username" binding:"required"` Password string `json:"password" binding:"required"` Email string `json:"email"` @@ -5467,7 +5539,7 @@ func (s *Server) createCredential(c *gin.Context) { return } - cred, err := s.credentialsManager.CreateCredential(req.Name, req.RegistryTypeSlug, req.Username, req.Password, req.Email, req.IsDefault) + cred, err := s.credentialsManager.CreateCredential(req.Name, req.RegistryTypeSlug, req.RegistryURL, req.Username, req.Password, req.Email, req.IsDefault) if err != nil { c.JSON(http.StatusBadRequest, gin.H{ "error": err.Error(), @@ -5490,11 +5562,12 @@ func (s *Server) updateCredential(c *gin.Context) { id := c.Param("id") var req struct { - Name string `json:"name"` - Username string `json:"username"` - Password string `json:"password"` - Email string `json:"email"` - IsDefault *bool `json:"is_default"` + Name string `json:"name"` + RegistryURL string `json:"registry_url"` + Username string `json:"username"` + Password string `json:"password"` + Email string `json:"email"` + IsDefault *bool `json:"is_default"` } if err := c.ShouldBindJSON(&req); err != nil { @@ -5504,7 +5577,7 @@ func (s *Server) updateCredential(c *gin.Context) { return } - cred, err := s.credentialsManager.UpdateCredential(id, req.Name, req.Username, req.Password, req.Email, req.IsDefault) + cred, err := s.credentialsManager.UpdateCredential(id, req.Name, req.RegistryURL, req.Username, req.Password, req.Email, req.IsDefault) if err != nil { c.JSON(http.StatusBadRequest, gin.H{ "error": err.Error(), @@ -5555,3 +5628,23 @@ func (s *Server) testCredential(c *gin.Context) { "success": true, }) } + +func (s *Server) deploymentAuthOptions(name string) (credentials.AuthConfig, []docker.RunOption) { + deployment, err := s.manager.GetDeployment(name) + if err != nil || deployment.Metadata == nil { + return credentials.AuthConfig{}, nil + } + var ids []string + if deployment.Metadata.CredentialID != "" { + ids = append(ids, deployment.Metadata.CredentialID) + } + for _, id := range deployment.Metadata.ServiceCredentials { + ids = append(ids, id) + } + cfg, err := s.credentialsManager.BuildAuthConfig(ids) + if err != nil { + log.Printf("Warning: failed to build docker auth config for %s: %v", name, err) + return credentials.AuthConfig{}, nil + } + return cfg, []docker.RunOption{docker.WithDockerConfig(cfg.Dir())} +} diff --git a/internal/credentials/authconfig.go b/internal/credentials/authconfig.go new file mode 100644 index 0000000..34d0235 --- /dev/null +++ b/internal/credentials/authconfig.go @@ -0,0 +1,109 @@ +package credentials + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +type AuthEntry struct { + Registry string + Username string + Password string +} + +type AuthConfig struct { + dir string +} + +func (a AuthConfig) Dir() string { + return a.dir +} + +func (a AuthConfig) Close() { + if a.dir == "" { + return + } + _ = os.RemoveAll(a.dir) +} + +type dockerAuthEntry struct { + Auth string `json:"auth"` +} + +type dockerAuthFile struct { + Auths map[string]dockerAuthEntry `json:"auths"` +} + +const dockerHubAuthKey = "https://index.docker.io/v1/" + +func (m *Manager) BuildAuthConfig(credentialIDs []string, extras ...AuthEntry) (AuthConfig, error) { + auths := map[string]dockerAuthEntry{} + + m.mu.RLock() + seen := map[string]bool{} + for _, id := range credentialIDs { + if id == "" || seen[id] { + continue + } + seen[id] = true + cred, ok := m.credentials[id] + if !ok { + continue + } + host := cred.RegistryURL + if host == "" { + if rt, ok := m.registryTypes[cred.RegistryTypeSlug]; ok && len(rt.URLPatterns) > 0 { + if isFullHostname(rt.URLPatterns[0]) { + host = rt.URLPatterns[0] + } + } + } + if host == "" { + continue + } + addAuth(auths, host, cred.Username, cred.Password) + } + m.mu.RUnlock() + + for _, e := range extras { + if e.Username == "" || e.Password == "" { + continue + } + host := e.Registry + if host == "" { + host = "docker.io" + } + addAuth(auths, host, e.Username, e.Password) + } + + if len(auths) == 0 { + return AuthConfig{}, nil + } + + dir, err := os.MkdirTemp("", "flatrun-docker-auth-*") + if err != nil { + return AuthConfig{}, fmt.Errorf("create auth dir: %w", err) + } + data, err := json.Marshal(dockerAuthFile{Auths: auths}) + if err != nil { + _ = os.RemoveAll(dir) + return AuthConfig{}, err + } + if err := os.WriteFile(filepath.Join(dir, "config.json"), data, 0600); err != nil { + _ = os.RemoveAll(dir) + return AuthConfig{}, err + } + return AuthConfig{dir: dir}, nil +} + +func addAuth(auths map[string]dockerAuthEntry, host, username, password string) { + token := base64.StdEncoding.EncodeToString([]byte(username + ":" + password)) + if host == "docker.io" || host == "index.docker.io" || host == "registry-1.docker.io" { + auths[dockerHubAuthKey] = dockerAuthEntry{Auth: token} + return + } + auths[host] = dockerAuthEntry{Auth: token} +} diff --git a/internal/credentials/docker.go b/internal/credentials/docker.go index 7687d9e..1b53d18 100644 --- a/internal/credentials/docker.go +++ b/internal/credentials/docker.go @@ -1,8 +1,12 @@ package credentials import ( + "encoding/base64" + "encoding/json" "fmt" + "os" "os/exec" + "path/filepath" "strings" "github.com/flatrun/agent/pkg/models" @@ -33,54 +37,53 @@ func testDockerLogin(rt *models.RegistryType, cred *models.RegistryCredential) e return nil } -func DockerLogin(registry, username, password string) error { - args := []string{"login", "--username", username, "--password-stdin"} +func PullImageWithAuth(imageName string, cred *models.RegistryCredential) error { + cmd := exec.Command("docker", "pull", imageName) - if registry != "" && registry != "docker.io" { - args = append(args, registry) + if cred != nil { + dir, err := writeEphemeralAuth(extractRegistry(imageName), cred) + if err != nil { + return fmt.Errorf("authentication setup failed: %w", err) + } + defer os.RemoveAll(dir) + cmd.Env = append(os.Environ(), "DOCKER_CONFIG="+dir) } - cmd := exec.Command("docker", args...) - cmd.Stdin = strings.NewReader(password) - output, err := cmd.CombinedOutput() if err != nil { - return fmt.Errorf("docker login failed: %s", strings.TrimSpace(string(output))) + return fmt.Errorf("failed to pull image: %s", strings.TrimSpace(string(output))) } return nil } -func DockerLogout(registry string) error { - args := []string{"logout"} - - if registry != "" && registry != "docker.io" { - args = append(args, registry) +func writeEphemeralAuth(registry string, cred *models.RegistryCredential) (string, error) { + host := cred.RegistryURL + if host == "" { + host = registry } - - cmd := exec.Command("docker", args...) - output, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("docker logout failed: %s", strings.TrimSpace(string(output))) + if host == "" { + host = "docker.io" } - - return nil -} - -func PullImageWithAuth(imageName string, cred *models.RegistryCredential) error { - registry := extractRegistry(imageName) - - if cred != nil { - if err := DockerLogin(registry, cred.Username, cred.Password); err != nil { - return fmt.Errorf("authentication failed: %w", err) - } + key := host + if key == "docker.io" || key == "index.docker.io" || key == "registry-1.docker.io" { + key = dockerHubAuthKey } - - cmd := exec.Command("docker", "pull", imageName) - output, err := cmd.CombinedOutput() + auths := map[string]dockerAuthEntry{ + key: {Auth: base64.StdEncoding.EncodeToString([]byte(cred.Username + ":" + cred.Password))}, + } + dir, err := os.MkdirTemp("", "flatrun-docker-auth-*") if err != nil { - return fmt.Errorf("failed to pull image: %s", strings.TrimSpace(string(output))) + return "", err } - - return nil + data, err := json.Marshal(dockerAuthFile{Auths: auths}) + if err != nil { + _ = os.RemoveAll(dir) + return "", err + } + if err := os.WriteFile(filepath.Join(dir, "config.json"), data, 0600); err != nil { + _ = os.RemoveAll(dir) + return "", err + } + return dir, nil } diff --git a/internal/credentials/manager.go b/internal/credentials/manager.go index 9f48da1..1ad81c8 100644 --- a/internal/credentials/manager.go +++ b/internal/credentials/manager.go @@ -295,7 +295,7 @@ func (m *Manager) GetCredential(id string) (*models.RegistryCredential, error) { return cred, nil } -func (m *Manager) CreateCredential(name, registryTypeSlug, username, password, email string, isDefault bool) (*models.RegistryCredential, error) { +func (m *Manager) CreateCredential(name, registryTypeSlug, registryURL, username, password, email string, isDefault bool) (*models.RegistryCredential, error) { m.mu.Lock() defer m.mu.Unlock() @@ -324,6 +324,7 @@ func (m *Manager) CreateCredential(name, registryTypeSlug, username, password, e ID: id, Name: name, RegistryTypeSlug: registryTypeSlug, + RegistryURL: registryURL, Username: username, Password: password, Email: email, @@ -342,7 +343,43 @@ func (m *Manager) CreateCredential(name, registryTypeSlug, username, password, e return cred, nil } -func (m *Manager) UpdateCredential(id, name, username, password, email string, isDefault *bool) (*models.RegistryCredential, error) { +func (m *Manager) RegistryForCredential(credentialID string) string { + m.mu.RLock() + defer m.mu.RUnlock() + + cred, ok := m.credentials[credentialID] + if !ok { + return "" + } + + if cred.RegistryURL != "" { + return cred.RegistryURL + } + + rt, ok := m.registryTypes[cred.RegistryTypeSlug] + if !ok || len(rt.URLPatterns) == 0 { + return "" + } + + primary := rt.URLPatterns[0] + if !isFullHostname(primary) { + return "" + } + return primary +} + +func isFullHostname(s string) bool { + if s == "" { + return false + } + if !strings.Contains(s, ".") && !strings.Contains(s, ":") && s != "localhost" { + return false + } + first := s[0] + return (first >= 'a' && first <= 'z') || (first >= 'A' && first <= 'Z') || (first >= '0' && first <= '9') +} + +func (m *Manager) UpdateCredential(id, name, registryURL, username, password, email string, isDefault *bool) (*models.RegistryCredential, error) { m.mu.Lock() defer m.mu.Unlock() @@ -360,6 +397,9 @@ func (m *Manager) UpdateCredential(id, name, username, password, email string, i cred.Name = name } + if registryURL != "" { + cred.RegistryURL = registryURL + } if username != "" { cred.Username = username } @@ -402,6 +442,19 @@ func (m *Manager) DeleteCredential(id string) error { return m.saveCredentials() } +func (m *Manager) RegistryTypeForImage(imageName string) string { + m.mu.RLock() + defer m.mu.RUnlock() + + registry := extractRegistry(imageName) + for _, rt := range m.registryTypes { + if matchesURLPatterns(rt.URLPatterns, registry) { + return rt.Slug + } + } + return "" +} + func (m *Manager) FindCredentialForImage(imageName string) *models.RegistryCredential { m.mu.RLock() defer m.mu.RUnlock() diff --git a/internal/credentials/manager_test.go b/internal/credentials/manager_test.go index 4ee43bd..c94218d 100644 --- a/internal/credentials/manager_test.go +++ b/internal/credentials/manager_test.go @@ -1,6 +1,8 @@ package credentials import ( + "encoding/base64" + "encoding/json" "os" "path/filepath" "testing" @@ -65,7 +67,7 @@ func TestCreateCredential(t *testing.T) { m, tmpDir := setupTestManager(t) defer os.RemoveAll(tmpDir) - cred, err := m.CreateCredential("Test Credential", "docker-hub", "testuser", "testpass", "", false) + cred, err := m.CreateCredential("Test Credential", "docker-hub", "", "testuser", "testpass", "", false) if err != nil { t.Fatalf("Failed to create credential: %v", err) } @@ -93,12 +95,12 @@ func TestCreateCredentialDuplicateName(t *testing.T) { m, tmpDir := setupTestManager(t) defer os.RemoveAll(tmpDir) - _, err := m.CreateCredential("Test Credential", "docker-hub", "user1", "pass1", "", false) + _, err := m.CreateCredential("Test Credential", "docker-hub", "", "user1", "pass1", "", false) if err != nil { t.Fatalf("Failed to create first credential: %v", err) } - _, err = m.CreateCredential("Test Credential", "docker-hub", "user2", "pass2", "", false) + _, err = m.CreateCredential("Test Credential", "docker-hub", "", "user2", "pass2", "", false) if err == nil { t.Error("Expected error for duplicate credential name") } @@ -108,7 +110,7 @@ func TestCreateCredentialInvalidRegistry(t *testing.T) { m, tmpDir := setupTestManager(t) defer os.RemoveAll(tmpDir) - _, err := m.CreateCredential("Test", "nonexistent-registry", "user", "pass", "", false) + _, err := m.CreateCredential("Test", "nonexistent-registry", "", "user", "pass", "", false) if err == nil { t.Error("Expected error for invalid registry type") } @@ -118,7 +120,7 @@ func TestGetCredential(t *testing.T) { m, tmpDir := setupTestManager(t) defer os.RemoveAll(tmpDir) - created, err := m.CreateCredential("Test Cred", "docker-hub", "user", "pass", "", false) + created, err := m.CreateCredential("Test Cred", "docker-hub", "", "user", "pass", "", false) if err != nil { t.Fatalf("Failed to create credential: %v", err) } @@ -146,8 +148,8 @@ func TestListCredentials(t *testing.T) { t.Errorf("Expected 0 credentials, got: %d", len(creds)) } - _, _ = m.CreateCredential("Cred1", "docker-hub", "user1", "pass1", "", false) - _, _ = m.CreateCredential("Cred2", "ghcr", "user2", "pass2", "", false) + _, _ = m.CreateCredential("Cred1", "docker-hub", "", "user1", "pass1", "", false) + _, _ = m.CreateCredential("Cred2", "ghcr", "", "user2", "pass2", "", false) creds = m.ListCredentials() if len(creds) != 2 { @@ -159,7 +161,7 @@ func TestDeleteCredential(t *testing.T) { m, tmpDir := setupTestManager(t) defer os.RemoveAll(tmpDir) - cred, _ := m.CreateCredential("To Delete", "docker-hub", "user", "pass", "", false) + cred, _ := m.CreateCredential("To Delete", "docker-hub", "", "user", "pass", "", false) err := m.DeleteCredential(cred.ID) if err != nil { @@ -181,9 +183,9 @@ func TestUpdateCredential(t *testing.T) { m, tmpDir := setupTestManager(t) defer os.RemoveAll(tmpDir) - cred, _ := m.CreateCredential("Original", "docker-hub", "user", "pass", "", false) + cred, _ := m.CreateCredential("Original", "docker-hub", "", "user", "pass", "", false) - updated, err := m.UpdateCredential(cred.ID, "Updated Name", "newuser", "newpass", "", nil) + updated, err := m.UpdateCredential(cred.ID, "Updated Name", "", "newuser", "newpass", "", nil) if err != nil { t.Fatalf("Failed to update credential: %v", err) } @@ -200,8 +202,8 @@ func TestDefaultCredential(t *testing.T) { m, tmpDir := setupTestManager(t) defer os.RemoveAll(tmpDir) - cred1, _ := m.CreateCredential("Cred1", "docker-hub", "user1", "pass1", "", true) - cred2, _ := m.CreateCredential("Cred2", "docker-hub", "user2", "pass2", "", false) + cred1, _ := m.CreateCredential("Cred1", "docker-hub", "", "user1", "pass1", "", true) + cred2, _ := m.CreateCredential("Cred2", "docker-hub", "", "user2", "pass2", "", false) fetched1, _ := m.GetCredential(cred1.ID) if !fetched1.IsDefault { @@ -209,7 +211,7 @@ func TestDefaultCredential(t *testing.T) { } isDefault := true - _, _ = m.UpdateCredential(cred2.ID, "", "", "", "", &isDefault) + _, _ = m.UpdateCredential(cred2.ID, "", "", "", "", "", &isDefault) fetched1, _ = m.GetCredential(cred1.ID) fetched2, _ := m.GetCredential(cred2.ID) @@ -226,8 +228,8 @@ func TestFindCredentialForImage(t *testing.T) { m, tmpDir := setupTestManager(t) defer os.RemoveAll(tmpDir) - _, _ = m.CreateCredential("Docker Hub Cred", "docker-hub", "dockeruser", "dockerpass", "", true) - _, _ = m.CreateCredential("GHCR Cred", "ghcr", "ghcruser", "ghcrpass", "", true) + _, _ = m.CreateCredential("Docker Hub Cred", "docker-hub", "", "dockeruser", "dockerpass", "", true) + _, _ = m.CreateCredential("GHCR Cred", "ghcr", "", "ghcruser", "ghcrpass", "", true) tests := []struct { image string @@ -312,7 +314,7 @@ func TestPersistence(t *testing.T) { defer os.RemoveAll(tmpDir) m1 := NewManager(tmpDir) - _, err = m1.CreateCredential("Persist Test", "docker-hub", "user", "pass", "", true) + _, err = m1.CreateCredential("Persist Test", "docker-hub", "", "user", "pass", "", true) if err != nil { t.Fatalf("Failed to create credential: %v", err) } @@ -327,6 +329,186 @@ func TestPersistence(t *testing.T) { } } +func TestRegistryForCredential(t *testing.T) { + m, tmpDir := setupTestManager(t) + defer os.RemoveAll(tmpDir) + + fixed, _ := m.CreateCredential("Hub Cred", "docker-hub", "", "u", "p", "", false) + if got := m.RegistryForCredential(fixed.ID); got != "docker.io" { + t.Errorf("fixed-host: expected docker.io, got %q", got) + } + + ecr, _ := m.CreateCredential("ECR Cred", "ecr", "", "u", "p", "", false) + if got := m.RegistryForCredential(ecr.ID); got != "" { + t.Errorf("ecr without URL: expected empty, got %q", got) + } + + withURL, _ := m.CreateCredential("ECR Account", "ecr", "123.dkr.ecr.us-east-1.amazonaws.com", "u", "p", "", false) + if got := m.RegistryForCredential(withURL.ID); got != "123.dkr.ecr.us-east-1.amazonaws.com" { + t.Errorf("explicit URL: expected account host, got %q", got) + } + + gar, _ := m.CreateCredential("GAR Cred", "gar", "", "u", "p", "", false) + if got := m.RegistryForCredential(gar.ID); got != "" { + t.Errorf("gar without URL: expected empty, got %q", got) + } + + if got := m.RegistryForCredential("does-not-exist"); got != "" { + t.Errorf("unknown id: expected empty, got %q", got) + } +} + +func TestRegistryTypeForImage(t *testing.T) { + m, tmpDir := setupTestManager(t) + defer os.RemoveAll(tmpDir) + + tests := []struct { + image string + expected string + }{ + {"nginx:latest", "docker-hub"}, + {"ghcr.io/owner/repo:tag", "ghcr"}, + {"gcr.io/project/image", "gcr"}, + {"quay.io/org/image", "quay"}, + {"123.dkr.ecr.us-east-1.amazonaws.com/repo", "ecr"}, + {"europe-docker.pkg.dev/project/repo/image", "gar"}, + {"registry.example.com/image", ""}, + } + + for _, tc := range tests { + got := m.RegistryTypeForImage(tc.image) + if got != tc.expected { + t.Errorf("RegistryTypeForImage(%q) = %q, expected %q", tc.image, got, tc.expected) + } + } +} + +func TestUpdateCredentialRegistryURL(t *testing.T) { + m, tmpDir := setupTestManager(t) + defer os.RemoveAll(tmpDir) + + cred, _ := m.CreateCredential("ECR", "ecr", "old.dkr.ecr.us-east-1.amazonaws.com", "u", "p", "", false) + updated, err := m.UpdateCredential(cred.ID, "", "new.dkr.ecr.us-west-2.amazonaws.com", "", "", "", nil) + if err != nil { + t.Fatalf("update failed: %v", err) + } + if updated.RegistryURL != "new.dkr.ecr.us-west-2.amazonaws.com" { + t.Errorf("RegistryURL not updated, got %q", updated.RegistryURL) + } + + unchanged, _ := m.UpdateCredential(cred.ID, "Renamed", "", "", "", "", nil) + if unchanged.RegistryURL != "new.dkr.ecr.us-west-2.amazonaws.com" { + t.Errorf("RegistryURL cleared by empty update, got %q", unchanged.RegistryURL) + } +} + +func TestBuildAuthConfig(t *testing.T) { + m, tmpDir := setupTestManager(t) + defer os.RemoveAll(tmpDir) + + hub, _ := m.CreateCredential("Hub", "docker-hub", "", "huser", "hpass", "", false) + ghcr, _ := m.CreateCredential("GHCR", "ghcr", "", "guser", "gpass", "", false) + ecr, _ := m.CreateCredential("ECR", "ecr", "123.dkr.ecr.us-east-1.amazonaws.com", "AKIA", "secret", "", false) + + cfg, err := m.BuildAuthConfig([]string{hub.ID, ghcr.ID, ecr.ID}) + if err != nil { + t.Fatalf("BuildAuthConfig: %v", err) + } + if cfg.Dir() == "" { + t.Fatal("expected non-empty AuthConfig dir") + } + defer cfg.Close() + + data, err := os.ReadFile(filepath.Join(cfg.Dir(), "config.json")) + if err != nil { + t.Fatalf("read config.json: %v", err) + } + + var parsed struct { + Auths map[string]struct { + Auth string `json:"auth"` + } `json:"auths"` + } + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("parse: %v", err) + } + + hubKey := "https://index.docker.io/v1/" + if _, ok := parsed.Auths[hubKey]; !ok { + t.Errorf("missing docker hub auth key %q, got %v", hubKey, parsed.Auths) + } + if _, ok := parsed.Auths["ghcr.io"]; !ok { + t.Error("missing ghcr.io auth") + } + if _, ok := parsed.Auths["123.dkr.ecr.us-east-1.amazonaws.com"]; !ok { + t.Error("missing ECR auth") + } + + raw, err := base64.StdEncoding.DecodeString(parsed.Auths["ghcr.io"].Auth) + if err != nil { + t.Fatalf("decode ghcr auth: %v", err) + } + if string(raw) != "guser:gpass" { + t.Errorf("ghcr auth payload = %q, want guser:gpass", raw) + } +} + +func TestBuildAuthConfigSkipsUnresolvable(t *testing.T) { + m, tmpDir := setupTestManager(t) + defer os.RemoveAll(tmpDir) + + ecr, _ := m.CreateCredential("ECR no URL", "ecr", "", "u", "p", "", false) + + cfg, err := m.BuildAuthConfig([]string{ecr.ID}) + if err != nil { + t.Fatalf("BuildAuthConfig: %v", err) + } + if cfg.Dir() != "" { + cfg.Close() + t.Error("expected empty AuthConfig dir when no resolvable credentials") + } +} + +func TestBuildAuthConfigWithExtras(t *testing.T) { + m, tmpDir := setupTestManager(t) + defer os.RemoveAll(tmpDir) + + cfg, err := m.BuildAuthConfig(nil, AuthEntry{Registry: "ghcr.io", Username: "u", Password: "p"}) + if err != nil { + t.Fatalf("BuildAuthConfig: %v", err) + } + if cfg.Dir() == "" { + t.Fatal("expected non-empty AuthConfig dir") + } + defer cfg.Close() + + data, _ := os.ReadFile(filepath.Join(cfg.Dir(), "config.json")) + if !containsBytes(data, "ghcr.io") { + t.Errorf("expected ghcr.io in config, got %s", data) + } +} + +func TestAuthConfigCloseEmpty(t *testing.T) { + var cfg AuthConfig + cfg.Close() + if cfg.Dir() != "" { + t.Errorf("zero-value AuthConfig should have empty dir, got %q", cfg.Dir()) + } +} + +func containsBytes(b []byte, sub string) bool { + return len(b) >= len(sub) && indexBytes(b, sub) >= 0 +} + +func indexBytes(b []byte, sub string) int { + for i := 0; i+len(sub) <= len(b); i++ { + if string(b[i:i+len(sub)]) == sub { + return i + } + } + return -1 +} + func TestGenerateSlug(t *testing.T) { tests := []struct { input string diff --git a/internal/docker/compose.go b/internal/docker/compose.go index b03336f..2b6682a 100644 --- a/internal/docker/compose.go +++ b/internal/docker/compose.go @@ -19,46 +19,57 @@ func NewComposeExecutor(basePath string) *ComposeExecutor { return &ComposeExecutor{basePath: basePath} } -func (c *ComposeExecutor) Up(deploymentPath string) (string, error) { - return c.runCompose(deploymentPath, "up", "-d", "--remove-orphans") +type RunOption func(*runOpts) + +type runOpts struct { + extraEnv []string +} + +func WithDockerConfig(dir string) RunOption { + return func(o *runOpts) { + if dir != "" { + o.extraEnv = append(o.extraEnv, "DOCKER_CONFIG="+dir) + } + } } -func (c *ComposeExecutor) Down(deploymentPath string) (string, error) { - return c.runCompose(deploymentPath, "down", "--remove-orphans") +func (c *ComposeExecutor) Up(deploymentPath string, opts ...RunOption) (string, error) { + return c.runCompose(deploymentPath, opts, "up", "-d", "--remove-orphans") } -func (c *ComposeExecutor) Start(deploymentPath string) (string, error) { - // Try start first for existing containers - output, err := c.runCompose(deploymentPath, "start") +func (c *ComposeExecutor) Down(deploymentPath string, opts ...RunOption) (string, error) { + return c.runCompose(deploymentPath, opts, "down", "--remove-orphans") +} + +func (c *ComposeExecutor) Start(deploymentPath string, opts ...RunOption) (string, error) { + output, err := c.runCompose(deploymentPath, opts, "start") if err != nil { - // Fall back to up if containers don't exist - return c.runCompose(deploymentPath, "up", "-d", "--remove-orphans") + return c.runCompose(deploymentPath, opts, "up", "-d", "--remove-orphans") } return output, nil } -func (c *ComposeExecutor) Stop(deploymentPath string) (string, error) { - return c.runCompose(deploymentPath, "stop") +func (c *ComposeExecutor) Stop(deploymentPath string, opts ...RunOption) (string, error) { + return c.runCompose(deploymentPath, opts, "stop") } -func (c *ComposeExecutor) Restart(deploymentPath string) (string, error) { - // Use down to properly remove containers before recreating - _, _ = c.runCompose(deploymentPath, "down", "--remove-orphans") - return c.runCompose(deploymentPath, "up", "-d", "--remove-orphans") +func (c *ComposeExecutor) Restart(deploymentPath string, opts ...RunOption) (string, error) { + _, _ = c.runCompose(deploymentPath, opts, "down", "--remove-orphans") + return c.runCompose(deploymentPath, opts, "up", "-d", "--remove-orphans") } -func (c *ComposeExecutor) Rebuild(deploymentPath string) (string, error) { - _, _ = c.runCompose(deploymentPath, "down", "--remove-orphans") - return c.runCompose(deploymentPath, "up", "-d", "--build", "--remove-orphans") +func (c *ComposeExecutor) Rebuild(deploymentPath string, opts ...RunOption) (string, error) { + _, _ = c.runCompose(deploymentPath, opts, "down", "--remove-orphans") + return c.runCompose(deploymentPath, opts, "up", "-d", "--build", "--remove-orphans") } func (c *ComposeExecutor) Logs(deploymentPath string, tail int) (string, error) { tailStr := fmt.Sprintf("%d", tail) - return c.runCompose(deploymentPath, "logs", "--tail", tailStr) + return c.runCompose(deploymentPath, nil, "logs", "--tail", tailStr) } func (c *ComposeExecutor) PS(deploymentPath string) (string, error) { - return c.runCompose(deploymentPath, "ps", "--format", "json") + return c.runCompose(deploymentPath, nil, "ps", "--format", "json") } type ImageInfo struct { @@ -68,7 +79,7 @@ type ImageInfo struct { IsBuild bool `json:"is_build"` } -func (c *ComposeExecutor) Pull(deploymentPath string, onlyLatest bool) (string, error) { +func (c *ComposeExecutor) Pull(deploymentPath string, onlyLatest bool, opts ...RunOption) (string, error) { if onlyLatest { services, err := c.getLatestTaggedServices(deploymentPath) if err != nil || len(services) == 0 { @@ -76,9 +87,9 @@ func (c *ComposeExecutor) Pull(deploymentPath string, onlyLatest bool) (string, } args := []string{"pull", "--ignore-buildable", "--policy", "always"} args = append(args, services...) - return c.runCompose(deploymentPath, args...) + return c.runCompose(deploymentPath, opts, args...) } - return c.runCompose(deploymentPath, "pull", "--ignore-buildable", "--policy", "always") + return c.runCompose(deploymentPath, opts, "pull", "--ignore-buildable", "--policy", "always") } func (c *ComposeExecutor) GetImageInfo(deploymentPath string) ([]ImageInfo, error) { @@ -233,7 +244,7 @@ func (c *ComposeExecutor) detectExistingProject(dirName string) string { return "" } -func (c *ComposeExecutor) runCompose(deploymentPath string, args ...string) (string, error) { +func (c *ComposeExecutor) runCompose(deploymentPath string, opts []RunOption, args ...string) (string, error) { composeCmd := c.findComposeCommand() if composeCmd == "" { return "", fmt.Errorf("docker compose command not found") @@ -267,6 +278,14 @@ func (c *ComposeExecutor) runCompose(deploymentPath string, args ...string) (str cmd.Dir = deploymentPath + var ro runOpts + for _, opt := range opts { + opt(&ro) + } + if len(ro.extraEnv) > 0 { + cmd.Env = append(os.Environ(), ro.extraEnv...) + } + var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr @@ -361,4 +380,3 @@ func (c *ComposeExecutor) ExecCommand(containerID string, command string) (strin return "", fmt.Errorf("no compatible shell found in container") } - diff --git a/internal/docker/manager.go b/internal/docker/manager.go index 9c523bf..ba87c6c 100644 --- a/internal/docker/manager.go +++ b/internal/docker/manager.go @@ -166,7 +166,7 @@ func (m *Manager) ensureContainerNames(name string) { _ = os.WriteFile(composePath, []byte(updated), 0644) } -func (m *Manager) StartDeployment(name string) (string, error) { +func (m *Manager) StartDeployment(name string, opts ...RunOption) (string, error) { m.mu.RLock() deployment, err := m.discovery.GetDeployment(name) m.mu.RUnlock() @@ -177,7 +177,7 @@ func (m *Manager) StartDeployment(name string) (string, error) { m.ensureContainerNames(name) - output, err := m.executor.Up(deployment.Path) + output, err := m.executor.Up(deployment.Path, opts...) if err != nil { return output, err } @@ -344,7 +344,7 @@ func (m *Manager) StopDeployment(name string) (string, error) { return m.executor.Stop(deployment.Path) } -func (m *Manager) RestartDeployment(name string) (string, error) { +func (m *Manager) RestartDeployment(name string, opts ...RunOption) (string, error) { m.mu.RLock() deployment, err := m.discovery.GetDeployment(name) m.mu.RUnlock() @@ -357,7 +357,7 @@ func (m *Manager) RestartDeployment(name string) (string, error) { snapshotDir := m.snapshotBindMounts(name, deployment.Path) - output, err := m.executor.Restart(deployment.Path) + output, err := m.executor.Restart(deployment.Path, opts...) if err != nil { m.restoreBindMounts(deployment.Path, snapshotDir) return output, err @@ -371,7 +371,7 @@ func (m *Manager) RestartDeployment(name string) (string, error) { return output, nil } -func (m *Manager) RebuildDeployment(name string) (string, error) { +func (m *Manager) RebuildDeployment(name string, opts ...RunOption) (string, error) { m.mu.RLock() deployment, err := m.discovery.GetDeployment(name) m.mu.RUnlock() @@ -384,7 +384,7 @@ func (m *Manager) RebuildDeployment(name string) (string, error) { snapshotDir := m.snapshotBindMounts(name, deployment.Path) - output, err := m.executor.Rebuild(deployment.Path) + output, err := m.executor.Rebuild(deployment.Path, opts...) if err != nil { m.restoreBindMounts(deployment.Path, snapshotDir) return output, err @@ -398,7 +398,7 @@ func (m *Manager) RebuildDeployment(name string) (string, error) { return output, nil } -func (m *Manager) PullDeployment(name string, onlyLatest bool) (string, error) { +func (m *Manager) PullDeployment(name string, onlyLatest bool, opts ...RunOption) (string, error) { m.mu.RLock() deployment, err := m.discovery.GetDeployment(name) m.mu.RUnlock() @@ -407,7 +407,7 @@ func (m *Manager) PullDeployment(name string, onlyLatest bool) (string, error) { return "", err } - return m.executor.Pull(deployment.Path, onlyLatest) + return m.executor.Pull(deployment.Path, onlyLatest, opts...) } func (m *Manager) GetDeploymentImages(name string) ([]ImageInfo, error) { diff --git a/pkg/models/deployment.go b/pkg/models/deployment.go index 94db413..bacd047 100644 --- a/pkg/models/deployment.go +++ b/pkg/models/deployment.go @@ -32,7 +32,8 @@ type ServiceMetadata struct { QuickActions []QuickAction `yaml:"quick_actions,omitempty" json:"quick_actions,omitempty"` Security *DeploymentSecurityConfig `yaml:"security,omitempty" json:"security,omitempty"` Backup *BackupSpec `yaml:"backup,omitempty" json:"backup,omitempty"` - CredentialID string `yaml:"credential_id,omitempty" json:"credential_id,omitempty"` + CredentialID string `yaml:"credential_id,omitempty" json:"credential_id,omitempty"` + ServiceCredentials map[string]string `yaml:"service_credentials,omitempty" json:"service_credentials,omitempty"` Domains []DomainConfig `yaml:"domains,omitempty" json:"domains,omitempty"` Databases []DatabaseConfig `yaml:"databases,omitempty" json:"databases,omitempty"` } diff --git a/pkg/models/registry.go b/pkg/models/registry.go index 76c102f..55a5a64 100644 --- a/pkg/models/registry.go +++ b/pkg/models/registry.go @@ -35,6 +35,7 @@ type RegistryCredential struct { ID string `json:"id" yaml:"id"` Name string `json:"name" yaml:"name"` RegistryTypeSlug string `json:"registry_type_slug" yaml:"registry_type_slug"` + RegistryURL string `json:"registry_url,omitempty" yaml:"registry_url,omitempty"` Username string `json:"username" yaml:"username"` Password string `json:"-" yaml:"password"` Email string `json:"email,omitempty" yaml:"email,omitempty"`