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"`