diff --git a/api/go.mod b/api/go.mod index b880bd891..c5e2a1c8c 100644 --- a/api/go.mod +++ b/api/go.mod @@ -16,7 +16,6 @@ require ( k8s.io/api v0.31.3 k8s.io/apimachinery v0.31.3 k8s.io/client-go v0.31.3 - ) require ( @@ -172,7 +171,6 @@ require ( sigs.k8s.io/kustomize/api v0.17.2 // indirect sigs.k8s.io/kustomize/kyaml v0.17.1 // indirect sigs.k8s.io/yaml v1.4.0 - ) require ( diff --git a/api/pkg/apis/v1alpha1/managers/solution/solution-manager.go b/api/pkg/apis/v1alpha1/managers/solution/solution-manager.go index bac9548c5..a659c2801 100644 --- a/api/pkg/apis/v1alpha1/managers/solution/solution-manager.go +++ b/api/pkg/apis/v1alpha1/managers/solution/solution-manager.go @@ -28,6 +28,7 @@ import ( "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/observability" observ_utils "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/observability/utils" "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/providers" + certProvider "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/providers/cert" config "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/providers/config" "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/providers/keylock" "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/providers/queue" @@ -41,6 +42,8 @@ import ( var ( log = logger.NewLogger("coa.runtime") apiOperationMetrics *metrics.Metrics + CAIssuer = os.Getenv("ISSUER_NAME") + ServiceName = os.Getenv("SYMPHONY_SERVICE_NAME") ) var deploymentTypeMap = map[bool]string{ @@ -63,11 +66,16 @@ const ( DeploymentState = "DeployState" DeploymentPlan = "DeploymentPlan" OperationState = "OperationState" + + // Certificate creation retry constants + CertCreationMaxRetries = 10 + CertCreationRetryDelay = 2 * time.Second ) type SolutionManager struct { SummaryManager TargetProviders map[string]tgt.ITargetProvider + CertProvider certProvider.ICertProvider ConfigProvider config.IExtConfigProvider SecretProvider secret.ISecretProvider KeyLockProvider keylock.IKeyLockProvider @@ -83,6 +91,7 @@ func (s *SolutionManager) Init(context *contexts.VendorContext, config managers. if err != nil { return err } + s.TargetProviders = make(map[string]tgt.ITargetProvider) for k, v := range providers { if p, ok := v.(tgt.ITargetProvider); ok { @@ -118,6 +127,14 @@ func (s *SolutionManager) Init(context *contexts.VendorContext, config managers. return err } + // Initialize cert provider using unified approach + certProvider, err := managers.GetCertProvider(config, providers) + if err == nil { + s.CertProvider = certProvider + } else { + log.Warnf("Cert provider not configured: %v", err) + } + if v, ok := config.Properties["isTarget"]; ok { b, err := strconv.ParseBool(v) if err == nil || b { @@ -159,6 +176,130 @@ func (s *SolutionManager) Init(context *contexts.VendorContext, config managers. return nil } + +// CreateCertificateWithValidation creates a certificate with validation checks +// It validates that the certificate doesn't exist before creation and verifies creation success after +func (s *SolutionManager) CreateCertificateWithValidation(ctx context.Context, certID string, request certProvider.CertRequest) error { + if s.CertProvider == nil { + return fmt.Errorf("cert provider not initialized") + } + + // Pre-creation validation: check if certificate already exists + log.InfofCtx(ctx, " M (Solution): validating certificate %s doesn't exist before creation", certID) + _, err := s.CertProvider.GetCert(ctx, certID, request.Namespace) + if err == nil { + log.InfofCtx(ctx, " M (Solution): certificate %s already exists, skipping creation", certID) + return nil + } + + // Create the certificate + log.InfofCtx(ctx, " M (Solution): creating working certificate %s", certID) + err = s.CertProvider.CreateCert(ctx, request) + if err != nil { + return fmt.Errorf("failed to create certificate %s: %v", certID, err) + } + + // Post-creation validation with retry mechanism: verify certificate was created successfully + log.InfofCtx(ctx, " M (Solution): validating certificate %s was created successfully with retry mechanism", certID) + for i := 0; i < CertCreationMaxRetries; i++ { + _, err = s.CertProvider.GetCert(ctx, certID, request.Namespace) + if err == nil { + log.InfofCtx(ctx, " M (Solution): working certificate %s created and validated successfully after %d attempts", certID, i+1) + return nil + } + + if i < CertCreationMaxRetries-1 { + log.InfofCtx(ctx, " M (Solution): certificate %s not found on attempt %d/%d, waiting %v before retry. Error: %v", + certID, i+1, CertCreationMaxRetries, CertCreationRetryDelay, err) + time.Sleep(CertCreationRetryDelay) + } else { + log.ErrorfCtx(ctx, " M (Solution): certificate %s validation failed after %d attempts. Final error: %v", + certID, CertCreationMaxRetries, err) + } + } + + return fmt.Errorf("certificate %s creation validation failed, certificate not found after creation with %d retries: %v", + certID, CertCreationMaxRetries, err) +} + +// DeleteCertificateWithValidation deletes a certificate with validation checks +// It validates that the certificate exists before deletion and verifies deletion success after +func (s *SolutionManager) DeleteCertificateWithValidation(ctx context.Context, certID string, namespace string) error { + if s.CertProvider == nil { + return fmt.Errorf("cert provider not initialized") + } + + // Pre-deletion validation: check if certificate exists + log.InfofCtx(ctx, " M (Solution): validating certificate %s exists before deletion", certID) + _, err := s.CertProvider.GetCert(ctx, certID, namespace) + if err != nil { + log.InfofCtx(ctx, " M (Solution): certificate %s does not exist, skipping deletion", certID) + return nil + } + + // Delete the certificate + log.InfofCtx(ctx, " M (Solution): deleting working certificate %s", certID) + err = s.CertProvider.DeleteCert(ctx, certID, namespace) + if err != nil { + return fmt.Errorf("failed to delete certificate %s: %v", certID, err) + } + + // Post-deletion validation: verify certificate was deleted successfully + log.InfofCtx(ctx, " M (Solution): validating certificate %s was deleted successfully", certID) + _, err = s.CertProvider.GetCert(ctx, certID, namespace) + if err == nil { + return fmt.Errorf("certificate %s deletion validation failed, certificate still exists after deletion", certID) + } + + log.InfofCtx(ctx, " M (Solution): working certificate %s deleted and validated successfully", certID) + return nil +} + +// isRemoteTargetDeployment checks if a deployment spec involves a remote target by looking for components of type "remote-agent" +func isRemoteTargetDeployment(deploymentSpec *model.DeploymentSpec) bool { + if deploymentSpec == nil { + return false + } + + // check components in solution spec + if deploymentSpec.Solution.Spec == nil || len(deploymentSpec.Solution.Spec.Components) == 0 { + return false + } + + // iterate over all components to find one with type "remote-agent" + for _, component := range deploymentSpec.Solution.Spec.Components { + if component.Type == "remote-agent" { + return true + } + } + + return false +} + +// handleWorkingCertManagement manages working certificates for remote targets +func (s *SolutionManager) handleWorkingCertManagement(ctx context.Context, deployment model.DeploymentSpec, remove bool, namespace string) error { + log.InfofCtx(ctx, "V (Solution): handleWorkingCertManagement for remote target: %s, remove: %t", deployment.Solution.ObjectMeta.Name, remove) + + if remove { + // Delete working certificate when removing remote target + err := s.DeleteCertificateWithValidation(ctx, deployment.Solution.ObjectMeta.Name, namespace) + if err != nil { + return fmt.Errorf("failed to delete working certificate for remote target %s: %w", deployment.Solution.ObjectMeta.Name, err) + } + log.InfofCtx(ctx, "V (Solution): successfully deleted working certificate for remote target: %s", deployment.Solution.ObjectMeta.Name) + } else { + // Create working certificate for remote target + err := s.CreateCertificateWithValidation(ctx, deployment.Solution.ObjectMeta.Name, s.createCertRequest(deployment.Solution.ObjectMeta.Name, namespace)) + if err != nil { + return fmt.Errorf("failed to create or update working certificate for remote target %s: %w", deployment.Solution.ObjectMeta.Name, err) + } else { + log.InfofCtx(ctx, "V (Solution): successfully created working certificate for remote target: %s", deployment.Solution.ObjectMeta.Name) + } + } + + return nil +} + func (s *SolutionManager) AsyncReconcile(ctx context.Context, deployment model.DeploymentSpec, remove bool, namespace string, targetName string) (model.SummarySpec, error) { lockName := api_utils.GenerateKeyLockName(namespace, deployment.Instance.ObjectMeta.Name) s.KeyLockProvider.Lock(lockName) @@ -197,6 +338,7 @@ func (s *SolutionManager) AsyncReconcile(ctx context.Context, deployment model.D s.KeyLockProvider.UnLock(lockName) return summary, err } + // get the components count for the deployment componentCount := len(deployment.Solution.Spec.Components) apiOperationMetrics.ApiComponentCount( @@ -251,6 +393,14 @@ func (s *SolutionManager) AsyncReconcile(ctx context.Context, deployment model.D stepList = append(stepList, step) } initalPlan.Steps = stepList + // Handle working certificate management for remote targets + if isRemoteTargetDeployment(&deployment) { + err = s.handleWorkingCertManagement(ctx, deployment, remove, namespace) + if err != nil { + log.ErrorfCtx(ctx, "V (Solution): failed to handle working cert management: %s", err.Error()) + return summary, err + } + } log.InfoCtx(ctx, "publish topic for object %s", deployment.Instance.ObjectMeta.Name) s.VendorContext.Publish(model.DeploymentPlanTopic, v1alpha2.Event{ Metadata: map[string]string{ @@ -1895,3 +2045,17 @@ func (s *SolutionManager) getOperationState(ctx context.Context, operationId str } return ret, err } + +// createCertRequest creates a certificate request with required fields, letting the cert provider use its configured defaults for Duration and RenewBefore +func (s *SolutionManager) createCertRequest(targetName string, namespace string) certProvider.CertRequest { + // Create request with required fields - provider will use its configured defaults for Duration and RenewBefore only + return certProvider.CertRequest{ + TargetName: targetName, + Namespace: namespace, + CommonName: ServiceName, + IssuerName: CAIssuer, + Subject: map[string]interface{}{ + "organizations": []interface{}{ServiceName}, + }, + } +} diff --git a/api/pkg/apis/v1alpha1/managers/targets/targets-manager.go b/api/pkg/apis/v1alpha1/managers/targets/targets-manager.go index 04b595037..cf13f2fbb 100644 --- a/api/pkg/apis/v1alpha1/managers/targets/targets-manager.go +++ b/api/pkg/apis/v1alpha1/managers/targets/targets-manager.go @@ -10,7 +10,10 @@ import ( "context" "encoding/json" "fmt" + "strings" + "time" + "github.com/cenkalti/backoff/v4" "github.com/eclipse-symphony/symphony/api/constants" "github.com/eclipse-symphony/symphony/api/pkg/apis/v1alpha1/model" "github.com/eclipse-symphony/symphony/api/pkg/apis/v1alpha1/validation" @@ -19,6 +22,7 @@ import ( "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/managers" "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/observability" "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/providers" + "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/providers/cert" "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/providers/registry" "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/providers/secret" "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/providers/states" @@ -30,6 +34,14 @@ import ( var log = logger.NewLogger("coa.runtime") +// Certificate waiting and retry constants +const ( + // Certificate waiting timeout configuration + CertificateWaitTimeout = 120 * time.Second // Total timeout for certificate readiness + CertRetryInitialInterval = 2 * time.Second // Initial interval for certificate retry backoff + CertRetryMaxInterval = 10 * time.Second // Maximum interval for certificate retry backoff +) + type TargetsManager struct { managers.Manager StateProvider states.IStateProvider @@ -37,6 +49,7 @@ type TargetsManager struct { needValidate bool TargetValidator validation.TargetValidator SecretProvider secret.ISecretProvider + CertProvider cert.ICertProvider } func (s *TargetsManager) Init(context *contexts.VendorContext, config managers.ManagerConfig, providers map[string]providers.IProvider) error { @@ -57,10 +70,11 @@ func (s *TargetsManager) Init(context *contexts.VendorContext, config managers.M s.TargetValidator = validation.NewTargetValidator(nil, s.targetUniqueNameLookup) } - for _, p := range providers { - if c, ok := p.(secret.ISecretProvider); ok { - s.SecretProvider = c - } + // Initialize cert provider using unified approach + if certProvider, err := managers.GetCertProvider(config, providers); err == nil { + s.CertProvider = certProvider + } else { + log.Warnf("Cert provider not configured: %v", err) } return nil @@ -308,3 +322,168 @@ func (t *TargetsManager) targetInstanceLookup(ctx context.Context, name string, } return len(instanceList) > 0, nil } + +// getTargetRuntimeKey returns the target runtime key with prefix +func getTargetRuntimeKey(targetName string) string { + return fmt.Sprintf("target-runtime-%s", targetName) +} + +// GetTargetCertificate retrieves and formats the certificate for a target +// This encapsulates the cert provider logic following MVP architecture +func (t *TargetsManager) GetTargetCertificate(ctx context.Context, targetName, namespace string) (publicKey, privateKey string, err error) { + ctx, span := observability.StartSpan("Targets Manager", ctx, &map[string]string{ + "method": "GetTargetCertificate", + }) + defer observ_utils.CloseSpanWithError(span, &err) + defer observ_utils.EmitUserDiagnosticsLogs(ctx, &err) + + // Verify target exists + + _, err = t.GetState(ctx, targetName, namespace) + if err != nil { + log.ErrorfCtx(ctx, "Target %s not found in namespace %s: %v", targetName, namespace, err) + return "", "", fmt.Errorf("target %s not found: %w", targetName, err) + } + + // Check if cert provider is available + if t.CertProvider == nil { + log.ErrorCtx(ctx, "Certificate provider not available") + return "", "", fmt.Errorf("certificate provider not available") + } + + // Get the target runtime key for certificate lookup + key := getTargetRuntimeKey(targetName) + + // Retrieve certificate from provider + certResponse, err := t.CertProvider.GetCert(ctx, key, namespace) + if err != nil { + log.ErrorfCtx(ctx, "Failed to retrieve certificate for target %s: %v", targetName, err) + return "", "", fmt.Errorf("working certificate not found for target %s: %w", key, err) + } + + if certResponse == nil { + log.ErrorfCtx(ctx, "Nil certificate response for target %s", targetName) + return "", "", fmt.Errorf("working certificate not found for target %s", key) + } + + // Format certificate data for remote agent (remove newlines as expected by the protocol) + publicKey = strings.ReplaceAll(certResponse.PublicKey, "\n", " ") + privateKey = strings.ReplaceAll(certResponse.PrivateKey, "\n", " ") + + log.InfofCtx(ctx, "Successfully retrieved working certificate for target %s (expires: %s)", targetName, certResponse.ExpiresAt.Format("2006-01-02 15:04:05")) + + return publicKey, privateKey, nil +} + +// waitForCertificateReady waits for Certificate to be ready and secret to have the correct type and content +func (t *TargetsManager) waitForCertificateReady(ctx context.Context, certName, namespace, secretName string) error { + log.InfofCtx(ctx, "T (TargetsManager): waiting for certificate %s to be ready in namespace %s", certName, namespace) + + // Create a context with timeout for the whole operation + timeoutCtx, cancel := context.WithTimeout(ctx, CertificateWaitTimeout) + defer cancel() + + op := func() error { + // Check Certificate status + ready, err := t.checkCertificateStatus(timeoutCtx, certName, namespace) + if err != nil { + log.ErrorfCtx(timeoutCtx, "T (TargetsManager): error checking certificate status: %v", err) + return err + } + + if !ready { + log.ErrorfCtx(timeoutCtx, "T (TargetsManager): certificate %s not ready yet", certName) + return fmt.Errorf("certificate %s not ready", certName) + } + + // Check if secret exists and has correct type + secretReady, err := t.checkSecretReady(timeoutCtx, secretName, namespace) + if err != nil { + log.ErrorfCtx(timeoutCtx, "T (TargetsManager): error checking secret status: %v", err) + return err + } + + if !secretReady { + log.ErrorfCtx(timeoutCtx, "T (TargetsManager): secret %s not ready yet", secretName) + return fmt.Errorf("secret %s not ready", secretName) + } + + log.InfofCtx(timeoutCtx, "T (TargetsManager): certificate %s and secret %s are ready", certName, secretName) + return nil + } + + // Use exponential backoff with the timeout context for cancellation + bo := backoff.NewExponentialBackOff() + bo.InitialInterval = CertRetryInitialInterval + bo.MaxInterval = CertRetryMaxInterval + // Respect the outer timeout via WithContext + err := backoff.RetryNotify(op, backoff.WithContext(bo, timeoutCtx), func(err error, duration time.Duration) { + log.InfofCtx(timeoutCtx, "T (TargetsManager): retrying certificate check in %v due to: %v", duration, err) + }) + + if err != nil { + return fmt.Errorf("timeout waiting for certificate %s to be ready: %s", certName, err.Error()) + } + + return nil +} + +// checkCertificateStatus checks if Certificate is ready +func (t *TargetsManager) checkCertificateStatus(ctx context.Context, certName, namespace string) (bool, error) { + getRequest := states.GetRequest{ + ID: certName, + Metadata: map[string]interface{}{ + "namespace": namespace, + "group": "cert-manager.io", + "version": "v1", + "resource": "certificates", + "kind": "Certificate", + }, + } + + entry, err := t.StateProvider.Get(ctx, getRequest) + if err != nil { + return false, fmt.Errorf("failed to get certificate: %s", err.Error()) + } + + // Check Certificate status conditions + if status, found := entry.Body.(map[string]interface{})["status"]; found { + if statusMap, ok := status.(map[string]interface{}); ok { + if conditions, found := statusMap["conditions"]; found { + if conditionsArray, ok := conditions.([]interface{}); ok { + for _, condition := range conditionsArray { + if condMap, ok := condition.(map[string]interface{}); ok { + if condType, found := condMap["type"]; found && strings.EqualFold(condType.(string), "ready") { + if condStatus, found := condMap["status"]; found && strings.EqualFold(condStatus.(string), "true") { + return true, nil + } + } + } + } + } + } + } + } + + return false, nil +} + +// checkSecretReady checks if secret exists and has the correct type and content +func (t *TargetsManager) checkSecretReady(ctx context.Context, secretName, namespace string) (bool, error) { + evalCtx := utils.EvaluationContext{Namespace: namespace} + + // Try to read both tls.crt and tls.key to verify secret is complete + _, err := t.SecretProvider.Read(ctx, secretName, "tls.crt", evalCtx) + if err != nil { + log.ErrorfCtx(ctx, "T (TargetsManager): secret %s not ready yet, waiting...", secretName) + return false, err // Secret not ready yet + } + + _, err = t.SecretProvider.Read(ctx, secretName, "tls.key", evalCtx) + if err != nil { + log.ErrorfCtx(ctx, "T (TargetsManager): secret %s not ready yet, waiting...", secretName) + return false, err // Secret not complete yet + } + + return true, nil +} diff --git a/api/pkg/apis/v1alpha1/providers/providerfactory.go b/api/pkg/apis/v1alpha1/providers/providerfactory.go index e120105e5..8fcb0b87c 100644 --- a/api/pkg/apis/v1alpha1/providers/providerfactory.go +++ b/api/pkg/apis/v1alpha1/providers/providerfactory.go @@ -45,6 +45,7 @@ import ( "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2" "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/contexts" cp "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/providers" + "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/providers/cert/k8scert" mockconfig "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/providers/config/mock" memorykeylock "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/providers/keylock/memory" mockledger "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/providers/ledger/mock" @@ -390,6 +391,12 @@ func (s SymphonyProviderFactory) CreateProvider(providerType string, config cp.I if err == nil { return mProvider, nil } + case "providers.cert.k8scert": + mProvider := &k8scert.K8SCertProvider{} + err = mProvider.Init(config) + if err == nil { + return mProvider, nil + } } return nil, err //TODO: in current design, factory doesn't return errors on unrecognized provider types as there could be other factories. We may want to change this. } diff --git a/api/pkg/apis/v1alpha1/vendors/solution-vendor_test.go b/api/pkg/apis/v1alpha1/vendors/solution-vendor_test.go index 88717e7b3..92142ca6b 100644 --- a/api/pkg/apis/v1alpha1/vendors/solution-vendor_test.go +++ b/api/pkg/apis/v1alpha1/vendors/solution-vendor_test.go @@ -61,6 +61,7 @@ func createSolutionVendor() SolutionVendor { "providers.secret": "mock-secret", "providers.keylock": "mem-keylock", "providers.queue": "redis-queue", + "providers.cert": "mock-cert", }, Providers: map[string]managers.ProviderConfig{ "mem-state": { @@ -87,6 +88,14 @@ func createSolutionVendor() SolutionVendor { Password: "", }, }, + "mock-cert": { + Type: "providers.cert.mock", + Config: map[string]interface{}{ + "inCluster": true, + "defaultDuration": "4320h", + "renewBefore": "360h", + }, + }, }, }, }, diff --git a/api/pkg/apis/v1alpha1/vendors/targets-vendor.go b/api/pkg/apis/v1alpha1/vendors/targets-vendor.go index 9d85b2044..65ac58f26 100644 --- a/api/pkg/apis/v1alpha1/vendors/targets-vendor.go +++ b/api/pkg/apis/v1alpha1/vendors/targets-vendor.go @@ -27,7 +27,6 @@ import ( "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/providers" "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/providers/pubsub" "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/providers/secret" - "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/providers/states" coa_utils "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/utils" "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/vendors" "github.com/eclipse-symphony/symphony/coa/pkg/logger" @@ -35,8 +34,14 @@ import ( utils2 "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/utils" "github.com/golang-jwt/jwt/v4" "github.com/valyala/fasthttp" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime/schema" +) + +// Secret reading retry constants +const ( + SecretReadMaxRetries = 3 // Maximum number of retries for secret reading + SecretReadInitialInterval = 1 * time.Second // Initial interval for secret reading retry backoff + SecretReadMaxInterval = 10 * time.Second // Maximum interval for secret reading retry backoff + SecretReadMaxElapsedTime = 30 * time.Second // Maximum total time for secret reading retries ) var ( @@ -371,13 +376,13 @@ func readSecretWithRetry(ctx context.Context, secretProvider secret.ISecretProvi return nil } - // Configure exponential backoff with max 3 retries and initial delay of 1 second + // Configure exponential backoff with named constants instead of magic numbers bo := backoff.NewExponentialBackOff() - bo.InitialInterval = 1 * time.Second - bo.MaxInterval = 10 * time.Second - bo.MaxElapsedTime = 30 * time.Second + bo.InitialInterval = SecretReadInitialInterval + bo.MaxInterval = SecretReadMaxInterval + bo.MaxElapsedTime = SecretReadMaxElapsedTime - retryBackoff := backoff.WithMaxRetries(bo, 3) + retryBackoff := backoff.WithMaxRetries(bo, SecretReadMaxRetries) err := backoff.RetryNotify(operation, retryBackoff, func(err error, duration time.Duration) { tLog.InfofCtx(ctx, "V (Targets) : retrying secret read for %s in %v due to: %v", key, duration, err) @@ -683,13 +688,14 @@ func (c *TargetsVendor) onHeartBeat(request v1alpha2.COARequest) v1alpha2.COARes return resp } -// getting a certificate for a target +// getting a certificate for a target - READ-ONLY +// Certificate creation is now handled by the solution-vendor's reconcile method func (c *TargetsVendor) onGetCert(request v1alpha2.COARequest) v1alpha2.COAResponse { ctx, span := observability.StartSpan("Targets Vendor", request.Context, &map[string]string{ "method": "onGetCert", }) defer span.End() - tLog.InfofCtx(ctx, "V (Targets) : onGetCert, method: %s", request.Method) + tLog.InfofCtx(ctx, "V (Targets) : onGetCert (READ-ONLY), method: %s", request.Method) id := request.Parameters["__name"] namespace, exist := request.Parameters["namespace"] if !exist { @@ -698,132 +704,30 @@ func (c *TargetsVendor) onGetCert(request v1alpha2.COARequest) v1alpha2.COARespo switch request.Method { case fasthttp.MethodPost: - subject := fmt.Sprintf("CN=%s-%s.%s", namespace, id, ServiceName) - // create a new GroupVersionKind for the certificate - gvk := schema.GroupVersionKind{ - Group: "cert-manager.io", - Version: "v1", - Kind: "Certificate", - } - - // create a new unstructured object for the certificate - cert := &unstructured.Unstructured{} - cert.SetGroupVersionKind(gvk) - - cert.SetName(id) - cert.SetNamespace(namespace) - - secretName := fmt.Sprintf("%s-tls", id) - - // Get configurable working certificate duration and renewBefore values with defaults - duration := c.getWorkingCertDuration() - renewBefore := c.getWorkingCertRenewBefore() - - spec := map[string]interface{}{ - "secretName": secretName, - "duration": duration, - "renewBefore": renewBefore, - "commonName": subject, - "dnsNames": []string{ - subject, - }, - "issuerRef": map[string]interface{}{ - "name": CAIssuer, - "kind": "Issuer", - }, - "subject": map[string]interface{}{ - "organizations": []interface{}{ - ServiceName, - }, - }, - "privateKey": map[string]interface{}{ - "algorithm": "RSA", - "size": 2048, - }, - } - - cert.Object["spec"] = spec - - upsertRequest := states.UpsertRequest{ - Value: states.StateEntry{ - ID: id, - Body: cert.Object, - }, - Metadata: map[string]interface{}{ - "namespace": namespace, - "group": gvk.Group, - "version": gvk.Version, - "resource": "certificates", - "kind": gvk.Kind, - }, - } - - // Check if Certificate already exists to avoid concurrent creation - getRequest := states.GetRequest{ - ID: id, - Metadata: map[string]interface{}{ - "namespace": namespace, - "group": gvk.Group, - "version": gvk.Version, - "resource": "certificates", - "kind": gvk.Kind, - }, - } - - _, err := c.TargetsManager.StateProvider.Get(ctx, getRequest) - if err == nil { - // Certificate already exists, log and proceed to wait - tLog.InfofCtx(ctx, "V (Targets) : Certificate %s already exists, waiting for ready state", id) - } else { - // Certificate doesn't exist, create it - jsonData, _ := json.Marshal(upsertRequest) - tLog.InfofCtx(ctx, "V (Targets) : create certificate object - %s", jsonData) - _, err := c.TargetsManager.StateProvider.Upsert(ctx, upsertRequest) - if err != nil { - tLog.ErrorfCtx(ctx, "V (Targets) : onGetCert failed - %s", err.Error()) - return observ_utils.CloseSpanWithCOAResponse(span, v1alpha2.COAResponse{ - State: v1alpha2.InternalError, - Body: []byte(err.Error()), - }) - } - } - - // Wait for Certificate to be ready and secret to be created with correct type - err = c.waitForCertificateReady(ctx, id, namespace, secretName) - if err != nil { - tLog.ErrorfCtx(ctx, "V (Targets) : onGetCert failed waiting for certificate - %s", err.Error()) + // Check if targets manager is available + if c.TargetsManager == nil { + tLog.ErrorCtx(ctx, "V (Targets) : onGetCert failed - targets manager not available") return observ_utils.CloseSpanWithCOAResponse(span, v1alpha2.COAResponse{ State: v1alpha2.InternalError, - Body: []byte(err.Error()), + Body: []byte("targets manager not available for certificate operations"), }) } - // Use the fixed secret name directly - tLog.InfofCtx(ctx, "V (Targets) : Using fixed secret name: %s", secretName) - - public, err := readSecretWithRetry(ctx, c.TargetsManager.SecretProvider, secretName, "tls.crt", coa_utils.EvaluationContext{Namespace: namespace}) - if err != nil { - tLog.ErrorfCtx(ctx, "V (Targets) : onGetCert failed - %s", err.Error()) - return observ_utils.CloseSpanWithCOAResponse(span, v1alpha2.COAResponse{ - State: v1alpha2.InternalError, - Body: []byte(err.Error()), - }) - } - private, err := readSecretWithRetry(ctx, c.TargetsManager.SecretProvider, secretName, "tls.key", coa_utils.EvaluationContext{Namespace: namespace}) + // Use the manager's encapsulated certificate retrieval method (follows MVP architecture) + publicKey, privateKey, err := c.TargetsManager.GetTargetCertificate(ctx, id, namespace) if err != nil { - tLog.ErrorfCtx(ctx, "V (Targets) : onGetCert failed - %s", err.Error()) + tLog.ErrorfCtx(ctx, "V (Targets) : onGetCert failed to retrieve certificate for target %s - %s", id, err.Error()) return observ_utils.CloseSpanWithCOAResponse(span, v1alpha2.COAResponse{ - State: v1alpha2.InternalError, - Body: []byte(err.Error()), + State: v1alpha2.GetErrorState(err), + Body: []byte(fmt.Sprintf("failed to retrieve working certificate for target %s: %s", id, err.Error())), }) } - public = strings.ReplaceAll(public, "\n", " ") - private = strings.ReplaceAll(private, "\n", " ") + tLog.InfofCtx(ctx, "V (Targets) : successfully retrieved working certificate for target %s", id) return observ_utils.CloseSpanWithCOAResponse(span, v1alpha2.COAResponse{ State: v1alpha2.OK, - Body: []byte(fmt.Sprintf("{\"public\":\"%s\",\"private\":\"%s\"}", public, private)), + Body: []byte(fmt.Sprintf("{\"public\":\"%s\",\"private\":\"%s\"}", publicKey, privateKey)), }) } @@ -837,119 +741,6 @@ func (c *TargetsVendor) onGetCert(request v1alpha2.COARequest) v1alpha2.COARespo return resp } -// waitForCertificateReady waits for Certificate to be ready and secret to have the correct type and content -func (c *TargetsVendor) waitForCertificateReady(ctx context.Context, certName, namespace, secretName string) error { - tLog.InfofCtx(ctx, "V (Targets) : waiting for certificate %s to be ready in namespace %s", certName, namespace) - - // Create a context with timeout for the whole operation - timeoutCtx, cancel := context.WithTimeout(ctx, 120*time.Second) - defer cancel() - - op := func() error { - // Check Certificate status - ready, err := c.checkCertificateStatus(timeoutCtx, certName, namespace) - if err != nil { - tLog.ErrorfCtx(timeoutCtx, "V (Targets) : error checking certificate status: %v", err) - return err - } - - if !ready { - tLog.ErrorfCtx(timeoutCtx, "V (Targets) : certificate %s not ready yet", certName) - return fmt.Errorf("certificate %s not ready", certName) - } - - // Check if secret exists and has correct type - secretReady, err := c.checkSecretReady(timeoutCtx, secretName, namespace) - if err != nil { - tLog.ErrorfCtx(timeoutCtx, "V (Targets) : error checking secret status: %v", err) - return err - } - - if !secretReady { - tLog.ErrorfCtx(timeoutCtx, "V (Targets) : secret %s not ready yet", secretName) - return fmt.Errorf("secret %s not ready", secretName) - } - - tLog.InfofCtx(timeoutCtx, "V (Targets) : certificate %s and secret %s are ready", certName, secretName) - return nil - } - - // Use exponential backoff with the timeout context for cancellation - bo := backoff.NewExponentialBackOff() - bo.InitialInterval = 2 * time.Second - bo.MaxInterval = 10 * time.Second - // Respect the outer timeout via WithContext - err := backoff.RetryNotify(op, backoff.WithContext(bo, timeoutCtx), func(err error, duration time.Duration) { - tLog.InfofCtx(timeoutCtx, "V (Targets) : retrying certificate check in %v due to: %v", duration, err) - }) - - if err != nil { - return fmt.Errorf("timeout waiting for certificate %s to be ready: %s", certName, err.Error()) - } - - return nil -} - -// checkCertificateStatus checks if Certificate is ready -func (c *TargetsVendor) checkCertificateStatus(ctx context.Context, certName, namespace string) (bool, error) { - getRequest := states.GetRequest{ - ID: certName, - Metadata: map[string]interface{}{ - "namespace": namespace, - "group": "cert-manager.io", - "version": "v1", - "resource": "certificates", - "kind": "Certificate", - }, - } - - entry, err := c.TargetsManager.StateProvider.Get(ctx, getRequest) - if err != nil { - return false, fmt.Errorf("failed to get certificate: %s", err.Error()) - } - - // Check Certificate status conditions - if status, found := entry.Body.(map[string]interface{})["status"]; found { - if statusMap, ok := status.(map[string]interface{}); ok { - if conditions, found := statusMap["conditions"]; found { - if conditionsArray, ok := conditions.([]interface{}); ok { - for _, condition := range conditionsArray { - if condMap, ok := condition.(map[string]interface{}); ok { - if condType, found := condMap["type"]; found && strings.EqualFold(condType.(string), "ready") { - if condStatus, found := condMap["status"]; found && strings.EqualFold(condStatus.(string), "true") { - return true, nil - } - } - } - } - } - } - } - } - - return false, nil -} - -// checkSecretReady checks if secret exists and has the correct type and content -func (c *TargetsVendor) checkSecretReady(ctx context.Context, secretName, namespace string) (bool, error) { - evalCtx := coa_utils.EvaluationContext{Namespace: namespace} - - // Try to read both tls.crt and tls.key to verify secret is complete - _, err := c.TargetsManager.SecretProvider.Read(ctx, secretName, "tls.crt", evalCtx) - if err != nil { - tLog.ErrorfCtx(ctx, "V (Targets) : secret %s not ready yet, waiting...", secretName) - return false, err // Secret not ready yet - } - - _, err = c.TargetsManager.SecretProvider.Read(ctx, secretName, "tls.key", evalCtx) - if err != nil { - tLog.ErrorCtx(ctx, "V (Targets) : secret %s not ready yet, waiting...", secretName) - return false, err // Secret not complete yet - } - - return true, nil -} - func (c *TargetsVendor) onUpdateTopology(request v1alpha2.COARequest) v1alpha2.COAResponse { ctx, span := observability.StartSpan("Targets Vendor", request.Context, &map[string]string{ "method": "onUpdateTopology", diff --git a/api/pkg/apis/v1alpha1/vendors/targets-vendor_test.go b/api/pkg/apis/v1alpha1/vendors/targets-vendor_test.go index 730912ef7..54c0c5deb 100644 --- a/api/pkg/apis/v1alpha1/vendors/targets-vendor_test.go +++ b/api/pkg/apis/v1alpha1/vendors/targets-vendor_test.go @@ -10,6 +10,7 @@ import ( "context" "encoding/json" "testing" + "time" sym_mgr "github.com/eclipse-symphony/symphony/api/pkg/apis/v1alpha1/managers" "github.com/eclipse-symphony/symphony/api/pkg/apis/v1alpha1/model" @@ -17,6 +18,7 @@ import ( "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2" "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/managers" "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/providers" + certProvider "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/providers/cert" "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/providers/pubsub/memory" "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/providers/secret/mock" "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/providers/states" @@ -27,6 +29,44 @@ import ( "github.com/valyala/fasthttp" ) +// MockCertProvider implements both ICertProvider and IProvider interfaces for testing +type MockCertProvider struct { +} + +// Implement IProvider interface +func (m *MockCertProvider) Init(config providers.IProviderConfig) error { + return nil +} + +// Implement ICertProvider interface +func (m *MockCertProvider) CreateCert(ctx context.Context, req certProvider.CertRequest) error { + return nil +} + +func (m *MockCertProvider) DeleteCert(ctx context.Context, targetName, namespace string) error { + return nil +} + +func (m *MockCertProvider) GetCert(ctx context.Context, targetName, namespace string) (*certProvider.CertResponse, error) { + // Return mock certificate data that matches what the test expects + return &certProvider.CertResponse{ + PublicKey: "-----BEGIN CERTIFICATE-----\nMIIBkTCB+wIJANlqGR0GwHpNMA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNVBAMMCWxv\nY2FsaG9zdDAeFw0yMzA5MjIwOTE1MzRaFw0yNDA5MjEwOTE1MzRaMBQxEjAQBgNV\nBAMMCWxvY2FsaG9zdDBcMA0GCSqGSIb3DQEBAQUAA0sAMEgCQQC7rsI/sNlQmFD+\nkab4TGXYXfVBnPJnZvajRvHxiTPfkTfNWVE/6LiYh8WNk6BW8jXMf5jf+DBSjvKW\n8P3VNhv5AgMBAAEwDQYJKoZIhvcNAQELBQADQQBJ4v4Y7HdXaajdRP3IgJyVgKQL\nIvdzP8qfmYCAf0+Dg4Gx8kfyze89/+P8dGwBCU6VzQGsv6K4FlT0gWg=\n-----END CERTIFICATE-----", + PrivateKey: "-----BEGIN PRIVATE KEY-----\nMIIBVAIBADANBgkqhkiG9w0BAQEFAASCAT4wggE6AgEAAkEAu67CP7DZUJHQ+pGm\n+Exl2F31QZzyZ2b2o0bx8Ykz35E3zVlRP+i4mIfFjZOgVvI1zH+Y3/gwUo7ylvD9\n1TYb+QIDAQABAkEAjLP5+VKam+XlSlJiuk8VSwZJvN1Ba2z3o7bq7J7z6KfkfWo3\nUvLL+bt+5YfzpxjzHur8YKK+n8KhSz5WPLwLsQIhAOO+7v7FL1a6K+FmJ2fPGadU\nqcY7FKjP3LTnh35pjNn1AiEA2gL7YKzsKmK2ZvJukM8eJSlrL7JJJLVcLhHmYzXa\nqr0CIGl+ADVLJiVZyCgJiXUgD7qq5Gi7CWGm2RJef8Gtn2aFAiBU5aAB/j3NKt7g\nkMHRnznYKBdb8tKUsQZgxWY1KXDoNwIgSPqzOgCpG6UNhG2jgL9JyGG7JJ1b7PfJ\nD8wEgEJWj8Y=\n-----END PRIVATE KEY-----", + ExpiresAt: time.Now().Add(24 * time.Hour), + SerialNumber: "123456789", + }, nil +} + +func (m *MockCertProvider) CheckCertStatus(ctx context.Context, targetName, namespace string) (*certProvider.CertStatus, error) { + return &certProvider.CertStatus{ + Ready: true, + Reason: "Certificate is ready", + Message: "Mock certificate is ready for use", + LastUpdate: time.Now(), + NextRenewal: time.Now().Add(7 * 24 * time.Hour), + }, nil +} + func TestTargetsEndpoints(t *testing.T) { vendor := createTargetsVendor() vendor.Route = "targets" @@ -48,6 +88,10 @@ func createTargetsVendor() TargetsVendor { secretProvider := mock.MockSecretProvider{} secretProvider.Init(mock.MockSecretProviderConfig{Name: "test-secret"}) + // Create mock certificate provider and initialize it + mockCertProvider := &MockCertProvider{} + mockCertProvider.Init(nil) // Initialize the provider + pubSubProvider := memory.InMemoryPubSubProvider{} pubSubProvider.Init(memory.InMemoryPubSubConfig{Name: "test"}) vendor := TargetsVendor{} @@ -61,12 +105,21 @@ func createTargetsVendor() TargetsVendor { Type: "managers.symphony.targets", Properties: map[string]string{ "providers.persistentstate": "mem-state", + "providers.cert": "k8scert", }, Providers: map[string]managers.ProviderConfig{ "mem-state": { Type: "providers.state.memory", Config: memorystate.MemoryStateProviderConfig{}, }, + "k8scert": { + Type: "providers.cert.k8s", + Config: map[string]interface{}{ + "inCluster": true, + "defaultDuration": "4320h", + "renewBefore": "360h", + }, + }, }, }, }, @@ -75,11 +128,13 @@ func createTargetsVendor() TargetsVendor { }, map[string]map[string]providers.IProvider{ "targets-manager": { "mem-state": &stateProvider, + "k8scert": mockCertProvider, // Add certificate provider to the providers map }, }, &pubSubProvider) vendor.Config.Properties["useJobManager"] = "true" vendor.TargetsManager.TargetValidator = validation.NewTargetValidator(nil, nil) vendor.TargetsManager.SecretProvider = &secretProvider + // Certificate provider should now be automatically set during Init() due to the providers map return vendor } func TestTargetsOnRegistry(t *testing.T) { diff --git a/coa/go.mod b/coa/go.mod index 2a46f0889..e89ccde69 100644 --- a/coa/go.mod +++ b/coa/go.mod @@ -114,6 +114,7 @@ require ( google.golang.org/genproto/googleapis/api v0.0.0-20241007155032-5fefd90f89a9 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20241007155032-5fefd90f89a9 // indirect google.golang.org/protobuf v1.35.1 // indirect + gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/coa/go.sum b/coa/go.sum index 27b1fbb5e..501dab708 100644 --- a/coa/go.sum +++ b/coa/go.sum @@ -319,6 +319,8 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/cheggaaa/pb.v2 v2.0.7/go.mod h1:0CiZ1p8pvtxBlQpLXkHuUTpdJ1shm3OqCF1QugkjHL4= +gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= +gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/fatih/color.v1 v1.7.0/go.mod h1:P7yosIhqIl/sX8J8UypY5M+dDpD2KmyfP5IRs5v/fo0= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= diff --git a/coa/pkg/apis/v1alpha2/managers/managers.go b/coa/pkg/apis/v1alpha2/managers/managers.go index 9d7f4e9af..8c89a4d4c 100644 --- a/coa/pkg/apis/v1alpha2/managers/managers.go +++ b/coa/pkg/apis/v1alpha2/managers/managers.go @@ -13,6 +13,7 @@ import ( "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2" contexts "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/contexts" providers "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/providers" + "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/providers/cert" "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/providers/config" "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/providers/keylock" "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/providers/ledger" @@ -263,6 +264,22 @@ func GetReporter(config ManagerConfig, providers map[string]providers.IProvider) return reporterProvider, nil } +func GetCertProvider(config ManagerConfig, providers map[string]providers.IProvider) (cert.ICertProvider, error) { + certProviderName, ok := config.Properties[v1alpha2.ProvidersCert] + if !ok { + return nil, v1alpha2.NewCOAError(nil, "cert provider is not configured", v1alpha2.MissingConfig) + } + provider, ok := providers[certProviderName] + if !ok { + return nil, v1alpha2.NewCOAError(nil, "cert provider is not supplied", v1alpha2.MissingConfig) + } + certProvider, ok := provider.(cert.ICertProvider) + if !ok { + return nil, v1alpha2.NewCOAError(nil, "supplied provider is not a cert provider", v1alpha2.BadConfig) + } + return certProvider, nil +} + func NeedObjectValidate(config ManagerConfig, providers map[string]providers.IProvider) bool { stateProviderName, ok := config.Properties[v1alpha2.ProvidersPersistentState] if !ok { diff --git a/coa/pkg/apis/v1alpha2/providers/cert/cert.go b/coa/pkg/apis/v1alpha2/providers/cert/cert.go new file mode 100644 index 000000000..3b7864392 --- /dev/null +++ b/coa/pkg/apis/v1alpha2/providers/cert/cert.go @@ -0,0 +1,57 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT license. + * SPDX-License-Identifier: MIT + */ + +package cert + +import ( + "context" + "time" +) + +// ICertProvider defines the interface for certificate providers +type ICertProvider interface { + // CreateCert creates a new certificate for the specified target + CreateCert(ctx context.Context, req CertRequest) error + + // DeleteCert deletes the certificate for the specified target + DeleteCert(ctx context.Context, targetName, namespace string) error + + // GetCert retrieves the certificate for the specified target + GetCert(ctx context.Context, targetName, namespace string) (*CertResponse, error) + + // CheckCertStatus checks the status of the certificate for the specified target + CheckCertStatus(ctx context.Context, targetName, namespace string) (*CertStatus, error) +} + +// CertRequest represents a certificate creation request +type CertRequest struct { + TargetName string `json:"targetName"` + Namespace string `json:"namespace"` + Duration time.Duration `json:"duration"` + RenewBefore time.Duration `json:"renewBefore"` + CommonName string `json:"commonName"` + DNSNames []string `json:"dnsNames"` + IssuerName string `json:"issuerName"` + ServiceName string `json:"serviceName"` + Subject map[string]interface{} `json:"subject,omitempty"` +} + +// CertResponse represents a certificate response +type CertResponse struct { + PublicKey string `json:"publicKey"` + PrivateKey string `json:"privateKey"` + ExpiresAt time.Time `json:"expiresAt"` + SerialNumber string `json:"serialNumber"` +} + +// CertStatus represents the status of a certificate +type CertStatus struct { + Ready bool `json:"ready"` + Reason string `json:"reason"` + Message string `json:"message"` + LastUpdate time.Time `json:"lastUpdate"` + NextRenewal time.Time `json:"nextRenewal"` +} diff --git a/coa/pkg/apis/v1alpha2/providers/cert/k8scert/k8scert.go b/coa/pkg/apis/v1alpha2/providers/cert/k8scert/k8scert.go new file mode 100644 index 000000000..21f4edec4 --- /dev/null +++ b/coa/pkg/apis/v1alpha2/providers/cert/k8scert/k8scert.go @@ -0,0 +1,371 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT license. + * SPDX-License-Identifier: MIT + */ + +package k8scert + +import ( + "context" + "crypto/x509" + "encoding/json" + "encoding/pem" + "fmt" + "time" + + "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/providers" + "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/providers/cert" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" +) + +type K8SCertProviderConfig struct { + DefaultDuration string `json:"defaultDuration"` + RenewBefore string `json:"renewBefore"` +} + +type K8SCertProvider struct { + Config K8SCertProviderConfig + Context context.Context + K8sClient kubernetes.Interface + DynamicClient dynamic.Interface +} + +func (p *K8SCertProvider) ID() string { + return "k8s-cert" +} + +func (p *K8SCertProvider) SetContext(ctx context.Context) { + p.Context = ctx +} + +func toK8SCertProviderConfig(config providers.IProviderConfig) (K8SCertProviderConfig, error) { + ret := K8SCertProviderConfig{} + data, err := json.Marshal(config) + if err != nil { + return ret, err + } + err = json.Unmarshal(data, &ret) + return ret, err +} + +func (p *K8SCertProvider) Init(config providers.IProviderConfig) error { + aConfig, err := toK8SCertProviderConfig(config) + if err != nil { + return fmt.Errorf("failed to convert config: %w", err) + } + p.Config = aConfig + + // Get in-cluster config + restConfig, err := rest.InClusterConfig() + if err != nil { + return fmt.Errorf("failed to get in-cluster config: %w", err) + } + + // Create Kubernetes client + clientset, err := kubernetes.NewForConfig(restConfig) + if err != nil { + return fmt.Errorf("failed to create Kubernetes client: %w", err) + } + p.K8sClient = clientset + + // Create dynamic client for cert-manager CRDs + dynamicClient, err := dynamic.NewForConfig(restConfig) + if err != nil { + return fmt.Errorf("failed to create dynamic client: %w", err) + } + p.DynamicClient = dynamicClient + + return nil +} + +// getConfigDuration reads the defaultDuration from provider configuration +func (p *K8SCertProvider) getConfigDuration() time.Duration { + if p.Config.DefaultDuration == "" { + return 4320 * time.Hour // 180 days default + } + + duration, err := time.ParseDuration(p.Config.DefaultDuration) + if err != nil { + return 4320 * time.Hour // 180 days default + } + + return duration +} + +// getConfigRenewBefore reads the renewBefore from provider configuration +func (p *K8SCertProvider) getConfigRenewBefore() time.Duration { + if p.Config.RenewBefore == "" { + return 360 * time.Hour // 15 days default + } + + renewBefore, err := time.ParseDuration(p.Config.RenewBefore) + if err != nil { + return 360 * time.Hour // 15 days default + } + + return renewBefore +} + +// parseCertificateInfo extracts serial number and expiration time from PEM-encoded certificate data +func parseCertificateInfo(certData []byte) (string, time.Time, error) { + // Decode PEM block + block, _ := pem.Decode(certData) + if block == nil { + return "", time.Time{}, fmt.Errorf("failed to decode PEM block") + } + + // Parse certificate + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return "", time.Time{}, fmt.Errorf("failed to parse certificate: %w", err) + } + + return cert.SerialNumber.String(), cert.NotAfter, nil +} + +func (p *K8SCertProvider) CreateCert(ctx context.Context, req cert.CertRequest) error { + // Validate required fields + if err := p.validateCertRequest(req); err != nil { + return fmt.Errorf("invalid certificate request: %w", err) + } + + // Define the Certificate resource + certificateGVR := schema.GroupVersionResource{ + Group: "cert-manager.io", + Version: "v1", + Resource: "certificates", + } + + // Always use provider config for duration and renewBefore + duration := p.getConfigDuration() + renewBefore := p.getConfigRenewBefore() + + // Use consistent naming: targetname-working-cert + secretName := fmt.Sprintf("%s-working-cert", req.TargetName) + + // Build certificate spec with proper field handling + spec, err := p.buildCertificateSpec(req, secretName, duration, renewBefore) + if err != nil { + return fmt.Errorf("failed to build certificate spec: %w", err) + } + + // Create the Certificate object + certificate := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "cert-manager.io/v1", + "kind": "Certificate", + "metadata": map[string]interface{}{ + "name": req.TargetName, + "namespace": req.Namespace, + }, + "spec": spec, + }, + } + + // Create the certificate with better error handling + _, err = p.DynamicClient.Resource(certificateGVR).Namespace(req.Namespace).Create( + ctx, certificate, metav1.CreateOptions{}) + if err != nil { + if errors.IsAlreadyExists(err) { + return fmt.Errorf("certificate '%s' already exists in namespace '%s'", req.TargetName, req.Namespace) + } + return fmt.Errorf("failed to create certificate '%s' in namespace '%s': %w", req.TargetName, req.Namespace, err) + } + + return nil +} + +// validateCertRequest validates the required fields in the certificate request +func (p *K8SCertProvider) validateCertRequest(req cert.CertRequest) error { + if req.TargetName == "" { + return fmt.Errorf("targetName is required") + } + if req.Namespace == "" { + return fmt.Errorf("namespace is required") + } + if req.IssuerName == "" { + return fmt.Errorf("issuerName is required") + } + if req.CommonName == "" { + return fmt.Errorf("commonName is required") + } + return nil +} + +// buildCertificateSpec builds the certificate spec with proper field handling +func (p *K8SCertProvider) buildCertificateSpec(req cert.CertRequest, secretName string, duration, renewBefore time.Duration) (map[string]interface{}, error) { + spec := map[string]interface{}{ + "secretName": secretName, + "issuerRef": map[string]interface{}{ + "name": req.IssuerName, + "kind": "Issuer", + }, + "commonName": req.CommonName, + "duration": duration.String(), + "renewBefore": renewBefore.String(), + } + + // Only add dnsNames if it's not empty + if len(req.DNSNames) > 0 { + spec["dnsNames"] = req.DNSNames + } + + // Only add subject if it's not empty to avoid issues with nil maps + if len(req.Subject) > 0 { + spec["subject"] = req.Subject + } + + return spec, nil +} + +func (p *K8SCertProvider) DeleteCert(ctx context.Context, targetName, namespace string) error { + certificateGVR := schema.GroupVersionResource{ + Group: "cert-manager.io", + Version: "v1", + Resource: "certificates", + } + + err := p.DynamicClient.Resource(certificateGVR).Namespace(namespace).Delete( + ctx, targetName, metav1.DeleteOptions{}) + if err != nil && !errors.IsNotFound(err) { + return fmt.Errorf("failed to delete certificate: %w", err) + } + + return nil +} + +func (p *K8SCertProvider) GetCert(ctx context.Context, targetName, namespace string) (*cert.CertResponse, error) { + // Get the Certificate resource to find the secret name + certificateGVR := schema.GroupVersionResource{ + Group: "cert-manager.io", + Version: "v1", + Resource: "certificates", + } + + var certificate *unstructured.Unstructured + certificate, err := p.DynamicClient.Resource(certificateGVR).Namespace(namespace).Get( + ctx, targetName, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to get certificate for %s after retries: %w", targetName, err) + } + + // Extract the secret name from the certificate spec + spec, ok := certificate.Object["spec"].(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("invalid certificate spec") + } + + secretName, ok := spec["secretName"].(string) + if !ok { + return nil, fmt.Errorf("secret name not found in certificate spec") + } + + // Get the secret containing the certificate + secret, err := p.K8sClient.CoreV1().Secrets(namespace).Get(ctx, secretName, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to get secret %s: %w", secretName, err) + } + + // Extract certificate data + certData, exists := secret.Data["tls.crt"] + if !exists { + return nil, fmt.Errorf("certificate data not found in secret") + } + + // Extract private key data + keyData, exists := secret.Data["tls.key"] + if !exists { + return nil, fmt.Errorf("private key data not found in secret") + } + + // Parse certificate to get real serial number and expiration time + serialNumber, expiresAt, err := parseCertificateInfo(certData) + if err != nil { + // If parsing fails, return basic info + // Fallback: use current time + config duration as estimated expiration (not real cert expiration) + return &cert.CertResponse{ + PublicKey: string(certData), + PrivateKey: string(keyData), + SerialNumber: "parsing-failed", + ExpiresAt: time.Now().Add(p.getConfigDuration()), // fallback, estimated value + }, nil + } + + return &cert.CertResponse{ + PublicKey: string(certData), + PrivateKey: string(keyData), + SerialNumber: serialNumber, + ExpiresAt: expiresAt, + }, nil +} + +func (p *K8SCertProvider) CheckCertStatus(ctx context.Context, targetName, namespace string) (*cert.CertStatus, error) { + certificateGVR := schema.GroupVersionResource{ + Group: "cert-manager.io", + Version: "v1", + Resource: "certificates", + } + + certificate, err := p.DynamicClient.Resource(certificateGVR).Namespace(namespace).Get( + ctx, targetName, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to get certificate: %w", err) + } + + // Check the status + status, ok := certificate.Object["status"].(map[string]interface{}) + if !ok { + return &cert.CertStatus{ + Ready: false, + LastUpdate: time.Now(), + }, nil + } + + conditions, ok := status["conditions"].([]interface{}) + if !ok || len(conditions) == 0 { + return &cert.CertStatus{ + Ready: false, + LastUpdate: time.Now(), + }, nil + } + + // Check the Ready condition + for _, condition := range conditions { + condMap, ok := condition.(map[string]interface{}) + if !ok { + continue + } + + if condType, ok := condMap["type"].(string); ok && condType == "Ready" { + if condStatus, ok := condMap["status"].(string); ok { + if condStatus == "True" { + return &cert.CertStatus{ + Ready: true, + LastUpdate: time.Now(), + }, nil + } else { + reason, _ := condMap["reason"].(string) + message, _ := condMap["message"].(string) + return &cert.CertStatus{ + Ready: false, + Reason: reason, + Message: message, + LastUpdate: time.Now(), + }, nil + } + } + } + } + + return &cert.CertStatus{ + Ready: false, + LastUpdate: time.Now(), + }, nil +} diff --git a/coa/pkg/apis/v1alpha2/providers/cert/k8scert/k8scert_test.go b/coa/pkg/apis/v1alpha2/providers/cert/k8scert/k8scert_test.go new file mode 100644 index 000000000..7b554174f --- /dev/null +++ b/coa/pkg/apis/v1alpha2/providers/cert/k8scert/k8scert_test.go @@ -0,0 +1,657 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT license. + * SPDX-License-Identifier: MIT + */ + +package k8scert + +import ( + "context" + "testing" + "time" + + "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/providers/cert" + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/dynamic/fake" + k8sfake "k8s.io/client-go/kubernetes/fake" +) + +// MockProviderConfig implements IProviderConfig for testing +type MockProviderConfig struct { + Name string `json:"name"` + DefaultDuration string `json:"defaultDuration"` + RenewBefore string `json:"renewBefore"` +} + +func TestK8SCertProvider_ID(t *testing.T) { + provider := &K8SCertProvider{} + assert.Equal(t, "k8s-cert", provider.ID()) +} + +func TestK8SCertProvider_SetContext(t *testing.T) { + provider := &K8SCertProvider{} + ctx := context.Background() + provider.SetContext(ctx) + assert.Equal(t, ctx, provider.Context) +} + +func TestGetCert_Success(t *testing.T) { + // Create fake certificate resource + certificate := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "cert-manager.io/v1", + "kind": "Certificate", + "metadata": map[string]interface{}{ + "name": "test-target", + "namespace": "test-namespace", + }, + "spec": map[string]interface{}{ + "secretName": "test-secret", + }, + }, + } + + // Create fake secret with certificate data + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "test-namespace", + }, + Data: map[string][]byte{ + "tls.crt": []byte("-----BEGIN CERTIFICATE-----\nMIIBkTCB+gIJAK...certificate data...\n-----END CERTIFICATE-----\n"), + "tls.key": []byte("-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgk...key data...\n-----END PRIVATE KEY-----\n"), + }, + } + + // Create fake clients + scheme := runtime.NewScheme() + dynamicClient := fake.NewSimpleDynamicClient(scheme, certificate) + kubeClient := k8sfake.NewSimpleClientset(secret) + + provider := &K8SCertProvider{ + K8sClient: kubeClient, + DynamicClient: dynamicClient, + Context: context.Background(), + } + + // Test GetCert + result, err := provider.GetCert(context.Background(), "test-target", "test-namespace") + + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Contains(t, result.PublicKey, "-----BEGIN CERTIFICATE-----") + assert.Contains(t, result.PrivateKey, "-----BEGIN PRIVATE KEY-----") +} + +func TestGetCert_CertificateNotFound(t *testing.T) { + // Create fake clients without the certificate + scheme := runtime.NewScheme() + dynamicClient := fake.NewSimpleDynamicClient(scheme) + kubeClient := k8sfake.NewSimpleClientset() + + provider := &K8SCertProvider{ + K8sClient: kubeClient, + DynamicClient: dynamicClient, + Context: context.Background(), + } + + // Test GetCert with non-existent certificate + result, err := provider.GetCert(context.Background(), "test-target", "test-namespace") + + assert.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "failed to get certificate") +} + +func TestCheckCertStatus_Ready(t *testing.T) { + // Create certificate with ready status + certificate := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "cert-manager.io/v1", + "kind": "Certificate", + "metadata": map[string]interface{}{ + "name": "test-target", + "namespace": "test-namespace", + }, + "status": map[string]interface{}{ + "conditions": []interface{}{ + map[string]interface{}{ + "type": "Ready", + "status": "True", + }, + }, + }, + }, + } + + scheme := runtime.NewScheme() + dynamicClient := fake.NewSimpleDynamicClient(scheme, certificate) + + provider := &K8SCertProvider{ + DynamicClient: dynamicClient, + Context: context.Background(), + } + + // Test CheckCertStatus + status, err := provider.CheckCertStatus(context.Background(), "test-target", "test-namespace") + + assert.NoError(t, err) + assert.NotNil(t, status) + assert.True(t, status.Ready) +} + +func TestCheckCertStatus_NotReady(t *testing.T) { + // Create certificate with not ready status + certificate := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "cert-manager.io/v1", + "kind": "Certificate", + "metadata": map[string]interface{}{ + "name": "test-target", + "namespace": "test-namespace", + }, + "status": map[string]interface{}{ + "conditions": []interface{}{ + map[string]interface{}{ + "type": "Ready", + "status": "False", + "reason": "Pending", + "message": "Certificate is being issued", + }, + }, + }, + }, + } + + scheme := runtime.NewScheme() + dynamicClient := fake.NewSimpleDynamicClient(scheme, certificate) + + provider := &K8SCertProvider{ + DynamicClient: dynamicClient, + Context: context.Background(), + } + + // Test CheckCertStatus + status, err := provider.CheckCertStatus(context.Background(), "test-target", "test-namespace") + + assert.NoError(t, err) + assert.NotNil(t, status) + assert.False(t, status.Ready) + assert.Equal(t, "Pending", status.Reason) + assert.Equal(t, "Certificate is being issued", status.Message) +} + +func TestCreateCert_Success(t *testing.T) { + scheme := runtime.NewScheme() + dynamicClient := fake.NewSimpleDynamicClient(scheme) + + provider := &K8SCertProvider{ + DynamicClient: dynamicClient, + Context: context.Background(), + } + + // Test CreateCert with minimal required fields to avoid deep copy issues + req := cert.CertRequest{ + TargetName: "test-target", + Namespace: "test-namespace", + Duration: time.Hour * 2160, // 90 days + RenewBefore: time.Hour * 360, // 15 days + CommonName: "test-service", + IssuerName: "test-issuer", + } + + err := provider.CreateCert(context.Background(), req) + assert.NoError(t, err) +} + +func TestDeleteCert_Success(t *testing.T) { + // Create certificate to delete + certificate := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "cert-manager.io/v1", + "kind": "Certificate", + "metadata": map[string]interface{}{ + "name": "test-target", + "namespace": "test-namespace", + }, + }, + } + + scheme := runtime.NewScheme() + dynamicClient := fake.NewSimpleDynamicClient(scheme, certificate) + + provider := &K8SCertProvider{ + DynamicClient: dynamicClient, + Context: context.Background(), + } + + // Test DeleteCert + err := provider.DeleteCert(context.Background(), "test-target", "test-namespace") + assert.NoError(t, err) +} + +func TestDeleteCert_NotFound(t *testing.T) { + // Create empty client + scheme := runtime.NewScheme() + dynamicClient := fake.NewSimpleDynamicClient(scheme) + + provider := &K8SCertProvider{ + DynamicClient: dynamicClient, + Context: context.Background(), + } + + // Test DeleteCert with non-existent certificate (should not error) + err := provider.DeleteCert(context.Background(), "test-target", "test-namespace") + assert.NoError(t, err) // DeleteCert should not error if certificate doesn't exist +} + +func TestParseCertificateInfo_InvalidPEM(t *testing.T) { + // Test with invalid PEM data + invalidPEM := []byte("invalid pem data") + serialNumber, expiresAt, err := parseCertificateInfo(invalidPEM) + + assert.Error(t, err) + assert.Empty(t, serialNumber) + assert.True(t, expiresAt.IsZero()) + assert.Contains(t, err.Error(), "failed to decode PEM block") +} + +func TestCertRequest_Fields(t *testing.T) { + // Test that CertRequest has all expected fields + req := cert.CertRequest{ + TargetName: "test-target", + Namespace: "test-namespace", + Duration: time.Hour * 24, + RenewBefore: time.Hour * 2, + CommonName: "test-common", + DNSNames: []string{"example.com"}, + IssuerName: "test-issuer", + ServiceName: "test-service", + } + + assert.Equal(t, "test-target", req.TargetName) + assert.Equal(t, "test-namespace", req.Namespace) + assert.Equal(t, time.Hour*24, req.Duration) + assert.Equal(t, time.Hour*2, req.RenewBefore) + assert.Equal(t, "test-common", req.CommonName) + assert.Equal(t, []string{"example.com"}, req.DNSNames) + assert.Equal(t, "test-issuer", req.IssuerName) + assert.Equal(t, "test-service", req.ServiceName) +} + +func TestCertResponse_Fields(t *testing.T) { + // Test that CertResponse has all expected fields + now := time.Now() + resp := cert.CertResponse{ + PublicKey: "public-key", + PrivateKey: "private-key", + ExpiresAt: now, + SerialNumber: "123456", + } + + assert.Equal(t, "public-key", resp.PublicKey) + assert.Equal(t, "private-key", resp.PrivateKey) + assert.Equal(t, now, resp.ExpiresAt) + assert.Equal(t, "123456", resp.SerialNumber) +} + +func TestCertStatus_Fields(t *testing.T) { + // Test that CertStatus has all expected fields + now := time.Now() + status := cert.CertStatus{ + Ready: true, + Reason: "Ready", + Message: "Certificate is ready", + LastUpdate: now, + NextRenewal: now.Add(time.Hour), + } + + assert.True(t, status.Ready) + assert.Equal(t, "Ready", status.Reason) + assert.Equal(t, "Certificate is ready", status.Message) + assert.Equal(t, now, status.LastUpdate) + assert.Equal(t, now.Add(time.Hour), status.NextRenewal) +} + +func TestToK8SCertProviderConfig(t *testing.T) { + // Test config conversion + mockConfig := MockProviderConfig{ + DefaultDuration: "4320h", + RenewBefore: "360h", + } + + result, err := toK8SCertProviderConfig(mockConfig) + assert.NoError(t, err) + assert.Equal(t, "4320h", result.DefaultDuration) + assert.Equal(t, "360h", result.RenewBefore) +} + +func TestGetConfigDuration(t *testing.T) { + // Test with valid config + provider := &K8SCertProvider{ + Config: K8SCertProviderConfig{ + DefaultDuration: "2160h", // 90 days + }, + } + duration := provider.getConfigDuration() + assert.Equal(t, time.Hour*2160, duration) + + // Test with empty config + provider.Config.DefaultDuration = "" + duration = provider.getConfigDuration() + assert.Equal(t, time.Hour*4320, duration) // Should use default + + // Test with invalid config + provider.Config.DefaultDuration = "invalid" + duration = provider.getConfigDuration() + assert.Equal(t, time.Hour*4320, duration) // Should use default +} + +func TestGetConfigRenewBefore(t *testing.T) { + // Test with valid config + provider := &K8SCertProvider{ + Config: K8SCertProviderConfig{ + RenewBefore: "240h", // 10 days + }, + } + renewBefore := provider.getConfigRenewBefore() + assert.Equal(t, time.Hour*240, renewBefore) + + // Test with empty config + provider.Config.RenewBefore = "" + renewBefore = provider.getConfigRenewBefore() + assert.Equal(t, time.Hour*360, renewBefore) // Should use default + + // Test with invalid config + provider.Config.RenewBefore = "invalid" + renewBefore = provider.getConfigRenewBefore() + assert.Equal(t, time.Hour*360, renewBefore) // Should use default +} + +func TestCreateCert_WithZeroValues(t *testing.T) { + scheme := runtime.NewScheme() + dynamicClient := fake.NewSimpleDynamicClient(scheme) + + provider := &K8SCertProvider{ + Config: K8SCertProviderConfig{ + DefaultDuration: "2160h", // 90 days + RenewBefore: "240h", // 10 days + }, + DynamicClient: dynamicClient, + Context: context.Background(), + } + + // Test CreateCert with zero duration and renewBefore (should use config defaults) + req := cert.CertRequest{ + TargetName: "test-target", + Namespace: "test-namespace", + Duration: 0, // Zero value - should use config default + RenewBefore: 0, // Zero value - should use config default + CommonName: "test-service", + IssuerName: "test-issuer", + } + + err := provider.CreateCert(context.Background(), req) + assert.NoError(t, err) +} + +func TestCreateCert_WithNonZeroValues(t *testing.T) { + scheme := runtime.NewScheme() + dynamicClient := fake.NewSimpleDynamicClient(scheme) + + provider := &K8SCertProvider{ + Config: K8SCertProviderConfig{ + DefaultDuration: "2160h", // 90 days + RenewBefore: "240h", // 10 days + }, + DynamicClient: dynamicClient, + Context: context.Background(), + } + + // Test CreateCert with non-zero values (should use request values) + req := cert.CertRequest{ + TargetName: "test-target", + Namespace: "test-namespace", + Duration: time.Hour * 720, // 30 days - should use this value + RenewBefore: time.Hour * 72, // 3 days - should use this value + CommonName: "test-service", + IssuerName: "test-issuer", + } + + err := provider.CreateCert(context.Background(), req) + assert.NoError(t, err) +} + +func TestCreateCert_SecretNaming(t *testing.T) { + scheme := runtime.NewScheme() + dynamicClient := fake.NewSimpleDynamicClient(scheme) + + provider := &K8SCertProvider{ + Config: K8SCertProviderConfig{ + DefaultDuration: "2160h", + RenewBefore: "240h", + }, + DynamicClient: dynamicClient, + Context: context.Background(), + } + + req := cert.CertRequest{ + TargetName: "my-target", + Namespace: "test-namespace", + Duration: time.Hour * 24, + RenewBefore: time.Hour * 2, + CommonName: "test-service", + IssuerName: "test-issuer", + } + + err := provider.CreateCert(context.Background(), req) + assert.NoError(t, err) + + // The secret name should be "my-target-working-cert" + // We can't directly verify this from the fake client, but the test passing means + // the certificate was created without errors using the new naming scheme +} + +func TestValidateCertRequest_ValidRequest(t *testing.T) { + provider := &K8SCertProvider{} + + req := cert.CertRequest{ + TargetName: "test-target", + Namespace: "test-namespace", + CommonName: "test-common", + IssuerName: "test-issuer", + } + + err := provider.validateCertRequest(req) + assert.NoError(t, err) +} + +func TestValidateCertRequest_MissingTargetName(t *testing.T) { + provider := &K8SCertProvider{} + + req := cert.CertRequest{ + Namespace: "test-namespace", + CommonName: "test-common", + IssuerName: "test-issuer", + } + + err := provider.validateCertRequest(req) + assert.Error(t, err) + assert.Contains(t, err.Error(), "targetName is required") +} + +func TestValidateCertRequest_MissingNamespace(t *testing.T) { + provider := &K8SCertProvider{} + + req := cert.CertRequest{ + TargetName: "test-target", + CommonName: "test-common", + IssuerName: "test-issuer", + } + + err := provider.validateCertRequest(req) + assert.Error(t, err) + assert.Contains(t, err.Error(), "namespace is required") +} + +func TestValidateCertRequest_MissingCommonName(t *testing.T) { + provider := &K8SCertProvider{} + + req := cert.CertRequest{ + TargetName: "test-target", + Namespace: "test-namespace", + IssuerName: "test-issuer", + } + + err := provider.validateCertRequest(req) + assert.Error(t, err) + assert.Contains(t, err.Error(), "commonName is required") +} + +func TestValidateCertRequest_MissingIssuerName(t *testing.T) { + provider := &K8SCertProvider{} + + req := cert.CertRequest{ + TargetName: "test-target", + Namespace: "test-namespace", + CommonName: "test-common", + } + + err := provider.validateCertRequest(req) + assert.Error(t, err) + assert.Contains(t, err.Error(), "issuerName is required") +} + +func TestBuildCertificateSpec_WithDNSNames(t *testing.T) { + provider := &K8SCertProvider{} + + req := cert.CertRequest{ + TargetName: "test-target", + Namespace: "test-namespace", + CommonName: "test-common", + IssuerName: "test-issuer", + DNSNames: []string{"example.com", "www.example.com"}, + } + + spec, err := provider.buildCertificateSpec(req, "test-secret", time.Hour*24, time.Hour*2) + assert.NoError(t, err) + assert.NotNil(t, spec) + + dnsNames, exists := spec["dnsNames"] + assert.True(t, exists) + assert.Equal(t, []string{"example.com", "www.example.com"}, dnsNames) +} + +func TestBuildCertificateSpec_WithoutDNSNames(t *testing.T) { + provider := &K8SCertProvider{} + + req := cert.CertRequest{ + TargetName: "test-target", + Namespace: "test-namespace", + CommonName: "test-common", + IssuerName: "test-issuer", + DNSNames: []string{}, // Empty slice + } + + spec, err := provider.buildCertificateSpec(req, "test-secret", time.Hour*24, time.Hour*2) + assert.NoError(t, err) + assert.NotNil(t, spec) + + _, exists := spec["dnsNames"] + assert.False(t, exists) // Should not be included when empty +} + +func TestBuildCertificateSpec_WithSubject(t *testing.T) { + provider := &K8SCertProvider{} + + subject := map[string]interface{}{ + "organization": "Test Org", + "country": "US", + } + + req := cert.CertRequest{ + TargetName: "test-target", + Namespace: "test-namespace", + CommonName: "test-common", + IssuerName: "test-issuer", + Subject: subject, + } + + spec, err := provider.buildCertificateSpec(req, "test-secret", time.Hour*24, time.Hour*2) + assert.NoError(t, err) + assert.NotNil(t, spec) + + specSubject, exists := spec["subject"] + assert.True(t, exists) + assert.Equal(t, subject, specSubject) +} + +func TestBuildCertificateSpec_WithoutSubject(t *testing.T) { + provider := &K8SCertProvider{} + + req := cert.CertRequest{ + TargetName: "test-target", + Namespace: "test-namespace", + CommonName: "test-common", + IssuerName: "test-issuer", + Subject: nil, // Nil subject + } + + spec, err := provider.buildCertificateSpec(req, "test-secret", time.Hour*24, time.Hour*2) + assert.NoError(t, err) + assert.NotNil(t, spec) + + _, exists := spec["subject"] + assert.False(t, exists) // Should not be included when nil +} + +func TestBuildCertificateSpec_WithEmptySubject(t *testing.T) { + provider := &K8SCertProvider{} + + req := cert.CertRequest{ + TargetName: "test-target", + Namespace: "test-namespace", + CommonName: "test-common", + IssuerName: "test-issuer", + Subject: map[string]interface{}{}, // Empty map + } + + spec, err := provider.buildCertificateSpec(req, "test-secret", time.Hour*24, time.Hour*2) + assert.NoError(t, err) + assert.NotNil(t, spec) + + _, exists := spec["subject"] + assert.False(t, exists) // Should not be included when empty +} + +func TestCreateCert_ValidationFailure(t *testing.T) { + scheme := runtime.NewScheme() + dynamicClient := fake.NewSimpleDynamicClient(scheme) + + provider := &K8SCertProvider{ + Config: K8SCertProviderConfig{ + DefaultDuration: "2160h", + RenewBefore: "240h", + }, + DynamicClient: dynamicClient, + Context: context.Background(), + } + + // Test with missing required field + req := cert.CertRequest{ + TargetName: "", // Missing target name + Namespace: "test-namespace", + CommonName: "test-common", + IssuerName: "test-issuer", + } + + err := provider.CreateCert(context.Background(), req) + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid certificate request") + assert.Contains(t, err.Error(), "targetName is required") +} diff --git a/coa/pkg/apis/v1alpha2/types.go b/coa/pkg/apis/v1alpha2/types.go index 451ddd1f9..15c718bae 100644 --- a/coa/pkg/apis/v1alpha2/types.go +++ b/coa/pkg/apis/v1alpha2/types.go @@ -395,6 +395,7 @@ const ( ProviderQueue = "providers.queue" ProviderLedger = "providers.ledger" ProvidersKeyLock = "providers.keylock" + ProvidersCert = "providers.cert" StatusOutput = "__status" ErrorOutput = "__error" StateOutput = "__state" diff --git a/packages/helm/symphony/files/symphony-api.json b/packages/helm/symphony/files/symphony-api.json index ac35ad524..ec418f3fd 100644 --- a/packages/helm/symphony/files/symphony-api.json +++ b/packages/helm/symphony/files/symphony-api.json @@ -328,16 +328,13 @@ { "type": "vendors.targets", "route": "targets", - "properties": { - "workingCertDuration": "{{ .Values.targets.workingCertDuration | default "2160h" }}", - "workingCertRenewBefore": "{{ .Values.targets.workingCertRenewBefore | default "360h" }}" - }, "managers": [ { "name": "targets-manager", "type": "managers.symphony.targets", "properties": { - "providers.persistentstate": "k8s-state" + "providers.persistentstate": "k8s-state", + "providers.cert": "k8s-cert" }, "providers": { "k8s-state": { @@ -346,10 +343,11 @@ "inCluster": true } }, - "secret": { - "type": "providers.secret.k8s", + "k8s-cert": { + "type": "providers.cert.k8scert", "config": { - "inCluster": true + "defaultDuration": "{{ .Values.cert.certDurationTime | default "4320h" }}", + "renewBefore": "{{ .Values.cert.certRenewBeforeTime | default "360h" }}" } } } @@ -519,7 +517,8 @@ "providers.config": "mock-config", "providers.queue": "redis-queue", "providers.secret": "mock-secret", - "providers.keylock": "mem-keylock" + "providers.keylock": "mem-keylock", + "providers.cert": "k8s-cert" }, "providers": { "redis-state": { @@ -557,6 +556,13 @@ "mock-secret": { "type": "providers.secret.mock", "config": {} + }, + "k8s-cert": { + "type": "providers.cert.k8scert", + "config": { + "defaultDuration": "{{ .Values.cert.certDurationTime | default "4320h" }}", + "renewBefore": "{{ .Values.cert.certRenewBeforeTime | default "360h" }}" + } } } } diff --git a/remote-agent/bootstrap/bootstrap.ps1 b/remote-agent/bootstrap/bootstrap.ps1 index d85e406b3..d068599ba 100644 --- a/remote-agent/bootstrap/bootstrap.ps1 +++ b/remote-agent/bootstrap/bootstrap.ps1 @@ -177,20 +177,43 @@ if ($protocol -eq 'http') { } # HTTP mode: Get certificates from server - try { - $WebRequestParams = @{ - Uri = "$($endpoint)/targets/getcert/$($target_name)?namespace=$($namespace)&osPlatform=windows" - Method = 'Post' - Certificate = $cert - Headers = @{ "Content-Type" = "application/json"; "User-Agent" = "PowerShell-Debug" } + # get certificates from symphony server with retries + Write-Host "Begin to get certificates from symphony server" -ForegroundColor Blue + $maxRetries = 12 + $retryCount = 0 + $success = $false + $response = $null + $WebRequestParams = @{ + Uri = "$($endpoint)/targets/getcert/$($target_name)?namespace=$($namespace)&osPlatform=windows" + Method = 'Post' + Certificate = $cert + Headers = @{ "Content-Type" = "application/json"; "User-Agent" = "PowerShell-Debug" } + } + while ($retryCount -lt $maxRetries -and -not $success) { + try { + Write-Host "WebRequestParams:" -ForegroundColor Cyan + $WebRequestParams.GetEnumerator() | ForEach-Object { Write-Host (" {0}: {1}" -f $_.Key, $_.Value) } + $response = Invoke-WebRequest @WebRequestParams -Verbose + $jsonResponse = $response.Content | ConvertFrom-Json + if (-not [string]::IsNullOrEmpty($jsonResponse.public) -and -not [string]::IsNullOrEmpty($jsonResponse.private)) { + $success = $true + Write-Host "Successfully got working certificates from symphony server" -ForegroundColor Green + break + } else { + Write-Host "Certificate not ready, retrying in 10 seconds... ($($retryCount+1)/$maxRetries)" -ForegroundColor Yellow + } + } catch { + Write-Host "Error: Failed to send request to endpoint. Retrying in 10 seconds... ($($retryCount+1)/$maxRetries)" -ForegroundColor Red + Write-Host "Error Message: $($_.Exception.Message)" -ForegroundColor Red + } + Start-Sleep -Seconds 10 + $retryCount++ + } + if (-not $success) { + Write-Host "Error: Failed to get certificate after $($maxRetries*10) seconds." -ForegroundColor Red + if ($response -and $response.Content) { + Write-Host "Last response: $($response.Content)" -ForegroundColor Red } - Write-Host "WebRequestParams:" -ForegroundColor Cyan - $WebRequestParams.GetEnumerator() | ForEach-Object { Write-Host (" {0}: {1}" -f $_.Key, $_.Value) } - $response = Invoke-WebRequest @WebRequestParams -Verbose - Write-Host "Successfully got working certificates from symphony server" -ForegroundColor Green - } catch { - Write-Host "Error: Failed to send request to endpoint." -ForegroundColor Red - Write-Host "Error Message: $($_.Exception.Message)" -ForegroundColor Red exit 1 } diff --git a/remote-agent/bootstrap/bootstrap.sh b/remote-agent/bootstrap/bootstrap.sh index d069b5790..e92a16cab 100755 --- a/remote-agent/bootstrap/bootstrap.sh +++ b/remote-agent/bootstrap/bootstrap.sh @@ -190,8 +190,28 @@ if [ "$protocol" = "http" ]; then curl_cmd="$curl_cmd -k" fi - # Get certificate - result=$(eval "$curl_cmd -X POST \"$bootstrapCertEndpoint\" -H \"Content-Type: application/json\"") + # Get certificate with retry (10s interval, max 120s) + retry_count=0 + max_retries=12 + result="" + while true; do + result=$(eval "$curl_cmd -X POST \"$bootstrapCertEndpoint\" -H \"Content-Type: application/json\"") + if [ $? -eq 0 ]; then + # Check if response contains valid public and private fields + public=$(echo $result | jq -r '.public') + private=$(echo $result | jq -r '.private') + if [ -n "$public" ] && [ -n "$private" ]; then + break + fi + fi + retry_count=$((retry_count+1)) + if [ $retry_count -ge $max_retries ]; then + echo -e "\e[31mError: Failed to get certificate after $((max_retries*10)) seconds. Response: $result\e[0m" + exit 1 + fi + echo -e "\e[33mCertificate not ready, retrying in 10 seconds... ($retry_count/$max_retries)\e[0m" + sleep 10 + done if [ $? -ne 0 ]; then echo -e "\e[31mError: Failed to call certificate endpoint. Please check the endpoint and try again.\e[0m" @@ -205,10 +225,10 @@ if [ "$protocol" = "http" ]; then private=$(echo $result | jq -r '.private') # Check if we got valid certificates - if [ "$public" = "null" ] || [ "$private" = "null" ] || [ -z "$public" ] || [ -z "$private" ]; then - echo -e "\e[31mError: Failed to extract certificates from response. Response: $result\e[0m" - exit 1 - fi + if [ -z "$public" ] || [ "$public" = "null" ] || [ -z "$private" ] || [ "$private" = "null" ]; then + echo -e "\e[31mError: Failed to extract certificates from response. Response: $result\e[0m" + exit 1 + fi # Reconstruct PEM format properly (Symphony converts \n to spaces for transmission) # Convert to word arrays and reconstruct with proper headers/footers diff --git a/test/integration/scenarios/13.remoteAgent/magefile.go b/test/integration/scenarios/13.remoteAgent/magefile.go index 0fa7da5ec..f69b777cf 100644 --- a/test/integration/scenarios/13.remoteAgent/magefile.go +++ b/test/integration/scenarios/13.remoteAgent/magefile.go @@ -18,7 +18,7 @@ import ( // Test config const ( - TEST_NAME = "Remote Agent Communication scenario (HTTP and MQTT)" + TEST_NAME = "Remote Agent Communication scenario " TEST_TIMEOUT = "30m" )