From b5532933f665c390ed07d94663647fdbe2932df6 Mon Sep 17 00:00:00 2001 From: nfebe Date: Fri, 30 Jan 2026 12:38:13 +0100 Subject: [PATCH] feat(credentials): track registry credentials for private deployments Add CredentialID field to ServiceMetadata to associate registry credentials with deployments. This allows the system to authenticate with private registries when pulling images on restart/update. - Store credential ID during deployment creation (existing or new) - Login with stored credential before pulling deployment images - Add serialization tests for YAML/JSON credential_id field Note: UI types (ui/src/types/index.ts) need separate update. --- internal/api/credential_tracking_test.go | 142 +++++++++++++++++++++++ internal/api/server.go | 36 +++++- pkg/models/deployment.go | 1 + 3 files changed, 176 insertions(+), 3 deletions(-) create mode 100644 internal/api/credential_tracking_test.go 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 {