diff --git a/internal/api/credential_tracking_test.go b/internal/api/credential_tracking_test.go new file mode 100644 index 0000000..23d90c9 --- /dev/null +++ b/internal/api/credential_tracking_test.go @@ -0,0 +1,142 @@ +package api + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/flatrun/agent/pkg/models" + "gopkg.in/yaml.v3" +) + +func TestServiceMetadataCredentialID(t *testing.T) { + metadata := &models.ServiceMetadata{ + Name: "test-deployment", + Type: "custom", + Networking: models.NetworkingConfig{ + Expose: true, + Domain: "test.example.com", + ContainerPort: 8080, + }, + CredentialID: "cred-123", + } + + if metadata.CredentialID != "cred-123" { + t.Errorf("expected CredentialID 'cred-123', got '%s'", metadata.CredentialID) + } +} + +func TestServiceMetadataCredentialIDEmpty(t *testing.T) { + metadata := &models.ServiceMetadata{ + Name: "public-deployment", + Type: "custom", + } + + if metadata.CredentialID != "" { + t.Errorf("expected empty CredentialID for public deployment, got '%s'", metadata.CredentialID) + } +} + +func TestServiceMetadataCredentialIDOmitempty(t *testing.T) { + metadata := &models.ServiceMetadata{ + Name: "test", + Type: "custom", + } + + if metadata.CredentialID != "" { + t.Error("CredentialID should be empty by default") + } + + metadata.CredentialID = "new-cred-id" + if metadata.CredentialID != "new-cred-id" { + t.Errorf("expected CredentialID 'new-cred-id', got '%s'", metadata.CredentialID) + } +} + +func TestServiceMetadataCredentialIDYAMLSerialization(t *testing.T) { + metadata := &models.ServiceMetadata{ + Name: "private-app", + Type: "custom", + CredentialID: "cred-abc-123", + } + + data, err := yaml.Marshal(metadata) + if err != nil { + t.Fatalf("failed to marshal metadata: %v", err) + } + + yamlStr := string(data) + if !strings.Contains(yamlStr, "credential_id: cred-abc-123") { + t.Errorf("YAML should contain credential_id field, got:\n%s", yamlStr) + } + + var unmarshaled models.ServiceMetadata + if err := yaml.Unmarshal(data, &unmarshaled); err != nil { + t.Fatalf("failed to unmarshal metadata: %v", err) + } + + if unmarshaled.CredentialID != "cred-abc-123" { + t.Errorf("expected CredentialID 'cred-abc-123' after unmarshal, got '%s'", unmarshaled.CredentialID) + } +} + +func TestServiceMetadataCredentialIDYAMLOmitsEmpty(t *testing.T) { + metadata := &models.ServiceMetadata{ + Name: "public-app", + Type: "custom", + } + + data, err := yaml.Marshal(metadata) + if err != nil { + t.Fatalf("failed to marshal metadata: %v", err) + } + + yamlStr := string(data) + if strings.Contains(yamlStr, "credential_id") { + t.Errorf("YAML should omit credential_id when empty, got:\n%s", yamlStr) + } +} + +func TestServiceMetadataCredentialIDJSONSerialization(t *testing.T) { + metadata := &models.ServiceMetadata{ + Name: "private-app", + Type: "custom", + CredentialID: "cred-xyz-789", + } + + data, err := json.Marshal(metadata) + if err != nil { + t.Fatalf("failed to marshal metadata: %v", err) + } + + jsonStr := string(data) + if !strings.Contains(jsonStr, `"credential_id":"cred-xyz-789"`) { + t.Errorf("JSON should contain credential_id field, got:\n%s", jsonStr) + } + + var unmarshaled models.ServiceMetadata + if err := json.Unmarshal(data, &unmarshaled); err != nil { + t.Fatalf("failed to unmarshal metadata: %v", err) + } + + if unmarshaled.CredentialID != "cred-xyz-789" { + t.Errorf("expected CredentialID 'cred-xyz-789' after unmarshal, got '%s'", unmarshaled.CredentialID) + } +} + +func TestServiceMetadataCredentialIDJSONOmitsEmpty(t *testing.T) { + metadata := &models.ServiceMetadata{ + Name: "public-app", + Type: "custom", + } + + data, err := json.Marshal(metadata) + if err != nil { + t.Fatalf("failed to marshal metadata: %v", err) + } + + jsonStr := string(data) + if strings.Contains(jsonStr, "credential_id") { + t.Errorf("JSON should omit credential_id when empty, got:\n%s", jsonStr) + } +} diff --git a/internal/api/server.go b/internal/api/server.go index c46c221..ed93bae 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -19,11 +19,10 @@ import ( "github.com/flatrun/agent/internal/audit" "github.com/flatrun/agent/internal/auth" "github.com/flatrun/agent/internal/backup" - "github.com/flatrun/agent/internal/dns" - dnsPlugins "github.com/flatrun/agent/pkg/plugins/dns" "github.com/flatrun/agent/internal/certs" "github.com/flatrun/agent/internal/credentials" "github.com/flatrun/agent/internal/database" + "github.com/flatrun/agent/internal/dns" "github.com/flatrun/agent/internal/docker" "github.com/flatrun/agent/internal/files" "github.com/flatrun/agent/internal/infra" @@ -36,6 +35,7 @@ import ( "github.com/flatrun/agent/pkg/config" "github.com/flatrun/agent/pkg/models" "github.com/flatrun/agent/pkg/plugins" + dnsPlugins "github.com/flatrun/agent/pkg/plugins/dns" "github.com/flatrun/agent/pkg/subdomain" "github.com/flatrun/agent/pkg/version" "github.com/flatrun/agent/templates" @@ -705,10 +705,12 @@ func (s *Server) createDeployment(c *gin.Context) { } var registryLoginError string + var credentialID 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) if err != nil { registryLoginError = "Failed to load credential: " + err.Error() @@ -722,7 +724,7 @@ func (s *Server) createDeployment(c *gin.Context) { password = req.RegistryCredential.Password if req.RegistryCredential.SaveCredential && req.RegistryCredential.CredentialName != "" { - _, err := s.credentialsManager.CreateCredential( + newCred, err := s.credentialsManager.CreateCredential( req.RegistryCredential.CredentialName, "docker-hub", username, @@ -732,6 +734,8 @@ func (s *Server) createDeployment(c *gin.Context) { ) if err != nil { log.Printf("Warning: failed to save credential: %v", err) + } else { + credentialID = newCred.ID } } } @@ -744,6 +748,13 @@ func (s *Server) createDeployment(c *gin.Context) { } } + if credentialID != "" && req.Metadata != nil { + req.Metadata.CredentialID = credentialID + if err := s.manager.SaveMetadata(req.Name, req.Metadata); err != nil { + log.Printf("Warning: failed to update metadata with credential ID: %v", err) + } + } + var startOutput string var startError string if req.AutoStart { @@ -1167,6 +1178,25 @@ func (s *Server) pullDeploymentImage(c *gin.Context) { } _ = c.ShouldBindJSON(&req) + deployment, err := s.manager.GetDeployment(name) + if 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) + } + } + } + output, err := s.manager.PullDeployment(name, req.OnlyLatest) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ diff --git a/pkg/models/deployment.go b/pkg/models/deployment.go index d32def1..fa21ec8 100644 --- a/pkg/models/deployment.go +++ b/pkg/models/deployment.go @@ -32,6 +32,7 @@ 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"` } type BackupSpec struct {