diff --git a/go.mod b/go.mod index 2b0a409..bdb0475 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/GoCodeAlone/workflow-plugin-digitalocean go 1.26.0 require ( - github.com/GoCodeAlone/workflow v0.17.0 + github.com/GoCodeAlone/workflow v0.18.2 github.com/aws/aws-sdk-go-v2 v1.41.5 github.com/aws/aws-sdk-go-v2/credentials v1.19.12 github.com/aws/aws-sdk-go-v2/service/s3 v1.97.2 diff --git a/go.sum b/go.sum index fffbc61..4987d6c 100644 --- a/go.sum +++ b/go.sum @@ -56,8 +56,8 @@ github.com/GoCodeAlone/modular/modules/jsonschema v1.15.0 h1:xb1mI4NZkzvNKQ2F6nk github.com/GoCodeAlone/modular/modules/jsonschema v1.15.0/go.mod h1:hhGouwAVsonmJ4Lain4jINZ9nZCoc9l9eF3BHbmR8eE= github.com/GoCodeAlone/modular/modules/reverseproxy/v2 v2.8.0 h1:cvdLHbM/vzvygQTcAWSJsy+dAPzzwWyjzKMmTBFcFIo= github.com/GoCodeAlone/modular/modules/reverseproxy/v2 v2.8.0/go.mod h1:/9ipMG4qM2CHQ14BfXKdVlYRJelef6M8MFI5TbZv67M= -github.com/GoCodeAlone/workflow v0.17.0 h1:Fp4eOdaZKNnIsBvLJT4PcxSmv+++M3X9McKjKMEMz3g= -github.com/GoCodeAlone/workflow v0.17.0/go.mod h1:MGC8lxQzA1TLRIK09mGEN5JNpRzFUcTWgqk3M0/H5FI= +github.com/GoCodeAlone/workflow v0.18.2 h1:Vbm+NvhWMcHHl1zE6aQzNh3MSJzQhmunFO6DA3x9qh8= +github.com/GoCodeAlone/workflow v0.18.2/go.mod h1:ypkCqXTwnIPqNjS8h38KZfwzdVsgwgkS1d6Dq0lXyQQ= github.com/GoCodeAlone/yaegi v0.17.2 h1:WK6Y6e0t1a6U7r+S2dN3CGWW1PizYD3zO0zneToZPxM= github.com/GoCodeAlone/yaegi v0.17.2/go.mod h1:z5Pr6Wse6QJcQvpgxTxzMAevFarH0N37TG88Y9dprx0= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 h1:rIkQfkCOVKc1OiRCNcSDD8ml5RJlZbH/Xsq7lbpynwc= diff --git a/internal/drivers/app_platform.go b/internal/drivers/app_platform.go index 2cc8d93..8513cc4 100644 --- a/internal/drivers/app_platform.go +++ b/internal/drivers/app_platform.go @@ -42,34 +42,16 @@ func NewAppPlatformDriverWithClient(c AppPlatformClient, region string) *AppPlat } func (d *AppPlatformDriver) Create(ctx context.Context, spec interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { - imgSpec, err := imageSpecFromConfig(spec.Config) - if err != nil { - return nil, fmt.Errorf("app platform image config: %w", err) - } region, _ := spec.Config["region"].(string) if region == "" { region = d.region } - httpPort, _ := intFromConfig(spec.Config, "http_port", 8080) - instanceCount, _ := intFromConfig(spec.Config, "instance_count", 1) - - req := &godo.AppCreateRequest{ - Spec: &godo.AppSpec{ - Name: spec.Name, - Region: region, - Services: []*godo.AppServiceSpec{ - { - Name: spec.Name, - InstanceCount: int64(instanceCount), - HTTPPort: int64(httpPort), - Envs: envVarsFromConfig(spec.Config), - Image: imgSpec, - }, - }, - }, + appSpec, err := buildAppSpec(spec.Name, spec.Config, region) + if err != nil { + return nil, fmt.Errorf("app platform build spec: %w", err) } - app, _, err := d.client.Create(ctx, req) + app, _, err := d.client.Create(ctx, &godo.AppCreateRequest{Spec: appSpec}) if err != nil { return nil, fmt.Errorf("app platform create %q: %w", spec.Name, WrapGodoError(err)) } @@ -110,34 +92,16 @@ func (d *AppPlatformDriver) findAppByName(ctx context.Context, name string) (*in } func (d *AppPlatformDriver) Update(ctx context.Context, ref interfaces.ResourceRef, spec interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { - imgSpec, err := imageSpecFromConfig(spec.Config) - if err != nil { - return nil, fmt.Errorf("app platform image config: %w", err) - } region, _ := spec.Config["region"].(string) if region == "" { region = d.region } - httpPort, _ := intFromConfig(spec.Config, "http_port", 8080) - instanceCount, _ := intFromConfig(spec.Config, "instance_count", 1) - - req := &godo.AppUpdateRequest{ - Spec: &godo.AppSpec{ - Name: spec.Name, - Region: region, - Services: []*godo.AppServiceSpec{ - { - Name: spec.Name, - InstanceCount: int64(instanceCount), - HTTPPort: int64(httpPort), - Envs: envVarsFromConfig(spec.Config), - Image: imgSpec, - }, - }, - }, + appSpec, err := buildAppSpec(spec.Name, spec.Config, region) + if err != nil { + return nil, fmt.Errorf("app platform build spec: %w", err) } - app, _, err := d.client.Update(ctx, ref.ProviderID, req) + app, _, err := d.client.Update(ctx, ref.ProviderID, &godo.AppUpdateRequest{Spec: appSpec}) if err != nil { return nil, fmt.Errorf("app platform update %q: %w", ref.Name, WrapGodoError(err)) } @@ -376,11 +340,19 @@ func imageSpecFromMap(m map[string]any) (*godo.ImageSourceSpec, error) { } // envVarsFromConfig converts the "env_vars" map in spec config to App Platform -// environment variable definitions. Values listed under "secret_env_vars" are -// marked as SECRET so DigitalOcean stores them encrypted. +// environment variable definitions. Secret vars use "env_vars_secret" (canonical key). +// "secret_env_vars" is a legacy alias: it is used only when "env_vars_secret" is +// absent from the config entirely (key-presence check, not length check). func envVarsFromConfig(cfg map[string]any) []*godo.AppVariableDefinition { raw, _ := cfg["env_vars"].(map[string]any) - secrets, _ := cfg["secret_env_vars"].(map[string]any) + // Prefer the canonical key; fall back to the legacy alias only when the + // canonical key is not present at all (not just empty). + var secrets map[string]any + if v, ok := cfg["env_vars_secret"]; ok { + secrets, _ = v.(map[string]any) + } else { + secrets, _ = cfg["secret_env_vars"].(map[string]any) + } if len(raw) == 0 && len(secrets) == 0 { return nil } diff --git a/internal/drivers/app_platform_buildspec.go b/internal/drivers/app_platform_buildspec.go new file mode 100644 index 0000000..f0314b1 --- /dev/null +++ b/internal/drivers/app_platform_buildspec.go @@ -0,0 +1,749 @@ +package drivers + +import ( + "fmt" + "strings" + + "github.com/digitalocean/godo" +) + +// containerSizingMap maps canonical abstract size tiers to DO App Platform instance size slugs. +var containerSizingMap = map[string]string{ + "xs": "apps-s-1vcpu-0.5gb", + "s": "apps-s-1vcpu-1gb", + "m": "apps-s-2vcpu-4gb", + "l": "apps-s-4vcpu-8gb", + "xl": "apps-s-8vcpu-16gb", +} + +// buildAppSpec converts a canonical config map into a fully-populated godo.AppSpec. +// It maps every supported canonical IaC key to the corresponding DO App Platform field. +func buildAppSpec(name string, cfg map[string]any, region string) (*godo.AppSpec, error) { + imgSpec, err := imageSpecFromConfig(cfg) + if err != nil { + return nil, fmt.Errorf("app platform image config: %w", err) + } + + httpPort, _ := intFromConfig(cfg, "http_port", 8080) + instanceCount, _ := intFromConfig(cfg, "instance_count", 1) + + svc := &godo.AppServiceSpec{ + Name: name, + Image: imgSpec, + HTTPPort: int64(httpPort), + InstanceCount: int64(instanceCount), + Envs: envVarsFromConfig(cfg), + BuildCommand: strFromConfig(cfg, "build_command", ""), + RunCommand: strFromConfig(cfg, "run_command", ""), + DockerfilePath: strFromConfig(cfg, "dockerfile_path", ""), + SourceDir: strFromConfig(cfg, "source_dir", ""), + InstanceSizeSlug: instanceSizeSlugFromConfig(cfg), + Protocol: servingProtocolFromConfig(cfg), + InternalPorts: internalPortsFromConfig(cfg), + Routes: routesFromConfig(cfg), + HealthCheck: serviceHealthCheckFromConfig(cfg), + LivenessHealthCheck: livenessHealthCheckFromConfig(cfg), + CORS: corsFromConfig(cfg), + Autoscaling: autoscalingFromConfig(cfg), + Termination: serviceTerminationFromConfig(cfg), + LogDestinations: logDestinationsFromConfig(cfg), + Alerts: componentAlertsFromConfig(cfg), + } + + // Extract provider_specific.digitalocean overrides for top-level AppSpec fields. + var ( + disableEdgeCache bool + disableEmailObfuscation bool + enhancedThreatControl bool + features []string + ) + if ps, ok := cfg["provider_specific"].(map[string]any); ok { + if do, ok := ps["digitalocean"].(map[string]any); ok { + disableEdgeCache, _ = do["disable_edge_cache"].(bool) + disableEmailObfuscation, _ = do["disable_email_obfuscation"].(bool) + enhancedThreatControl, _ = do["enhanced_threat_control_enabled"].(bool) + if ff, ok := do["features"].([]any); ok { + for _, f := range ff { + if s, ok := f.(string); ok { + features = append(features, s) + } + } + } + } + } + + spec := &godo.AppSpec{ + Name: name, + Region: region, + Services: []*godo.AppServiceSpec{svc}, + Jobs: jobsFromConfig(cfg), + Workers: workersFromConfig(cfg), + StaticSites: staticSitesFromConfig(cfg), + Domains: domainsFromConfig(cfg), + Alerts: appAlertsFromConfig(cfg), + Ingress: ingressFromConfig(cfg), + Egress: egressFromConfig(cfg), + Maintenance: maintenanceFromConfig(cfg), + Vpc: vpcFromConfig(cfg), + Features: features, + DisableEdgeCache: disableEdgeCache, + DisableEmailObfuscation: disableEmailObfuscation, + EnhancedThreatControlEnabled: enhancedThreatControl, + } + return spec, nil +} + +// instanceSizeSlugFromConfig resolves the canonical "size" key to a DO instance size slug. +// provider_specific.digitalocean.instance_size_slug takes precedence over the abstract "size" tier. +func instanceSizeSlugFromConfig(cfg map[string]any) string { + if ps, ok := cfg["provider_specific"].(map[string]any); ok { + if do, ok := ps["digitalocean"].(map[string]any); ok { + if slug, ok := do["instance_size_slug"].(string); ok && slug != "" { + return slug + } + } + } + size := strFromConfig(cfg, "size", "") + if slug, ok := containerSizingMap[size]; ok { + return slug + } + return "" +} + +// servingProtocolFromConfig maps canonical "protocol" to a godo.ServingProtocol. +// DO supports HTTP and HTTP2; unknown values are passed through for forward compatibility. +func servingProtocolFromConfig(cfg map[string]any) godo.ServingProtocol { + proto := strings.ToUpper(strFromConfig(cfg, "protocol", "")) + switch proto { + case "HTTP2": + return godo.SERVINGPROTOCOL_HTTP2 + case "HTTP", "": + return "" // omit — DO defaults to HTTP + default: + return godo.ServingProtocol(proto) + } +} + +// internalPortsFromConfig converts the canonical "internal_ports" list to []int64. +func internalPortsFromConfig(cfg map[string]any) []int64 { + raw, ok := cfg["internal_ports"].([]any) + if !ok || len(raw) == 0 { + return nil + } + out := make([]int64, 0, len(raw)) + for _, v := range raw { + switch t := v.(type) { + case int: + out = append(out, int64(t)) + case int64: + out = append(out, t) + case float64: + out = append(out, int64(t)) + } + } + return out +} + +// routesFromConfig converts the canonical "routes" list to []*godo.AppRouteSpec. +func routesFromConfig(cfg map[string]any) []*godo.AppRouteSpec { + raw, ok := cfg["routes"].([]any) + if !ok || len(raw) == 0 { + return nil + } + routes := make([]*godo.AppRouteSpec, 0, len(raw)) + for _, v := range raw { + m, ok := v.(map[string]any) + if !ok { + continue + } + r := &godo.AppRouteSpec{ + Path: strFromConfig(m, "path", "/"), + } + if pp, ok := m["preserve_path_prefix"].(bool); ok { + r.PreservePathPrefix = pp + } + routes = append(routes, r) + } + return routes +} + +// serviceHealthCheckFromConfig converts the canonical "health_check" map to a godo.AppServiceSpecHealthCheck. +func serviceHealthCheckFromConfig(cfg map[string]any) *godo.AppServiceSpecHealthCheck { + raw, ok := cfg["health_check"].(map[string]any) + if !ok || len(raw) == 0 { + return nil + } + hc := &godo.AppServiceSpecHealthCheck{ + HTTPPath: strFromConfig(raw, "http_path", ""), + } + if v, ok := intFromConfig(raw, "initial_delay_seconds", 0); ok { + hc.InitialDelaySeconds = int32(v) + } + if v, ok := intFromConfig(raw, "period_seconds", 0); ok { + hc.PeriodSeconds = int32(v) + } + if v, ok := intFromConfig(raw, "timeout_seconds", 0); ok { + hc.TimeoutSeconds = int32(v) + } + if v, ok := intFromConfig(raw, "success_threshold", 0); ok { + hc.SuccessThreshold = int32(v) + } + if v, ok := intFromConfig(raw, "failure_threshold", 0); ok { + hc.FailureThreshold = int32(v) + } + if v, ok := intFromConfig(raw, "port", 0); ok { + hc.Port = int64(v) + } + return hc +} + +// livenessHealthCheckFromConfig converts the canonical "liveness_check" map to a godo.HealthCheckSpec. +func livenessHealthCheckFromConfig(cfg map[string]any) *godo.HealthCheckSpec { + raw, ok := cfg["liveness_check"].(map[string]any) + if !ok || len(raw) == 0 { + return nil + } + hc := &godo.HealthCheckSpec{ + HTTPPath: strFromConfig(raw, "http_path", ""), + } + if v, ok := intFromConfig(raw, "initial_delay_seconds", 0); ok { + hc.InitialDelaySeconds = int32(v) + } + if v, ok := intFromConfig(raw, "period_seconds", 0); ok { + hc.PeriodSeconds = int32(v) + } + if v, ok := intFromConfig(raw, "timeout_seconds", 0); ok { + hc.TimeoutSeconds = int32(v) + } + if v, ok := intFromConfig(raw, "success_threshold", 0); ok { + hc.SuccessThreshold = int32(v) + } + if v, ok := intFromConfig(raw, "failure_threshold", 0); ok { + hc.FailureThreshold = int32(v) + } + if v, ok := intFromConfig(raw, "port", 0); ok { + hc.Port = int64(v) + } + return hc +} + +// corsFromConfig converts the canonical "cors" map to a godo.AppCORSPolicy. +func corsFromConfig(cfg map[string]any) *godo.AppCORSPolicy { + raw, ok := cfg["cors"].(map[string]any) + if !ok || len(raw) == 0 { + return nil + } + policy := &godo.AppCORSPolicy{ + MaxAge: strFromConfig(raw, "max_age", ""), + } + if cred, ok := raw["allow_credentials"].(bool); ok { + policy.AllowCredentials = cred + } + policy.AllowOrigins = stringMatchesFromConfig(raw, "allow_origins") + policy.AllowMethods = stringsFromConfig(raw, "allow_methods") + policy.AllowHeaders = stringsFromConfig(raw, "allow_headers") + policy.ExposeHeaders = stringsFromConfig(raw, "expose_headers") + if len(policy.AllowOrigins) == 0 && len(policy.AllowMethods) == 0 && + len(policy.AllowHeaders) == 0 && len(policy.ExposeHeaders) == 0 && + !policy.AllowCredentials && policy.MaxAge == "" { + return nil + } + return policy +} + +// autoscalingFromConfig converts the canonical "autoscaling" map to a godo.AppAutoscalingSpec. +func autoscalingFromConfig(cfg map[string]any) *godo.AppAutoscalingSpec { + raw, ok := cfg["autoscaling"].(map[string]any) + if !ok || len(raw) == 0 { + return nil + } + spec := &godo.AppAutoscalingSpec{} + if v, ok := intFromConfig(raw, "min", 0); ok { + spec.MinInstanceCount = int64(v) + } + if v, ok := intFromConfig(raw, "max", 0); ok { + spec.MaxInstanceCount = int64(v) + } + if spec.MinInstanceCount == 0 && spec.MaxInstanceCount == 0 { + return nil + } + if v, ok := intFromConfig(raw, "cpu_percent", 0); ok && v > 0 { + spec.Metrics = &godo.AppAutoscalingSpecMetrics{ + CPU: &godo.AppAutoscalingSpecMetricCPU{Percent: int64(v)}, + } + } + return spec +} + +// serviceTerminationFromConfig converts canonical "termination" to godo.AppServiceSpecTermination. +func serviceTerminationFromConfig(cfg map[string]any) *godo.AppServiceSpecTermination { + raw, ok := cfg["termination"].(map[string]any) + if !ok || len(raw) == 0 { + return nil + } + t := &godo.AppServiceSpecTermination{} + if v, ok := intFromConfig(raw, "drain_seconds", 0); ok { + t.DrainSeconds = int32(v) + } + if v, ok := intFromConfig(raw, "grace_period_seconds", 0); ok { + t.GracePeriodSeconds = int32(v) + } + if t.DrainSeconds == 0 && t.GracePeriodSeconds == 0 { + return nil + } + return t +} + +// componentAlertsFromConfig converts the canonical "alerts" list to []*godo.AppAlertSpec. +// These alerts apply to the service component (CPU, memory, restart counts). +func componentAlertsFromConfig(cfg map[string]any) []*godo.AppAlertSpec { + raw, ok := cfg["alerts"].([]any) + if !ok || len(raw) == 0 { + return nil + } + out := make([]*godo.AppAlertSpec, 0, len(raw)) + for _, v := range raw { + m, ok := v.(map[string]any) + if !ok { + continue + } + spec := alertSpecFromMap(m) + if spec != nil { + out = append(out, spec) + } + } + return out +} + +// appAlertsFromConfig builds app-level alerts (deployment events) from canonical "alerts". +// App-level alerts use a separate list on the AppSpec. +func appAlertsFromConfig(cfg map[string]any) []*godo.AppAlertSpec { + // App-level alerts (DEPLOYMENT_FAILED, DEPLOYMENT_LIVE, etc.) are not in the canonical + // schema; they come from provider_specific.digitalocean.app_alerts if present. + ps, ok := cfg["provider_specific"].(map[string]any) + if !ok { + return nil + } + do, ok := ps["digitalocean"].(map[string]any) + if !ok { + return nil + } + raw, ok := do["app_alerts"].([]any) + if !ok || len(raw) == 0 { + return nil + } + out := make([]*godo.AppAlertSpec, 0, len(raw)) + for _, v := range raw { + m, ok := v.(map[string]any) + if !ok { + continue + } + spec := alertSpecFromMap(m) + if spec != nil { + out = append(out, spec) + } + } + return out +} + +// alertSpecFromMap converts a canonical alert map to a godo.AppAlertSpec. +func alertSpecFromMap(m map[string]any) *godo.AppAlertSpec { + rule := strFromConfig(m, "rule", "") + if rule == "" { + return nil + } + spec := &godo.AppAlertSpec{ + Rule: godo.AppAlertSpecRule(strings.ToUpper(rule)), + } + if op := strFromConfig(m, "operator", ""); op != "" { + spec.Operator = godo.AppAlertSpecOperator(strings.ToUpper(op)) + } + if win := strFromConfig(m, "window", ""); win != "" { + spec.Window = godo.AppAlertSpecWindow(strings.ToUpper(win)) + } + // YAML decode produces float64 for decimals and int for whole numbers; + // accept all numeric types so alert thresholds work regardless of YAML representation. + switch v := m["value"].(type) { + case float64: + spec.Value = float32(v) + case float32: + spec.Value = v + case int: + spec.Value = float32(v) + case int64: + spec.Value = float32(v) + } + if disabled, ok := m["disabled"].(bool); ok { + spec.Disabled = disabled + } + return spec +} + +// logDestinationsFromConfig converts the canonical "log_destinations" list to []*godo.AppLogDestinationSpec. +// Currently maps: endpoint (HTTP), papertrail endpoint, datadog api_key/endpoint, logtail token. +func logDestinationsFromConfig(cfg map[string]any) []*godo.AppLogDestinationSpec { + raw, ok := cfg["log_destinations"].([]any) + if !ok || len(raw) == 0 { + return nil + } + out := make([]*godo.AppLogDestinationSpec, 0, len(raw)) + for _, v := range raw { + m, ok := v.(map[string]any) + if !ok { + continue + } + name := strFromConfig(m, "name", "") + if name == "" { + continue + } + dest := &godo.AppLogDestinationSpec{ + Name: name, + Endpoint: strFromConfig(m, "endpoint", ""), + } + if tls, ok := m["tls_insecure"].(bool); ok { + dest.TLSInsecure = tls + } + // Provider-specific sub-specs. + if pt, ok := m["papertrail"].(map[string]any); ok { + dest.Papertrail = &godo.AppLogDestinationSpecPapertrail{ + Endpoint: strFromConfig(pt, "endpoint", ""), + } + } + if dd, ok := m["datadog"].(map[string]any); ok { + dest.Datadog = &godo.AppLogDestinationSpecDataDog{ + ApiKey: strFromConfig(dd, "api_key", ""), + Endpoint: strFromConfig(dd, "endpoint", ""), + } + } + if lt, ok := m["logtail"].(map[string]any); ok { + dest.Logtail = &godo.AppLogDestinationSpecLogtail{ + Token: strFromConfig(lt, "token", ""), + } + } + out = append(out, dest) + } + return out +} + +// domainsFromConfig converts the canonical "domains" list to []*godo.AppDomainSpec. +func domainsFromConfig(cfg map[string]any) []*godo.AppDomainSpec { + raw, ok := cfg["domains"].([]any) + if !ok || len(raw) == 0 { + return nil + } + out := make([]*godo.AppDomainSpec, 0, len(raw)) + for _, v := range raw { + m, ok := v.(map[string]any) + if !ok { + continue + } + domain := strFromConfig(m, "domain", "") + if domain == "" { + continue + } + d := &godo.AppDomainSpec{ + Domain: domain, + Zone: strFromConfig(m, "zone", ""), + Certificate: strFromConfig(m, "certificate", ""), + MinimumTLSVersion: strFromConfig(m, "minimum_tls_version", ""), + } + if wc, ok := m["wildcard"].(bool); ok { + d.Wildcard = wc + } + if t := strFromConfig(m, "type", ""); t != "" { + d.Type = godo.AppDomainSpecType(strings.ToUpper(t)) + } + out = append(out, d) + } + return out +} + +// ingressFromConfig converts the canonical "ingress" map to a *godo.AppIngressSpec. +// The canonical ingress spec is minimal; complex routing should use provider_specific. +// Returns nil when no supported fields are present (consistent with CORS/autoscaling/maintenance). +func ingressFromConfig(cfg map[string]any) *godo.AppIngressSpec { + raw, ok := cfg["ingress"].(map[string]any) + if !ok || len(raw) == 0 { + return nil + } + spec := &godo.AppIngressSpec{} + if lb := strFromConfig(raw, "load_balancer", ""); lb != "" { + spec.LoadBalancer = godo.AppIngressSpecLoadBalancer(strings.ToUpper(lb)) + } + // Rules are complex; skip for now unless coming from provider_specific. + // Return nil when no supported field was set to avoid sending an empty ingress block. + if spec.LoadBalancer == "" && len(spec.Rules) == 0 { + return nil + } + return spec +} + +// egressFromConfig converts the canonical "egress" map to a *godo.AppEgressSpec. +func egressFromConfig(cfg map[string]any) *godo.AppEgressSpec { + raw, ok := cfg["egress"].(map[string]any) + if !ok || len(raw) == 0 { + return nil + } + t := strFromConfig(raw, "type", "") + if t == "" { + return nil + } + return &godo.AppEgressSpec{ + Type: godo.AppEgressSpecType(strings.ToUpper(t)), + } +} + +// maintenanceFromConfig converts the canonical "maintenance" map to a *godo.AppMaintenanceSpec. +func maintenanceFromConfig(cfg map[string]any) *godo.AppMaintenanceSpec { + raw, ok := cfg["maintenance"].(map[string]any) + if !ok || len(raw) == 0 { + return nil + } + spec := &godo.AppMaintenanceSpec{ + OfflinePageURL: strFromConfig(raw, "offline_page_url", ""), + } + if enabled, ok := raw["enabled"].(bool); ok { + spec.Enabled = enabled + } + if archive, ok := raw["archive"].(bool); ok { + spec.Archive = archive + } + if !spec.Enabled && !spec.Archive && spec.OfflinePageURL == "" { + return nil + } + return spec +} + +// vpcFromConfig converts the canonical "vpc_ref" string to a *godo.AppVpcSpec. +func vpcFromConfig(cfg map[string]any) *godo.AppVpcSpec { + vpcID := strFromConfig(cfg, "vpc_ref", "") + if vpcID == "" { + return nil + } + return &godo.AppVpcSpec{ID: vpcID} +} + +// jobsFromConfig converts canonical "jobs" to []*godo.AppJobSpec. +func jobsFromConfig(cfg map[string]any) []*godo.AppJobSpec { + raw, ok := cfg["jobs"].([]any) + if !ok || len(raw) == 0 { + return nil + } + out := make([]*godo.AppJobSpec, 0, len(raw)) + for _, v := range raw { + m, ok := v.(map[string]any) + if !ok { + continue + } + job := buildJobSpec(m) + if job != nil { + out = append(out, job) + } + } + return out +} + +// buildJobSpec converts a single canonical job map to a godo.AppJobSpec. +func buildJobSpec(m map[string]any) *godo.AppJobSpec { + name := strFromConfig(m, "name", "") + if name == "" { + return nil + } + kind := mapJobKind(strFromConfig(m, "kind", "")) + job := &godo.AppJobSpec{ + Name: name, + Kind: kind, + RunCommand: strFromConfig(m, "run_command", ""), + BuildCommand: strFromConfig(m, "build_command", ""), + DockerfilePath: strFromConfig(m, "dockerfile_path", ""), + SourceDir: strFromConfig(m, "source_dir", ""), + InstanceSizeSlug: strFromConfig(m, "instance_size_slug", ""), + Envs: envVarsFromJobConfig(m), + } + // Image from "image" field. + if imgStr := strFromConfig(m, "image", ""); imgStr != "" { + img, err := ParseImageRef(imgStr) + if err == nil { + job.Image = img + } + } + // Scheduled jobs have a cron expression. + if cron := strFromConfig(m, "cron", ""); cron != "" { + job.Schedule = &godo.AppJobSpecSchedule{Cron: cron} + } + // Termination. + if t, ok := m["termination"].(map[string]any); ok { + if v, ok := intFromConfig(t, "grace_period_seconds", 0); ok { + job.Termination = &godo.AppJobSpecTermination{GracePeriodSeconds: int32(v)} + } + } + return job +} + +// mapJobKind converts a canonical job kind string to a godo.AppJobSpecKind. +func mapJobKind(kind string) godo.AppJobSpecKind { + switch strings.ToLower(kind) { + case "pre_deploy": + return godo.AppJobSpecKind_PreDeploy + case "post_deploy": + return godo.AppJobSpecKind_PostDeploy + case "failed_deploy": + return godo.AppJobSpecKind_FailedDeploy + case "scheduled": + return godo.AppJobSpecKind_Scheduled + default: + return godo.AppJobSpecKind_Unspecified + } +} + +// workersFromConfig converts canonical "workers" to []*godo.AppWorkerSpec. +func workersFromConfig(cfg map[string]any) []*godo.AppWorkerSpec { + raw, ok := cfg["workers"].([]any) + if !ok || len(raw) == 0 { + return nil + } + out := make([]*godo.AppWorkerSpec, 0, len(raw)) + for _, v := range raw { + m, ok := v.(map[string]any) + if !ok { + continue + } + w := buildWorkerSpec(m) + if w != nil { + out = append(out, w) + } + } + return out +} + +// buildWorkerSpec converts a single canonical worker map to a godo.AppWorkerSpec. +func buildWorkerSpec(m map[string]any) *godo.AppWorkerSpec { + name := strFromConfig(m, "name", "") + if name == "" { + return nil + } + instanceCount, _ := intFromConfig(m, "instance_count", 1) + w := &godo.AppWorkerSpec{ + Name: name, + RunCommand: strFromConfig(m, "run_command", ""), + BuildCommand: strFromConfig(m, "build_command", ""), + DockerfilePath: strFromConfig(m, "dockerfile_path", ""), + SourceDir: strFromConfig(m, "source_dir", ""), + InstanceSizeSlug: strFromConfig(m, "instance_size_slug", ""), + InstanceCount: int64(instanceCount), + Envs: envVarsFromJobConfig(m), + Autoscaling: autoscalingFromConfig(m), + } + if imgStr := strFromConfig(m, "image", ""); imgStr != "" { + img, err := ParseImageRef(imgStr) + if err == nil { + w.Image = img + } + } + // size tier override. + if size := strFromConfig(m, "size", ""); size != "" { + if slug, ok := containerSizingMap[size]; ok { + w.InstanceSizeSlug = slug + } + } + // Termination (workers only have grace_period_seconds, not drain_seconds). + if t, ok := m["termination"].(map[string]any); ok { + wt := &godo.AppWorkerSpecTermination{} + if v, ok := intFromConfig(t, "grace_period_seconds", 0); ok { + wt.GracePeriodSeconds = int32(v) + } + if wt.GracePeriodSeconds != 0 { + w.Termination = wt + } + } + return w +} + +// staticSitesFromConfig converts canonical "static_sites" to []*godo.AppStaticSiteSpec. +func staticSitesFromConfig(cfg map[string]any) []*godo.AppStaticSiteSpec { + raw, ok := cfg["static_sites"].([]any) + if !ok || len(raw) == 0 { + return nil + } + out := make([]*godo.AppStaticSiteSpec, 0, len(raw)) + for _, v := range raw { + m, ok := v.(map[string]any) + if !ok { + continue + } + s := buildStaticSiteSpec(m) + if s != nil { + out = append(out, s) + } + } + return out +} + +// buildStaticSiteSpec converts a single canonical static site map to a godo.AppStaticSiteSpec. +func buildStaticSiteSpec(m map[string]any) *godo.AppStaticSiteSpec { + name := strFromConfig(m, "name", "") + if name == "" { + return nil + } + return &godo.AppStaticSiteSpec{ + Name: name, + BuildCommand: strFromConfig(m, "build_command", ""), + OutputDir: strFromConfig(m, "output_dir", ""), + SourceDir: strFromConfig(m, "source_dir", ""), + DockerfilePath: strFromConfig(m, "dockerfile_path", ""), + IndexDocument: strFromConfig(m, "index_document", ""), + ErrorDocument: strFromConfig(m, "error_document", ""), + CatchallDocument: strFromConfig(m, "catchall_document", ""), + Envs: envVarsFromJobConfig(m), + Routes: routesFromConfig(m), + CORS: corsFromConfig(m), + } +} + +// envVarsFromJobConfig builds env var definitions from "env_vars" and "env_vars_secret" keys. +// It is used for jobs, workers, and static sites (which take the same envs format as services). +func envVarsFromJobConfig(cfg map[string]any) []*godo.AppVariableDefinition { + return envVarsFromConfig(cfg) +} + +// stringsFromConfig extracts a []string from a config key whose value is a []any of strings. +func stringsFromConfig(cfg map[string]any, key string) []string { + raw, ok := cfg[key].([]any) + if !ok || len(raw) == 0 { + return nil + } + out := make([]string, 0, len(raw)) + for _, v := range raw { + if s, ok := v.(string); ok { + out = append(out, s) + } + } + return out +} + +// stringMatchesFromConfig converts a []any of strings to []*godo.AppStringMatch using exact matching. +func stringMatchesFromConfig(cfg map[string]any, key string) []*godo.AppStringMatch { + raw, ok := cfg[key].([]any) + if !ok || len(raw) == 0 { + return nil + } + out := make([]*godo.AppStringMatch, 0, len(raw)) + for _, v := range raw { + switch t := v.(type) { + case string: + if t == "*" { + out = append(out, &godo.AppStringMatch{Prefix: ""}) + } else { + out = append(out, &godo.AppStringMatch{Exact: t}) + } + case map[string]any: + m := &godo.AppStringMatch{ + Exact: strFromConfig(t, "exact", ""), + Prefix: strFromConfig(t, "prefix", ""), + Regex: strFromConfig(t, "regex", ""), + } + out = append(out, m) + } + } + return out +} diff --git a/internal/drivers/app_platform_buildspec_test.go b/internal/drivers/app_platform_buildspec_test.go new file mode 100644 index 0000000..be0f57d --- /dev/null +++ b/internal/drivers/app_platform_buildspec_test.go @@ -0,0 +1,1048 @@ +package drivers_test + +import ( + "testing" + + "github.com/GoCodeAlone/workflow-plugin-digitalocean/internal/drivers" + "github.com/GoCodeAlone/workflow/interfaces" + "github.com/digitalocean/godo" +) + +// buildSpecViaCreate is a test helper that invokes Create on a mock client and +// returns the AppSpec that was sent in the create request. +func buildSpecViaCreate(t *testing.T, cfg map[string]any) *godo.AppSpec { + t.Helper() + mock := &mockAppClient{app: testApp()} + d := drivers.NewAppPlatformDriverWithClient(mock, "nyc3") + _, err := d.Create(t.Context(), interfaces.ResourceSpec{ + Name: "test-app", + Config: cfg, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + if mock.lastCreateReq == nil { + t.Fatal("no create request captured") + } + return mock.lastCreateReq.Spec +} + +// buildSpecViaUpdate is a test helper that invokes Update and returns the AppSpec used. +func buildSpecViaUpdate(t *testing.T, cfg map[string]any) *godo.AppSpec { + t.Helper() + mock := &mockAppClient{app: testApp()} + d := drivers.NewAppPlatformDriverWithClient(mock, "nyc3") + _, err := d.Update(t.Context(), interfaces.ResourceRef{Name: "test-app", ProviderID: "app-1"}, + interfaces.ResourceSpec{Name: "test-app", Config: cfg}) + if err != nil { + t.Fatalf("Update: %v", err) + } + if mock.lastUpdateReq == nil { + t.Fatal("no update request captured") + } + return mock.lastUpdateReq.Spec +} + +// ── Representative canonical config (buildAppSpec builder coverage) ────────── + +// TestBuildAppSpec_RepresentativeCanonicalConfig verifies that buildAppSpec accepts +// all currently-supported canonical keys without error and wires them to the correct +// AppSpec fields. The DOProvider.SupportedCanonicalKeys() key-list assertion is in +// internal/provider_test.go#TestDOProvider_SupportedCanonicalKeys. +func TestBuildAppSpec_RepresentativeCanonicalConfig(t *testing.T) { + cfg := map[string]any{ + "image": "registry.digitalocean.com/myrepo/myapp:v1", + "http_port": 8080, + "instance_count": 1, + "size": "m", + "env_vars": map[string]any{"FOO": "bar"}, + "autoscaling": map[string]any{"min": 1, "max": 3}, + "routes": []any{map[string]any{"path": "/"}}, + "health_check": map[string]any{"http_path": "/healthz"}, + "domains": []any{map[string]any{"domain": "x.example.com"}}, + "jobs": []any{map[string]any{"name": "j", "kind": "pre_deploy", "run_command": "/j"}}, + "workers": []any{map[string]any{"name": "w", "run_command": "/w"}}, + "static_sites": []any{map[string]any{"name": "s", "build_command": "npm run build", "output_dir": "dist"}}, + // "sidecars" is excluded from SupportedCanonicalKeys (doUnsupportedCanonicalKeys) until Task 37 lands. + "provider_specific": map[string]any{"digitalocean": map[string]any{"features": []any{"buildpack-stack=ubuntu-22"}}}, + } + spec := buildSpecViaCreate(t, cfg) + if spec == nil { + t.Fatal("expected non-nil AppSpec") + } + if len(spec.Services) != 1 { + t.Errorf("expected 1 service, got %d", len(spec.Services)) + } + if len(spec.Jobs) != 1 { + t.Errorf("expected 1 job, got %d", len(spec.Jobs)) + } + if len(spec.Workers) != 1 { + t.Errorf("expected 1 worker, got %d", len(spec.Workers)) + } + if len(spec.StaticSites) != 1 { + t.Errorf("expected 1 static site, got %d", len(spec.StaticSites)) + } + if len(spec.Domains) != 1 { + t.Errorf("expected 1 domain, got %d", len(spec.Domains)) + } +} + +// ── instanceSizeSlug ──────────────────────────────────────────────────────── + +func TestBuildAppSpec_InstanceSizeSlug_AbstractSize(t *testing.T) { + cases := []struct { + size string + want string + }{ + {"xs", "apps-s-1vcpu-0.5gb"}, + {"s", "apps-s-1vcpu-1gb"}, + {"m", "apps-s-2vcpu-4gb"}, + {"l", "apps-s-4vcpu-8gb"}, + {"xl", "apps-s-8vcpu-16gb"}, + {"", ""}, + } + for _, tc := range cases { + t.Run("size_"+tc.size, func(t *testing.T) { + cfg := map[string]any{ + "image": "registry.digitalocean.com/myrepo/myapp:v1", + "size": tc.size, + } + spec := buildSpecViaCreate(t, cfg) + got := spec.Services[0].InstanceSizeSlug + if got != tc.want { + t.Errorf("InstanceSizeSlug = %q, want %q", got, tc.want) + } + }) + } +} + +func TestBuildAppSpec_InstanceSizeSlug_ProviderSpecificOverride(t *testing.T) { + cfg := map[string]any{ + "image": "registry.digitalocean.com/myrepo/myapp:v1", + "size": "m", // would normally resolve to apps-s-2vcpu-4gb + "provider_specific": map[string]any{ + "digitalocean": map[string]any{ + "instance_size_slug": "apps-d-8vcpu-32gb", // override + }, + }, + } + spec := buildSpecViaCreate(t, cfg) + got := spec.Services[0].InstanceSizeSlug + if got != "apps-d-8vcpu-32gb" { + t.Errorf("InstanceSizeSlug = %q, want provider-specific override %q", got, "apps-d-8vcpu-32gb") + } +} + +// ── Protocol ───────────────────────────────────────────────────────────────── + +func TestBuildAppSpec_Protocol_HTTP2(t *testing.T) { + cfg := map[string]any{ + "image": "registry.digitalocean.com/myrepo/myapp:v1", + "protocol": "HTTP2", + } + spec := buildSpecViaCreate(t, cfg) + if spec.Services[0].Protocol != godo.SERVINGPROTOCOL_HTTP2 { + t.Errorf("Protocol = %q, want HTTP2", spec.Services[0].Protocol) + } +} + +func TestBuildAppSpec_Protocol_Default(t *testing.T) { + cfg := map[string]any{"image": "registry.digitalocean.com/myrepo/myapp:v1"} + spec := buildSpecViaCreate(t, cfg) + // Default is empty (DO uses HTTP internally). + if spec.Services[0].Protocol != "" { + t.Errorf("Protocol = %q, want empty (default HTTP)", spec.Services[0].Protocol) + } +} + +// ── BuildCommand / RunCommand / DockerfilePath / SourceDir ─────────────────── + +func TestBuildAppSpec_SourceFields(t *testing.T) { + cfg := map[string]any{ + "image": "registry.digitalocean.com/myrepo/myapp:v1", + "build_command": "go build -o /app/server .", + "run_command": "/app/server -config /config/app.yaml", + "dockerfile_path": "Dockerfile.prebuilt", + "source_dir": "./cmd/server", + } + spec := buildSpecViaCreate(t, cfg) + svc := spec.Services[0] + if svc.BuildCommand != "go build -o /app/server ." { + t.Errorf("BuildCommand = %q", svc.BuildCommand) + } + if svc.RunCommand != "/app/server -config /config/app.yaml" { + t.Errorf("RunCommand = %q", svc.RunCommand) + } + if svc.DockerfilePath != "Dockerfile.prebuilt" { + t.Errorf("DockerfilePath = %q", svc.DockerfilePath) + } + if svc.SourceDir != "./cmd/server" { + t.Errorf("SourceDir = %q", svc.SourceDir) + } +} + +// ── InternalPorts ──────────────────────────────────────────────────────────── + +func TestBuildAppSpec_InternalPorts(t *testing.T) { + cfg := map[string]any{ + "image": "registry.digitalocean.com/myrepo/myapp:v1", + "internal_ports": []any{9090, 9091}, + } + spec := buildSpecViaCreate(t, cfg) + ports := spec.Services[0].InternalPorts + if len(ports) != 2 { + t.Fatalf("expected 2 internal ports, got %d", len(ports)) + } + if ports[0] != 9090 || ports[1] != 9091 { + t.Errorf("InternalPorts = %v, want [9090, 9091]", ports) + } +} + +// ── Routes ─────────────────────────────────────────────────────────────────── + +func TestBuildAppSpec_Routes(t *testing.T) { + cfg := map[string]any{ + "image": "registry.digitalocean.com/myrepo/myapp:v1", + "routes": []any{ + map[string]any{"path": "/api", "preserve_path_prefix": true}, + map[string]any{"path": "/health"}, + }, + } + spec := buildSpecViaCreate(t, cfg) + routes := spec.Services[0].Routes + if len(routes) != 2 { + t.Fatalf("expected 2 routes, got %d", len(routes)) + } + if routes[0].Path != "/api" || !routes[0].PreservePathPrefix { + t.Errorf("route[0] = %+v, want path=/api preserve=true", routes[0]) + } + if routes[1].Path != "/health" { + t.Errorf("route[1].Path = %q, want /health", routes[1].Path) + } +} + +// ── HealthCheck ────────────────────────────────────────────────────────────── + +func TestBuildAppSpec_HealthCheck(t *testing.T) { + cfg := map[string]any{ + "image": "registry.digitalocean.com/myrepo/myapp:v1", + "health_check": map[string]any{ + "http_path": "/healthz", + "initial_delay_seconds": 5, + "period_seconds": 10, + "timeout_seconds": 3, + "success_threshold": 1, + "failure_threshold": 3, + "port": 8080, + }, + } + spec := buildSpecViaCreate(t, cfg) + hc := spec.Services[0].HealthCheck + if hc == nil { + t.Fatal("HealthCheck is nil") + } + if hc.HTTPPath != "/healthz" { + t.Errorf("HTTPPath = %q, want /healthz", hc.HTTPPath) + } + if hc.InitialDelaySeconds != 5 { + t.Errorf("InitialDelaySeconds = %d, want 5", hc.InitialDelaySeconds) + } + if hc.PeriodSeconds != 10 { + t.Errorf("PeriodSeconds = %d, want 10", hc.PeriodSeconds) + } + if hc.FailureThreshold != 3 { + t.Errorf("FailureThreshold = %d, want 3", hc.FailureThreshold) + } +} + +func TestBuildAppSpec_HealthCheck_Empty(t *testing.T) { + cfg := map[string]any{"image": "registry.digitalocean.com/myrepo/myapp:v1"} + spec := buildSpecViaCreate(t, cfg) + if spec.Services[0].HealthCheck != nil { + t.Error("expected nil HealthCheck when not specified") + } +} + +// ── LivenessHealthCheck ────────────────────────────────────────────────────── + +func TestBuildAppSpec_LivenessHealthCheck(t *testing.T) { + cfg := map[string]any{ + "image": "registry.digitalocean.com/myrepo/myapp:v1", + "liveness_check": map[string]any{ + "http_path": "/livez", + "period_seconds": 15, + }, + } + spec := buildSpecViaCreate(t, cfg) + lhc := spec.Services[0].LivenessHealthCheck + if lhc == nil { + t.Fatal("LivenessHealthCheck is nil") + } + if lhc.HTTPPath != "/livez" { + t.Errorf("HTTPPath = %q, want /livez", lhc.HTTPPath) + } + if lhc.PeriodSeconds != 15 { + t.Errorf("PeriodSeconds = %d, want 15", lhc.PeriodSeconds) + } +} + +// ── Autoscaling ────────────────────────────────────────────────────────────── + +func TestBuildAppSpec_Autoscaling(t *testing.T) { + cfg := map[string]any{ + "image": "registry.digitalocean.com/myrepo/myapp:v1", + "autoscaling": map[string]any{ + "min": 2, + "max": 10, + "cpu_percent": 70, + }, + } + spec := buildSpecViaCreate(t, cfg) + as := spec.Services[0].Autoscaling + if as == nil { + t.Fatal("Autoscaling is nil") + } + if as.MinInstanceCount != 2 { + t.Errorf("MinInstanceCount = %d, want 2", as.MinInstanceCount) + } + if as.MaxInstanceCount != 10 { + t.Errorf("MaxInstanceCount = %d, want 10", as.MaxInstanceCount) + } + if as.Metrics == nil || as.Metrics.CPU == nil { + t.Fatal("Metrics.CPU is nil") + } + if as.Metrics.CPU.Percent != 70 { + t.Errorf("CPU.Percent = %d, want 70", as.Metrics.CPU.Percent) + } +} + +func TestBuildAppSpec_Autoscaling_Empty(t *testing.T) { + cfg := map[string]any{"image": "registry.digitalocean.com/myrepo/myapp:v1"} + spec := buildSpecViaCreate(t, cfg) + if spec.Services[0].Autoscaling != nil { + t.Error("expected nil Autoscaling when not specified") + } +} + +// ── CORS ───────────────────────────────────────────────────────────────────── + +func TestBuildAppSpec_CORS(t *testing.T) { + cfg := map[string]any{ + "image": "registry.digitalocean.com/myrepo/myapp:v1", + "cors": map[string]any{ + "allow_origins": []any{"https://example.com"}, + "allow_methods": []any{"GET", "POST", "OPTIONS"}, + "allow_headers": []any{"Content-Type", "Authorization"}, + "allow_credentials": true, + "max_age": "24h", + }, + } + spec := buildSpecViaCreate(t, cfg) + cors := spec.Services[0].CORS + if cors == nil { + t.Fatal("CORS is nil") + } + if len(cors.AllowOrigins) != 1 || cors.AllowOrigins[0].Exact != "https://example.com" { + t.Errorf("AllowOrigins = %+v, want [{Exact: https://example.com}]", cors.AllowOrigins) + } + if len(cors.AllowMethods) != 3 { + t.Errorf("AllowMethods count = %d, want 3", len(cors.AllowMethods)) + } + if !cors.AllowCredentials { + t.Error("AllowCredentials should be true") + } + if cors.MaxAge != "24h" { + t.Errorf("MaxAge = %q, want 24h", cors.MaxAge) + } +} + +// ── Termination ────────────────────────────────────────────────────────────── + +func TestBuildAppSpec_Termination(t *testing.T) { + cfg := map[string]any{ + "image": "registry.digitalocean.com/myrepo/myapp:v1", + "termination": map[string]any{ + "drain_seconds": 30, + "grace_period_seconds": 60, + }, + } + spec := buildSpecViaCreate(t, cfg) + term := spec.Services[0].Termination + if term == nil { + t.Fatal("Termination is nil") + } + if term.DrainSeconds != 30 { + t.Errorf("DrainSeconds = %d, want 30", term.DrainSeconds) + } + if term.GracePeriodSeconds != 60 { + t.Errorf("GracePeriodSeconds = %d, want 60", term.GracePeriodSeconds) + } +} + +// ── Alerts — numeric value coercion ───────────────────────────────────────── + +func TestBuildAppSpec_Alerts_IntValue(t *testing.T) { + // YAML decodes whole numbers as int; ensure alertSpecFromMap handles it. + cfg := map[string]any{ + "image": "registry.digitalocean.com/myrepo/myapp:v1", + "alerts": []any{ + map[string]any{ + "rule": "CPU_UTILIZATION", + "operator": "GREATER_THAN", + "window": "FIVE_MINUTES", + "value": 80, // int, not float64 + }, + }, + } + spec := buildSpecViaCreate(t, cfg) + if len(spec.Services[0].Alerts) != 1 { + t.Fatalf("expected 1 alert, got %d", len(spec.Services[0].Alerts)) + } + a := spec.Services[0].Alerts[0] + if a.Value != 80.0 { + t.Errorf("Alert.Value = %v, want 80.0 (coerced from int)", a.Value) + } +} + +func TestBuildAppSpec_Alerts_Float64Value(t *testing.T) { + cfg := map[string]any{ + "image": "registry.digitalocean.com/myrepo/myapp:v1", + "alerts": []any{ + map[string]any{ + "rule": "MEM_UTILIZATION", + "value": 75.5, // float64 + }, + }, + } + spec := buildSpecViaCreate(t, cfg) + if len(spec.Services[0].Alerts) != 1 { + t.Fatalf("expected 1 alert, got %d", len(spec.Services[0].Alerts)) + } + if spec.Services[0].Alerts[0].Value != 75.5 { + t.Errorf("Alert.Value = %v, want 75.5", spec.Services[0].Alerts[0].Value) + } +} + +// ── Domains ────────────────────────────────────────────────────────────────── + +func TestBuildAppSpec_Domains(t *testing.T) { + cfg := map[string]any{ + "image": "registry.digitalocean.com/myrepo/myapp:v1", + "domains": []any{ + map[string]any{ + "domain": "app.example.com", + "type": "PRIMARY", + "zone": "example.com", + }, + map[string]any{ + "domain": "alias.example.com", + "type": "ALIAS", + "wildcard": true, + }, + }, + } + spec := buildSpecViaCreate(t, cfg) + domains := spec.Domains + if len(domains) != 2 { + t.Fatalf("expected 2 domains, got %d", len(domains)) + } + if domains[0].Domain != "app.example.com" { + t.Errorf("domain[0].Domain = %q, want app.example.com", domains[0].Domain) + } + if domains[0].Type != godo.AppDomainSpecType_Primary { + t.Errorf("domain[0].Type = %q, want PRIMARY", domains[0].Type) + } + if domains[0].Zone != "example.com" { + t.Errorf("domain[0].Zone = %q, want example.com", domains[0].Zone) + } + if !domains[1].Wildcard { + t.Error("domain[1].Wildcard should be true") + } +} + +// ── Egress ─────────────────────────────────────────────────────────────────── + +func TestBuildAppSpec_Egress(t *testing.T) { + cfg := map[string]any{ + "image": "registry.digitalocean.com/myrepo/myapp:v1", + "egress": map[string]any{"type": "DEDICATED_IP"}, + } + spec := buildSpecViaCreate(t, cfg) + if spec.Egress == nil { + t.Fatal("Egress is nil") + } + if spec.Egress.Type != godo.APPEGRESSSPECTYPE_DedicatedIp { + t.Errorf("Egress.Type = %q, want DEDICATED_IP", spec.Egress.Type) + } +} + +// ── Maintenance ────────────────────────────────────────────────────────────── + +func TestBuildAppSpec_Maintenance(t *testing.T) { + cfg := map[string]any{ + "image": "registry.digitalocean.com/myrepo/myapp:v1", + "maintenance": map[string]any{ + "enabled": true, + "offline_page_url": "https://example.com/maintenance", + }, + } + spec := buildSpecViaCreate(t, cfg) + if spec.Maintenance == nil { + t.Fatal("Maintenance is nil") + } + if !spec.Maintenance.Enabled { + t.Error("Maintenance.Enabled should be true") + } + if spec.Maintenance.OfflinePageURL != "https://example.com/maintenance" { + t.Errorf("OfflinePageURL = %q", spec.Maintenance.OfflinePageURL) + } +} + +// ── VPC ────────────────────────────────────────────────────────────────────── + +func TestBuildAppSpec_VPC(t *testing.T) { + cfg := map[string]any{ + "image": "registry.digitalocean.com/myrepo/myapp:v1", + "vpc_ref": "vpc-abc123", + } + spec := buildSpecViaCreate(t, cfg) + if spec.Vpc == nil { + t.Fatal("Vpc is nil") + } + if spec.Vpc.ID != "vpc-abc123" { + t.Errorf("Vpc.ID = %q, want vpc-abc123", spec.Vpc.ID) + } +} + +// ── ProviderSpecific features ───────────────────────────────────────────────── + +func TestBuildAppSpec_ProviderSpecific_Features(t *testing.T) { + cfg := map[string]any{ + "image": "registry.digitalocean.com/myrepo/myapp:v1", + "provider_specific": map[string]any{ + "digitalocean": map[string]any{ + "features": []any{"buildpack-stack=ubuntu-22"}, + "disable_edge_cache": true, + "disable_email_obfuscation": true, + "enhanced_threat_control_enabled": false, + }, + }, + } + spec := buildSpecViaCreate(t, cfg) + if len(spec.Features) != 1 || spec.Features[0] != "buildpack-stack=ubuntu-22" { + t.Errorf("Features = %v, want [buildpack-stack=ubuntu-22]", spec.Features) + } + if !spec.DisableEdgeCache { + t.Error("DisableEdgeCache should be true") + } + if !spec.DisableEmailObfuscation { + t.Error("DisableEmailObfuscation should be true") + } +} + +// ── Jobs (PRE_DEPLOY / POST_DEPLOY / SCHEDULED) ───────────────────────────── + +func TestBuildAppSpec_Jobs_PreDeploy(t *testing.T) { + cfg := map[string]any{ + "image": "registry.digitalocean.com/myrepo/myapp:v1", + "jobs": []any{ + map[string]any{ + "name": "migrate", + "kind": "pre_deploy", + "image": "registry.digitalocean.com/bmw/workflow-migrate:v1", + "run_command": "/workflow-migrate up", + "env_vars_secret": map[string]any{ + "DATABASE_URL": "staging-db-url", + }, + }, + }, + } + spec := buildSpecViaCreate(t, cfg) + if len(spec.Jobs) != 1 { + t.Fatalf("expected 1 job, got %d", len(spec.Jobs)) + } + job := spec.Jobs[0] + if job.Name != "migrate" { + t.Errorf("Name = %q, want migrate", job.Name) + } + if job.Kind != godo.AppJobSpecKind_PreDeploy { + t.Errorf("Kind = %q, want PRE_DEPLOY", job.Kind) + } + if job.RunCommand != "/workflow-migrate up" { + t.Errorf("RunCommand = %q", job.RunCommand) + } + if job.Image == nil || job.Image.Repository != "workflow-migrate" { + t.Errorf("Image.Repository = %q, want workflow-migrate", func() string { + if job.Image == nil { + return "" + } + return job.Image.Repository + }()) + } + // Secret env var. + found := false + for _, e := range job.Envs { + if e.Key == "DATABASE_URL" && e.Type == godo.AppVariableType_Secret { + found = true + } + } + if !found { + t.Error("expected DATABASE_URL secret env var in job") + } +} + +func TestBuildAppSpec_Jobs_PostDeploy(t *testing.T) { + cfg := map[string]any{ + "image": "registry.digitalocean.com/myrepo/myapp:v1", + "jobs": []any{ + map[string]any{ + "name": "smoke-test", + "kind": "post_deploy", + "run_command": "/app/smoke-test", + }, + }, + } + spec := buildSpecViaCreate(t, cfg) + if spec.Jobs[0].Kind != godo.AppJobSpecKind_PostDeploy { + t.Errorf("Kind = %q, want POST_DEPLOY", spec.Jobs[0].Kind) + } +} + +func TestBuildAppSpec_Jobs_Scheduled(t *testing.T) { + cfg := map[string]any{ + "image": "registry.digitalocean.com/myrepo/myapp:v1", + "jobs": []any{ + map[string]any{ + "name": "nightly-report", + "kind": "scheduled", + "cron": "0 2 * * *", + "run_command": "/app/report", + }, + }, + } + spec := buildSpecViaCreate(t, cfg) + job := spec.Jobs[0] + if job.Kind != godo.AppJobSpecKind_Scheduled { + t.Errorf("Kind = %q, want SCHEDULED", job.Kind) + } + if job.Schedule == nil || job.Schedule.Cron != "0 2 * * *" { + t.Errorf("Schedule.Cron = %q, want '0 2 * * *'", func() string { + if job.Schedule == nil { + return "" + } + return job.Schedule.Cron + }()) + } +} + +func TestBuildAppSpec_Jobs_FailedDeploy(t *testing.T) { + cfg := map[string]any{ + "image": "registry.digitalocean.com/myrepo/myapp:v1", + "jobs": []any{ + map[string]any{ + "name": "rollback", + "kind": "failed_deploy", + "run_command": "/app/rollback", + }, + }, + } + spec := buildSpecViaCreate(t, cfg) + if spec.Jobs[0].Kind != godo.AppJobSpecKind_FailedDeploy { + t.Errorf("Kind = %q, want FAILED_DEPLOY", spec.Jobs[0].Kind) + } +} + +func TestBuildAppSpec_Jobs_Termination(t *testing.T) { + cfg := map[string]any{ + "image": "registry.digitalocean.com/myrepo/myapp:v1", + "jobs": []any{ + map[string]any{ + "name": "cleanup", + "kind": "post_deploy", + "run_command": "/app/cleanup", + "termination": map[string]any{"grace_period_seconds": 120}, + }, + }, + } + spec := buildSpecViaCreate(t, cfg) + term := spec.Jobs[0].Termination + if term == nil { + t.Fatal("Job.Termination is nil") + } + if term.GracePeriodSeconds != 120 { + t.Errorf("GracePeriodSeconds = %d, want 120", term.GracePeriodSeconds) + } +} + +// ── Workers ────────────────────────────────────────────────────────────────── + +func TestBuildAppSpec_Workers(t *testing.T) { + cfg := map[string]any{ + "image": "registry.digitalocean.com/myrepo/myapp:v1", + "workers": []any{ + map[string]any{ + "name": "queue-processor", + "run_command": "/app/worker", + "instance_count": 3, + "size": "m", + "env_vars": map[string]any{ + "QUEUE_URL": "amqp://localhost", + }, + }, + }, + } + spec := buildSpecViaCreate(t, cfg) + if len(spec.Workers) != 1 { + t.Fatalf("expected 1 worker, got %d", len(spec.Workers)) + } + w := spec.Workers[0] + if w.Name != "queue-processor" { + t.Errorf("Name = %q, want queue-processor", w.Name) + } + if w.RunCommand != "/app/worker" { + t.Errorf("RunCommand = %q", w.RunCommand) + } + if w.InstanceCount != 3 { + t.Errorf("InstanceCount = %d, want 3", w.InstanceCount) + } + if w.InstanceSizeSlug != "apps-s-2vcpu-4gb" { + t.Errorf("InstanceSizeSlug = %q, want apps-s-2vcpu-4gb (m)", w.InstanceSizeSlug) + } + found := false + for _, e := range w.Envs { + if e.Key == "QUEUE_URL" { + found = true + } + } + if !found { + t.Error("expected QUEUE_URL env var in worker") + } +} + +func TestBuildAppSpec_Workers_Autoscaling(t *testing.T) { + cfg := map[string]any{ + "image": "registry.digitalocean.com/myrepo/myapp:v1", + "workers": []any{ + map[string]any{ + "name": "autoscaled-worker", + "run_command": "/app/worker", + "autoscaling": map[string]any{ + "min": 1, + "max": 5, + }, + }, + }, + } + spec := buildSpecViaCreate(t, cfg) + w := spec.Workers[0] + if w.Autoscaling == nil { + t.Fatal("Worker.Autoscaling is nil") + } + if w.Autoscaling.MinInstanceCount != 1 || w.Autoscaling.MaxInstanceCount != 5 { + t.Errorf("Autoscaling = min=%d max=%d, want min=1 max=5", + w.Autoscaling.MinInstanceCount, w.Autoscaling.MaxInstanceCount) + } +} + +func TestBuildAppSpec_Workers_Termination(t *testing.T) { + cfg := map[string]any{ + "image": "registry.digitalocean.com/myrepo/myapp:v1", + "workers": []any{ + map[string]any{ + "name": "drain-worker", + "run_command": "/app/worker", + "termination": map[string]any{"grace_period_seconds": 90}, + }, + }, + } + spec := buildSpecViaCreate(t, cfg) + term := spec.Workers[0].Termination + if term == nil { + t.Fatal("Worker.Termination is nil") + } + if term.GracePeriodSeconds != 90 { + t.Errorf("GracePeriodSeconds = %d, want 90", term.GracePeriodSeconds) + } +} + +// ── StaticSites ────────────────────────────────────────────────────────────── + +func TestBuildAppSpec_StaticSites(t *testing.T) { + cfg := map[string]any{ + "image": "registry.digitalocean.com/myrepo/myapp:v1", + "static_sites": []any{ + map[string]any{ + "name": "frontend", + "build_command": "npm run build", + "output_dir": "dist", + "routes": []any{map[string]any{"path": "/"}}, + "env_vars": map[string]any{ + "VITE_API_BASE": "https://api.example.com", + }, + }, + }, + } + spec := buildSpecViaCreate(t, cfg) + if len(spec.StaticSites) != 1 { + t.Fatalf("expected 1 static site, got %d", len(spec.StaticSites)) + } + ss := spec.StaticSites[0] + if ss.Name != "frontend" { + t.Errorf("Name = %q, want frontend", ss.Name) + } + if ss.BuildCommand != "npm run build" { + t.Errorf("BuildCommand = %q", ss.BuildCommand) + } + if ss.OutputDir != "dist" { + t.Errorf("OutputDir = %q", ss.OutputDir) + } + if len(ss.Routes) != 1 || ss.Routes[0].Path != "/" { + t.Errorf("Routes = %+v", ss.Routes) + } + found := false + for _, e := range ss.Envs { + if e.Key == "VITE_API_BASE" { + found = true + } + } + if !found { + t.Error("expected VITE_API_BASE env var in static site") + } +} + +func TestBuildAppSpec_StaticSites_FallbackDoc(t *testing.T) { + cfg := map[string]any{ + "image": "registry.digitalocean.com/myrepo/myapp:v1", + "static_sites": []any{ + map[string]any{ + "name": "spa", + "build_command": "npm run build", + "output_dir": "dist", + "catchall_document": "index.html", + "error_document": "404.html", + }, + }, + } + spec := buildSpecViaCreate(t, cfg) + ss := spec.StaticSites[0] + if ss.CatchallDocument != "index.html" { + t.Errorf("CatchallDocument = %q, want index.html", ss.CatchallDocument) + } + if ss.ErrorDocument != "404.html" { + t.Errorf("ErrorDocument = %q, want 404.html", ss.ErrorDocument) + } +} + +// ── Ingress ────────────────────────────────────────────────────────────────── + +func TestBuildAppSpec_Ingress_LoadBalancer(t *testing.T) { + cfg := map[string]any{ + "image": "registry.digitalocean.com/myrepo/myapp:v1", + "ingress": map[string]any{"load_balancer": "DIGITALOCEAN"}, + } + spec := buildSpecViaCreate(t, cfg) + if spec.Ingress == nil { + t.Fatal("Ingress is nil") + } + if spec.Ingress.LoadBalancer != godo.AppIngressSpecLoadBalancer_DigitalOcean { + t.Errorf("Ingress.LoadBalancer = %q, want DIGITALOCEAN", spec.Ingress.LoadBalancer) + } +} + +func TestBuildAppSpec_Ingress_EmptyIsNil(t *testing.T) { + // An ingress map with no recognised fields must not produce a non-nil AppIngressSpec. + cfg := map[string]any{ + "image": "registry.digitalocean.com/myrepo/myapp:v1", + "ingress": map[string]any{}, + } + spec := buildSpecViaCreate(t, cfg) + if spec.Ingress != nil { + t.Errorf("expected nil Ingress for empty ingress map, got %+v", spec.Ingress) + } +} + +// ── LogDestinations ────────────────────────────────────────────────────────── + +func TestBuildAppSpec_LogDestinations(t *testing.T) { + cfg := map[string]any{ + "image": "registry.digitalocean.com/myrepo/myapp:v1", + "log_destinations": []any{ + // HTTP endpoint destination with tls_insecure. + map[string]any{ + "name": "http-sink", + "endpoint": "https://logs.example.com/ingest", + "tls_insecure": true, + }, + // Papertrail destination (endpoint field). + map[string]any{ + "name": "pt", + "papertrail": map[string]any{ + "endpoint": "logs.papertrailapp.com:12345", + }, + }, + // Datadog destination (api_key + endpoint). + map[string]any{ + "name": "dd", + "datadog": map[string]any{ + "api_key": "dd-api-key", + "endpoint": "https://http-intake.logs.datadoghq.com", + }, + }, + // Logtail destination (token). + map[string]any{ + "name": "lt", + "logtail": map[string]any{ + "token": "lt-source-token", + }, + }, + }, + } + spec := buildSpecViaCreate(t, cfg) + lds := spec.Services[0].LogDestinations + if len(lds) != 4 { + t.Fatalf("expected 4 log destinations, got %d", len(lds)) + } + + // HTTP endpoint + tls_insecure. + http := lds[0] + if http.Name != "http-sink" { + t.Errorf("ld[0].Name = %q, want http-sink", http.Name) + } + if http.Endpoint != "https://logs.example.com/ingest" { + t.Errorf("ld[0].Endpoint = %q, want https://logs.example.com/ingest", http.Endpoint) + } + if !http.TLSInsecure { + t.Error("ld[0].TLSInsecure should be true") + } + + // Papertrail endpoint. + pt := lds[1] + if pt.Papertrail == nil { + t.Fatal("ld[1].Papertrail is nil") + } + if pt.Papertrail.Endpoint != "logs.papertrailapp.com:12345" { + t.Errorf("ld[1].Papertrail.Endpoint = %q", pt.Papertrail.Endpoint) + } + + // Datadog api_key + endpoint. + dd := lds[2] + if dd.Datadog == nil { + t.Fatal("ld[2].Datadog is nil") + } + if dd.Datadog.ApiKey != "dd-api-key" { + t.Errorf("ld[2].Datadog.ApiKey = %q", dd.Datadog.ApiKey) + } + if dd.Datadog.Endpoint != "https://http-intake.logs.datadoghq.com" { + t.Errorf("ld[2].Datadog.Endpoint = %q", dd.Datadog.Endpoint) + } + + // Logtail token. + lt := lds[3] + if lt.Logtail == nil { + t.Fatal("ld[3].Logtail is nil") + } + if lt.Logtail.Token != "lt-source-token" { + t.Errorf("ld[3].Logtail.Token = %q", lt.Logtail.Token) + } +} + +// ── Update propagates buildAppSpec ────────────────────────────────────────── + +func TestBuildAppSpec_UpdateUsesBuildSpec(t *testing.T) { + cfg := map[string]any{ + "image": "registry.digitalocean.com/myrepo/myapp:v2", + "jobs": []any{ + map[string]any{ + "name": "migrate", + "kind": "pre_deploy", + "run_command": "/workflow-migrate up", + }, + }, + } + spec := buildSpecViaUpdate(t, cfg) + if len(spec.Jobs) != 1 || spec.Jobs[0].Kind != godo.AppJobSpecKind_PreDeploy { + t.Errorf("Update did not propagate jobs correctly") + } +} + +// ── BMW pre-deploy scenario ────────────────────────────────────────────────── + +func TestBuildAppSpec_BMWPreDeployScenario(t *testing.T) { + cfg := map[string]any{ + "image": "registry.digitalocean.com/bmw-registry/buymywishlist:abc123", + "http_port": 8080, + "instance_count": 2, + "size": "s", + "env_vars": map[string]any{ + "SESSION_STORE": "pg", + }, + "env_vars_secret": map[string]any{ + "DATABASE_URL": "staging-db-url", + }, + "health_check": map[string]any{ + "http_path": "/healthz", + "initial_delay_seconds": 10, + }, + "domains": []any{ + map[string]any{"domain": "bmw.example.com", "type": "PRIMARY"}, + }, + "jobs": []any{ + map[string]any{ + "name": "migrate", + "kind": "pre_deploy", + "image": "registry.digitalocean.com/bmw-registry/workflow-migrate:v1", + "run_command": "/workflow-migrate up", + "env_vars_secret": map[string]any{ + "DATABASE_URL": "staging-db-url", + }, + }, + map[string]any{ + "name": "tenant-ensure", + "kind": "pre_deploy", + "image": "registry.digitalocean.com/bmw-registry/workflow-migrate:v1", + "run_command": "/workflow-migrate tenant-ensure", + "env_vars_secret": map[string]any{ + "DATABASE_URL": "staging-db-url", + "BMW_TENANT_SLUG": "bmw", + }, + }, + }, + } + + spec := buildSpecViaCreate(t, cfg) + + // Service checks. + if len(spec.Services) != 1 { + t.Fatalf("expected 1 service, got %d", len(spec.Services)) + } + svc := spec.Services[0] + if svc.InstanceCount != 2 { + t.Errorf("InstanceCount = %d, want 2", svc.InstanceCount) + } + if svc.InstanceSizeSlug != "apps-s-1vcpu-1gb" { + t.Errorf("InstanceSizeSlug = %q, want apps-s-1vcpu-1gb (s)", svc.InstanceSizeSlug) + } + if svc.HealthCheck == nil || svc.HealthCheck.HTTPPath != "/healthz" { + t.Error("HealthCheck not set correctly") + } + + // Jobs. + if len(spec.Jobs) != 2 { + t.Fatalf("expected 2 jobs, got %d", len(spec.Jobs)) + } + jobNames := map[string]bool{} + for _, j := range spec.Jobs { + jobNames[j.Name] = true + if j.Kind != godo.AppJobSpecKind_PreDeploy { + t.Errorf("job %q: Kind = %q, want PRE_DEPLOY", j.Name, j.Kind) + } + } + if !jobNames["migrate"] || !jobNames["tenant-ensure"] { + t.Errorf("job names = %v, want [migrate, tenant-ensure]", jobNames) + } + + // Domains. + if len(spec.Domains) != 1 || spec.Domains[0].Domain != "bmw.example.com" { + t.Errorf("Domains = %+v", spec.Domains) + } +} diff --git a/internal/module_instance_test.go b/internal/module_instance_test.go index db24786..307430b 100644 --- a/internal/module_instance_test.go +++ b/internal/module_instance_test.go @@ -795,3 +795,4 @@ func (f *fakeIaCProvider) ResolveSizing(_ string, _ interfaces.Size, hints *inte f.resolveSizingHints = hints return f.sizingResult, nil } +func (f *fakeIaCProvider) SupportedCanonicalKeys() []string { return interfaces.CanonicalKeys() } diff --git a/internal/provider.go b/internal/provider.go index 852df85..08bf03d 100644 --- a/internal/provider.go +++ b/internal/provider.go @@ -8,8 +8,8 @@ import ( "sort" "time" - "github.com/GoCodeAlone/workflow/interfaces" "github.com/GoCodeAlone/workflow-plugin-digitalocean/internal/drivers" + "github.com/GoCodeAlone/workflow/interfaces" "github.com/digitalocean/godo" "golang.org/x/oauth2" ) @@ -108,6 +108,27 @@ func (p *DOProvider) ResourceDriver(resourceType string) (interfaces.ResourceDri return d, nil } +// doUnsupportedCanonicalKeys lists canonical keys that the DO plugin does not yet map. +// Each entry is removed from SupportedCanonicalKeys() so wfctl validate can warn callers. +// "sidecars" is implemented in v0.7.0 Task 37 (feat/v0.7.0-sidecars). +var doUnsupportedCanonicalKeys = map[string]struct{}{ + interfaces.KeySidecars: {}, +} + +// SupportedCanonicalKeys returns the canonical IaC config keys that this DO provider +// currently maps. Keys in doUnsupportedCanonicalKeys are excluded until their Task +// implementation lands (see comments there). +func (p *DOProvider) SupportedCanonicalKeys() []string { + all := interfaces.CanonicalKeys() + out := make([]string, 0, len(all)) + for _, k := range all { + if _, unsupported := doUnsupportedCanonicalKeys[k]; !unsupported { + out = append(out, k) + } + } + return out +} + // ResolveSizing maps abstract size tiers to DigitalOcean SKUs. func (p *DOProvider) ResolveSizing(resourceType string, size interfaces.Size, hints *interfaces.ResourceHints) (*interfaces.ProviderSizing, error) { return resolveSizing(resourceType, size, hints) diff --git a/internal/provider_test.go b/internal/provider_test.go index 98e6249..08d41f0 100644 --- a/internal/provider_test.go +++ b/internal/provider_test.go @@ -95,6 +95,38 @@ func TestDOProvider_ResourceDriver_Unknown(t *testing.T) { } } +func TestDOProvider_SupportedCanonicalKeys(t *testing.T) { + p := NewDOProvider() + keys := p.SupportedCanonicalKeys() + if len(keys) == 0 { + t.Fatal("SupportedCanonicalKeys returned empty slice") + } + keySet := make(map[string]bool, len(keys)) + for _, k := range keys { + keySet[k] = true + } + + // Keys the DO provider actively maps in this release. + supported := []string{ + "name", "region", "image", "http_port", "instance_count", "size", + "env_vars", "env_vars_secret", "autoscaling", "routes", "health_check", + "liveness_check", "cors", "internal_ports", "build_command", "run_command", + "dockerfile_path", "source_dir", "termination", "domains", "alerts", + "log_destinations", "ingress", "egress", "maintenance", "vpc_ref", + "jobs", "workers", "static_sites", "provider_specific", + } + for _, k := range supported { + if !keySet[k] { + t.Errorf("SupportedCanonicalKeys missing expected key %q", k) + } + } + + // "sidecars" is not yet mapped (Task 37); must NOT appear until then. + if keySet["sidecars"] { + t.Error("SupportedCanonicalKeys must not include \"sidecars\" until Task 37 lands") + } +} + func TestConfigHash_Deterministic(t *testing.T) { cfg := map[string]any{"b": 2, "a": 1, "c": "three"} h1 := configHash(cfg)