diff --git a/drivers/autoscaling_group.go b/drivers/autoscaling_group.go new file mode 100644 index 0000000..8e05329 --- /dev/null +++ b/drivers/autoscaling_group.go @@ -0,0 +1,666 @@ +package drivers + +import ( + "context" + "fmt" + "sort" + "strings" + + awssdk "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/applicationautoscaling" + aastypes "github.com/aws/aws-sdk-go-v2/service/applicationautoscaling/types" + + "github.com/GoCodeAlone/workflow/interfaces" +) + +// AutoScalingClient is the subset of Application Auto Scaling API used by AutoScalingGroupDriver. +type AutoScalingClient interface { + RegisterScalableTarget(ctx context.Context, params *applicationautoscaling.RegisterScalableTargetInput, optFns ...func(*applicationautoscaling.Options)) (*applicationautoscaling.RegisterScalableTargetOutput, error) + DescribeScalableTargets(ctx context.Context, params *applicationautoscaling.DescribeScalableTargetsInput, optFns ...func(*applicationautoscaling.Options)) (*applicationautoscaling.DescribeScalableTargetsOutput, error) + DescribeScalingPolicies(ctx context.Context, params *applicationautoscaling.DescribeScalingPoliciesInput, optFns ...func(*applicationautoscaling.Options)) (*applicationautoscaling.DescribeScalingPoliciesOutput, error) + PutScalingPolicy(ctx context.Context, params *applicationautoscaling.PutScalingPolicyInput, optFns ...func(*applicationautoscaling.Options)) (*applicationautoscaling.PutScalingPolicyOutput, error) + DeleteScalingPolicy(ctx context.Context, params *applicationautoscaling.DeleteScalingPolicyInput, optFns ...func(*applicationautoscaling.Options)) (*applicationautoscaling.DeleteScalingPolicyOutput, error) + DeregisterScalableTarget(ctx context.Context, params *applicationautoscaling.DeregisterScalableTargetInput, optFns ...func(*applicationautoscaling.Options)) (*applicationautoscaling.DeregisterScalableTargetOutput, error) +} + +// AutoScalingGroupDriver manages AWS Application Auto Scaling targets (infra.autoscaling_group). +// +// This driver wraps the AWS Application Auto Scaling service, which manages scalable targets +// for services like ECS, DynamoDB, RDS, etc. It is NOT an EC2 Auto Scaling Group driver. +// +// Config keys: +// - service_namespace (string, required) — e.g., "ecs", "dynamodb", "rds" +// - resource_id (string, required) — e.g., "service/cluster/service-name" +// - scalable_dimension (string, required) — e.g., "ecs:service:DesiredCount" +// - min_capacity (int, required) — must be >= 0 +// - max_capacity (int, required) — must be >= min_capacity +// - role_arn (string, optional) +// - policies ([]any, optional) — each map with keys: +// * policy_name (string, required) +// * policy_type (string, required) — "TargetTrackingScaling" or "StepScaling" +// For TargetTrackingScaling: +// * target_value (float64, required) +// * predefined_metric_type (string, required) — e.g., "ECSServiceAverageCPUUtilization" +// * scale_in_cooldown (int, optional, default 300) +// * scale_out_cooldown (int, optional, default 300) +// For StepScaling: +// * adjustment_type (string, required) — e.g., "ChangeInCapacity" +// * step_adjustments ([]any, required) — each map with metric_interval_lower_bound, scaling_adjustment +// * cooldown (int, optional, default 300) +type AutoScalingGroupDriver struct { + noSensitiveKeys + client AutoScalingClient +} + +// NewAutoScalingGroupDriver creates an AutoScalingGroupDriver from an AWS config. +func NewAutoScalingGroupDriver(cfg awssdk.Config) *AutoScalingGroupDriver { + return &AutoScalingGroupDriver{client: applicationautoscaling.NewFromConfig(cfg)} +} + +// NewAutoScalingGroupDriverWithClient creates an AutoScalingGroupDriver with a custom client (for tests). +func NewAutoScalingGroupDriverWithClient(client AutoScalingClient) *AutoScalingGroupDriver { + return &AutoScalingGroupDriver{client: client} +} + +func (d *AutoScalingGroupDriver) ResourceType() string { return "infra.autoscaling_group" } + +// Create registers a new scalable target and applies any declared scaling policies. +func (d *AutoScalingGroupDriver) Create(ctx context.Context, spec interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { + ns, resourceID, dim, err := requiredAutoScalingFields(spec) + if err != nil { + return nil, fmt.Errorf("autoscaling_group: create %q: %w", spec.Name, err) + } + + minCap, maxCap, err := validateCapacity(spec.Config) + if err != nil { + return nil, fmt.Errorf("autoscaling_group: create %q: %w", spec.Name, err) + } + roleARN, _ := spec.Config["role_arn"].(string) + + in := &applicationautoscaling.RegisterScalableTargetInput{ + ServiceNamespace: aastypes.ServiceNamespace(ns), + ResourceId: awssdk.String(resourceID), + ScalableDimension: aastypes.ScalableDimension(dim), + MinCapacity: awssdk.Int32(minCap), + MaxCapacity: awssdk.Int32(maxCap), + } + if roleARN != "" { + in.RoleARN = awssdk.String(roleARN) + } + + out, err := d.client.RegisterScalableTarget(ctx, in) + if err != nil { + return nil, fmt.Errorf("autoscaling_group: create %q: %w", spec.Name, err) + } + + targetARN := awssdk.ToString(out.ScalableTargetARN) + providerID := encodeProviderID(ns, resourceID, dim) + + // Fetch live policies (handles idempotent re-runs) before syncing. + livePolicies, err := d.fetchLivePolicies(ctx, ns, resourceID, dim) + if err != nil { + return nil, fmt.Errorf("autoscaling_group: create %q: fetch policies: %w", spec.Name, err) + } + if err := d.syncPolicies(ctx, spec, ns, resourceID, dim, livePolicies); err != nil { + return nil, fmt.Errorf("autoscaling_group: create %q: apply policies: %w", spec.Name, err) + } + + policyNames := desiredPolicyNames(spec.Config) + return d.buildOutputWithConfig(spec.Name, targetARN, providerID, int(minCap), int(maxCap), policyNames, spec.Config), nil +} + +// Read describes the scalable target and its policies. +// ProviderID is required (format: "namespace|resource_id|scalable_dimension"). +func (d *AutoScalingGroupDriver) Read(ctx context.Context, ref interfaces.ResourceRef) (*interfaces.ResourceOutput, error) { + ns, resourceID, dim := decodeProviderID(ref.ProviderID) + if ns == "" { + return nil, fmt.Errorf("autoscaling_group: read %q: ProviderID is required (format: namespace|resource_id|dimension)", ref.Name) + } + + out, err := d.client.DescribeScalableTargets(ctx, &applicationautoscaling.DescribeScalableTargetsInput{ + ServiceNamespace: aastypes.ServiceNamespace(ns), + ResourceIds: []string{resourceID}, + ScalableDimension: aastypes.ScalableDimension(dim), + }) + if err != nil { + return nil, fmt.Errorf("autoscaling_group: describe %q: %w", ref.Name, err) + } + if len(out.ScalableTargets) == 0 { + return nil, fmt.Errorf("autoscaling_group: scalable target %q not found", ref.Name) + } + target := out.ScalableTargets[0] + + policyNames, err := d.readPolicyNames(ctx, target) + if err != nil { + return nil, fmt.Errorf("autoscaling_group: read %q: list policies: %w", ref.Name, err) + } + minCap := int(awssdk.ToInt32(target.MinCapacity)) + maxCap := int(awssdk.ToInt32(target.MaxCapacity)) + providerID := encodeProviderID(string(target.ServiceNamespace), awssdk.ToString(target.ResourceId), string(target.ScalableDimension)) + targetARN := awssdk.ToString(target.ScalableTargetARN) + + return d.buildOutput(ref.Name, targetARN, providerID, minCap, maxCap, policyNames), nil +} + +// Update re-registers the scalable target (idempotent) and reconciles scaling policies. +// If ref.ProviderID is set, it must match the identity encoded in the spec +// (service_namespace|resource_id|scalable_dimension). A mismatch indicates that the +// identity fields have changed, which would target a different scalable target and +// orphan the previous one; callers should delete and re-create in that case. +func (d *AutoScalingGroupDriver) Update(ctx context.Context, ref interfaces.ResourceRef, spec interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { + ns, resourceID, dim, err := requiredAutoScalingFields(spec) + if err != nil { + return nil, fmt.Errorf("autoscaling_group: update %q: %w", ref.Name, err) + } + + // Validate identity consistency when ProviderID is known. + if ref.ProviderID != "" { + wantProviderID := encodeProviderID(ns, resourceID, dim) + if ref.ProviderID != wantProviderID { + return nil, fmt.Errorf("autoscaling_group: update %q: identity mismatch — ProviderID %q does not match spec identity %q; delete and re-create to change scalable target identity", ref.Name, ref.ProviderID, wantProviderID) + } + } + + minCap, maxCap, err := validateCapacity(spec.Config) + if err != nil { + return nil, fmt.Errorf("autoscaling_group: update %q: %w", ref.Name, err) + } + roleARN, _ := spec.Config["role_arn"].(string) + + in := &applicationautoscaling.RegisterScalableTargetInput{ + ServiceNamespace: aastypes.ServiceNamespace(ns), + ResourceId: awssdk.String(resourceID), + ScalableDimension: aastypes.ScalableDimension(dim), + MinCapacity: awssdk.Int32(minCap), + MaxCapacity: awssdk.Int32(maxCap), + } + if roleARN != "" { + in.RoleARN = awssdk.String(roleARN) + } + + out, err := d.client.RegisterScalableTarget(ctx, in) + if err != nil { + return nil, fmt.Errorf("autoscaling_group: update %q: %w", ref.Name, err) + } + + // Fetch current live policies so we can delete any that are removed from the spec. + livePolicies, err := d.fetchLivePolicies(ctx, ns, resourceID, dim) + if err != nil { + return nil, fmt.Errorf("autoscaling_group: update %q: fetch policies: %w", ref.Name, err) + } + + if err := d.syncPolicies(ctx, spec, ns, resourceID, dim, livePolicies); err != nil { + return nil, fmt.Errorf("autoscaling_group: update %q: sync policies: %w", ref.Name, err) + } + + targetARN := awssdk.ToString(out.ScalableTargetARN) + providerID := encodeProviderID(ns, resourceID, dim) + policyNames := desiredPolicyNames(spec.Config) + return d.buildOutputWithConfig(ref.Name, targetARN, providerID, int(minCap), int(maxCap), policyNames, spec.Config), nil +} + +// Delete removes all scaling policies then deregisters the scalable target. +func (d *AutoScalingGroupDriver) Delete(ctx context.Context, ref interfaces.ResourceRef) error { + ns, resourceID, dim := decodeProviderID(ref.ProviderID) + if ns == "" { + return fmt.Errorf("autoscaling_group: delete %q: ProviderID is required (format: namespace|resource_id|dimension)", ref.Name) + } + + // Delete all existing policies first; errors here block the delete to avoid orphaned targets. + livePolicies, err := d.fetchLivePolicies(ctx, ns, resourceID, dim) + if err != nil { + return fmt.Errorf("autoscaling_group: delete %q: fetch policies: %w", ref.Name, err) + } + for _, p := range livePolicies { + if _, err := d.client.DeleteScalingPolicy(ctx, &applicationautoscaling.DeleteScalingPolicyInput{ + PolicyName: p.PolicyName, + ServiceNamespace: p.ServiceNamespace, + ResourceId: p.ResourceId, + ScalableDimension: p.ScalableDimension, + }); err != nil { + return fmt.Errorf("autoscaling_group: delete policy %q for %q: %w", awssdk.ToString(p.PolicyName), ref.Name, err) + } + } + + if _, err := d.client.DeregisterScalableTarget(ctx, &applicationautoscaling.DeregisterScalableTargetInput{ + ServiceNamespace: aastypes.ServiceNamespace(ns), + ResourceId: awssdk.String(resourceID), + ScalableDimension: aastypes.ScalableDimension(dim), + }); err != nil { + return fmt.Errorf("autoscaling_group: deregister %q: %w", ref.Name, err) + } + return nil +} + +// Diff computes whether the desired spec diverges from the current output. +// Compares capacity bounds, policy names, and a policy-config fingerprint so +// changes to policy parameters (target_value, cooldowns, step_adjustments) are +// also detected. The fingerprint is stored in outputs["policy_fingerprint"] by +// buildOutput-callers that pass it. +func (d *AutoScalingGroupDriver) Diff(_ context.Context, desired interfaces.ResourceSpec, current *interfaces.ResourceOutput) (*interfaces.DiffResult, error) { + if current == nil { + return &interfaces.DiffResult{NeedsUpdate: true}, nil + } + + // Build a deterministic policy-name fingerprint for comparison. + wantPolicies := desiredPolicyNames(desired.Config) + sort.Strings(wantPolicies) + wantPolicyKey := strings.Join(wantPolicies, ",") + + // Extract current policy names from outputs. + var curPolicies []string + if pn, ok := current.Outputs["policy_names"]; ok { + switch v := pn.(type) { + case []string: + curPolicies = v + case []any: + for _, item := range v { + if s, ok := item.(string); ok { + curPolicies = append(curPolicies, s) + } + } + } + } + sort.Strings(curPolicies) + curPolicyKey := strings.Join(curPolicies, ",") + + // Compute the expected ProviderID from the desired spec to detect identity drift. + specNS, _ := desired.Config["service_namespace"].(string) + specResourceID, _ := desired.Config["resource_id"].(string) + specDim, _ := desired.Config["scalable_dimension"].(string) + wantProviderID := encodeProviderID(specNS, specResourceID, specDim) + + // Build a deterministic fingerprint of policy configs (not just names) so changes + // to policy parameters (target_value, cooldowns, step_adjustments) are detected. + wantPolicyFingerprint := policyConfigFingerprint(desired.Config) + + want := map[string]any{ + "min_capacity": intProp(desired.Config, "min_capacity", 0), + "max_capacity": intProp(desired.Config, "max_capacity", 1), + "policy_names": wantPolicyKey, + "policy_fingerprint": wantPolicyFingerprint, + "provider_id": wantProviderID, + } + curFingerprint, _ := current.Outputs["policy_fingerprint"].(string) + currentForDiff := map[string]any{ + "min_capacity": current.Outputs["min_capacity"], + "max_capacity": current.Outputs["max_capacity"], + "policy_names": curPolicyKey, + "policy_fingerprint": curFingerprint, + "provider_id": current.ProviderID, + } + changes := diffOutputs(want, currentForDiff) + return &interfaces.DiffResult{NeedsUpdate: len(changes) > 0, Changes: changes}, nil +} + +// HealthCheck returns healthy if the scalable target is readable. +// ProviderID is required. +func (d *AutoScalingGroupDriver) HealthCheck(ctx context.Context, ref interfaces.ResourceRef) (*interfaces.HealthResult, error) { + _, err := d.Read(ctx, ref) + if err != nil { + return &interfaces.HealthResult{Healthy: false, Message: err.Error()}, nil + } + return &interfaces.HealthResult{Healthy: true, Message: "scalable target registered"}, nil +} + +// Scale updates the max_capacity of the scalable target to the given replica count. +func (d *AutoScalingGroupDriver) Scale(_ context.Context, _ interfaces.ResourceRef, _ int) (*interfaces.ResourceOutput, error) { + return nil, fmt.Errorf("autoscaling_group: use Update with max_capacity to resize") +} + +// ---- helpers ---- + +// validateCapacity extracts min/max_capacity, enforcing presence and min<=max. +func validateCapacity(config map[string]any) (minCap, maxCap int32, err error) { + if _, ok := config["min_capacity"]; !ok { + return 0, 0, fmt.Errorf("min_capacity is required") + } + if _, ok := config["max_capacity"]; !ok { + return 0, 0, fmt.Errorf("max_capacity is required") + } + minI := intProp(config, "min_capacity", -1) + maxI := intProp(config, "max_capacity", -1) + if minI < 0 { + return 0, 0, fmt.Errorf("min_capacity must be a non-negative integer") + } + if maxI < 0 { + return 0, 0, fmt.Errorf("max_capacity must be a non-negative integer") + } + if minI > maxI { + return 0, 0, fmt.Errorf("min_capacity (%d) must not exceed max_capacity (%d)", minI, maxI) + } + return int32(minI), int32(maxI), nil +} + +// requiredAutoScalingFields extracts and validates the three required config fields. +func requiredAutoScalingFields(spec interfaces.ResourceSpec) (ns, resourceID, dim string, err error) { + ns, _ = spec.Config["service_namespace"].(string) + if ns == "" { + return "", "", "", fmt.Errorf("service_namespace is required") + } + resourceID, _ = spec.Config["resource_id"].(string) + if resourceID == "" { + return "", "", "", fmt.Errorf("resource_id is required") + } + dim, _ = spec.Config["scalable_dimension"].(string) + if dim == "" { + return "", "", "", fmt.Errorf("scalable_dimension is required") + } + return ns, resourceID, dim, nil +} + +// encodeProviderID packs the three scalable-target identifiers into a single string. +// Format: "namespace|resource_id|scalable_dimension" +func encodeProviderID(ns, resourceID, dim string) string { + return ns + "|" + resourceID + "|" + dim +} + +// decodeProviderID unpacks a ProviderID produced by encodeProviderID. +func decodeProviderID(providerID string) (ns, resourceID, dim string) { + parts := strings.SplitN(providerID, "|", 3) + if len(parts) != 3 { + return "", "", "" + } + return parts[0], parts[1], parts[2] +} + +// buildOutput constructs a ResourceOutput from a scalable target's fields. +func (d *AutoScalingGroupDriver) buildOutput(name, targetARN, providerID string, minCap, maxCap int, policyNames []string) *interfaces.ResourceOutput { + outputs := map[string]any{ + "min_capacity": minCap, + "max_capacity": maxCap, + } + if targetARN != "" { + outputs["arn"] = targetARN + } + if len(policyNames) > 0 { + outputs["policy_names"] = policyNames + } + return &interfaces.ResourceOutput{ + Name: name, + Type: "infra.autoscaling_group", + ProviderID: providerID, + Outputs: outputs, + Status: "running", + } +} + +// buildOutputWithConfig is like buildOutput but also stores a policy_fingerprint +// derived from the spec config so Diff can detect policy parameter changes. +func (d *AutoScalingGroupDriver) buildOutputWithConfig(name, targetARN, providerID string, minCap, maxCap int, policyNames []string, config map[string]any) *interfaces.ResourceOutput { + out := d.buildOutput(name, targetARN, providerID, minCap, maxCap, policyNames) + out.Outputs["policy_fingerprint"] = policyConfigFingerprint(config) + return out +} + +// policyConfigFingerprint returns a deterministic string representation of the +// policies config for use in Diff comparisons. It encodes key policy parameters +// so changes to target_value, cooldowns, or step_adjustments are detected even +// when policy names stay the same. +func policyConfigFingerprint(config map[string]any) string { + policies, _ := parsePolicies(config) + if len(policies) == 0 { + return "" + } + // Sort by policy name for deterministic output. + sort.Slice(policies, func(i, j int) bool { + return policies[i].policyName < policies[j].policyName + }) + parts := make([]string, 0, len(policies)) + for _, p := range policies { + switch p.policyType { + case "TargetTrackingScaling": + parts = append(parts, fmt.Sprintf("%s:TT:%.2f:%s:%d:%d", + p.policyName, p.targetValue, p.predefinedMetricType, + p.scaleInCooldown, p.scaleOutCooldown)) + case "StepScaling": + steps := make([]string, len(p.stepAdjustments)) + for i, sa := range p.stepAdjustments { + steps[i] = fmt.Sprintf("%d", sa.scalingAdjustment) + } + parts = append(parts, fmt.Sprintf("%s:SS:%s:%d:%s", + p.policyName, p.adjustmentType, p.cooldown, strings.Join(steps, "+"))) + default: + parts = append(parts, p.policyName+":?:"+p.policyType) + } + } + return strings.Join(parts, "|") +} + +// fetchLivePolicies returns the current scaling policies for a scalable target. +// Returns an error so callers can decide whether to abort destructive operations. +func (d *AutoScalingGroupDriver) fetchLivePolicies(ctx context.Context, ns, resourceID, dim string) ([]aastypes.ScalingPolicy, error) { + out, err := d.client.DescribeScalingPolicies(ctx, &applicationautoscaling.DescribeScalingPoliciesInput{ + ServiceNamespace: aastypes.ServiceNamespace(ns), + ResourceId: awssdk.String(resourceID), + ScalableDimension: aastypes.ScalableDimension(dim), + }) + if err != nil { + return nil, err + } + if out == nil { + return nil, nil + } + return out.ScalingPolicies, nil +} + +// readPolicyNames fetches policy names for a ScalableTarget. +// Returns an error so callers can distinguish "target exists" from "policy enumeration failed." +func (d *AutoScalingGroupDriver) readPolicyNames(ctx context.Context, target aastypes.ScalableTarget) ([]string, error) { + policies, err := d.fetchLivePolicies(ctx, string(target.ServiceNamespace), awssdk.ToString(target.ResourceId), string(target.ScalableDimension)) + if err != nil { + return nil, err + } + var names []string + for _, p := range policies { + names = append(names, awssdk.ToString(p.PolicyName)) + } + return names, nil +} + +// desiredPolicyNames returns the policy_name values from the spec config. +// Malformed policy entries are skipped to allow safe fingerprinting; callers +// that need strict validation use parsePolicies directly. +func desiredPolicyNames(config map[string]any) []string { + policies, _ := parsePolicies(config) + names := make([]string, 0, len(policies)) + for _, p := range policies { + names = append(names, p.policyName) + } + return names +} + +// syncPolicies reconciles desired policies with live policies: +// 1. Delete live policies whose policy_name is absent from the desired list. +// 2. PutScalingPolicy for each desired policy. +func (d *AutoScalingGroupDriver) syncPolicies(ctx context.Context, spec interfaces.ResourceSpec, ns, resourceID, dim string, livePolicies []aastypes.ScalingPolicy) error { + desired, err := parsePolicies(spec.Config) + if err != nil { + return fmt.Errorf("parse policies: %w", err) + } + + // Build set of desired policy names. + desiredNames := make(map[string]struct{}, len(desired)) + for _, p := range desired { + desiredNames[p.policyName] = struct{}{} + } + + // Delete stale live policies. + for _, lp := range livePolicies { + if _, keep := desiredNames[awssdk.ToString(lp.PolicyName)]; !keep { + if _, err := d.client.DeleteScalingPolicy(ctx, &applicationautoscaling.DeleteScalingPolicyInput{ + PolicyName: lp.PolicyName, + ServiceNamespace: lp.ServiceNamespace, + ResourceId: lp.ResourceId, + ScalableDimension: lp.ScalableDimension, + }); err != nil { + return fmt.Errorf("delete stale policy %q: %w", awssdk.ToString(lp.PolicyName), err) + } + } + } + + // Upsert desired policies. + for _, p := range desired { + if err := d.putPolicy(ctx, ns, resourceID, dim, p); err != nil { + return err + } + } + return nil +} + +// policySpec is an internal parsed representation of one policy entry. +type policySpec struct { + policyName string + policyType string + // TargetTrackingScaling fields + targetValue float64 + predefinedMetricType string + scaleInCooldown int32 + scaleOutCooldown int32 + // StepScaling fields + adjustmentType string + stepAdjustments []stepAdjustment + cooldown int32 +} + +// stepAdjustment represents one step in a StepScaling policy. +type stepAdjustment struct { + metricIntervalLowerBound *float64 + metricIntervalUpperBound *float64 + scalingAdjustment int32 +} + +// parsePolicies extracts the policies slice from config. +// Returns an error if the policies key exists but has an unexpected type or contains +// malformed entries, so reconciliation can fail safely without destructive deletes. +func parsePolicies(config map[string]any) ([]policySpec, error) { + raw, ok := config["policies"] + if !ok { + return nil, nil + } + items, ok := raw.([]any) + if !ok { + return nil, fmt.Errorf("policies must be a list, got %T", raw) + } + var result []policySpec + for i, item := range items { + m, ok := item.(map[string]any) + if !ok { + return nil, fmt.Errorf("policies[%d] must be a map, got %T", i, item) + } + p := policySpec{} + p.policyName, _ = m["policy_name"].(string) + p.policyType, _ = m["policy_type"].(string) + + // target_value accepts float64, int, int64 (common in YAML decoding). + if tvRaw, ok := m["target_value"]; ok { + switch v := tvRaw.(type) { + case float64: + p.targetValue = v + case int: + p.targetValue = float64(v) + case int64: + p.targetValue = float64(v) + default: + return nil, fmt.Errorf("policies[%d]: target_value must be a number, got %T", i, tvRaw) + } + } + + p.predefinedMetricType, _ = m["predefined_metric_type"].(string) + p.scaleInCooldown = int32(intProp(m, "scale_in_cooldown", 300)) + p.scaleOutCooldown = int32(intProp(m, "scale_out_cooldown", 300)) + p.adjustmentType, _ = m["adjustment_type"].(string) + p.cooldown = int32(intProp(m, "cooldown", 300)) + + // Parse step_adjustments for StepScaling. + if saRaw, ok := m["step_adjustments"]; ok { + saItems, ok := saRaw.([]any) + if !ok { + return nil, fmt.Errorf("policies[%d]: step_adjustments must be a list, got %T", i, saRaw) + } + for j, saItem := range saItems { + saMap, ok := saItem.(map[string]any) + if !ok { + return nil, fmt.Errorf("policies[%d].step_adjustments[%d] must be a map, got %T", i, j, saItem) + } + sa := stepAdjustment{ + scalingAdjustment: int32(intProp(saMap, "scaling_adjustment", 0)), + } + if lb, ok := saMap["metric_interval_lower_bound"].(float64); ok { + sa.metricIntervalLowerBound = awssdk.Float64(lb) + } + if ub, ok := saMap["metric_interval_upper_bound"].(float64); ok { + sa.metricIntervalUpperBound = awssdk.Float64(ub) + } + p.stepAdjustments = append(p.stepAdjustments, sa) + } + } + + if p.policyName != "" { + result = append(result, p) + } + } + return result, nil +} + +// putPolicy calls PutScalingPolicy for a single policySpec. +// Both TargetTrackingScaling and StepScaling are supported with their required fields. +func (d *AutoScalingGroupDriver) putPolicy(ctx context.Context, ns, resourceID, dim string, p policySpec) error { + in := &applicationautoscaling.PutScalingPolicyInput{ + PolicyName: awssdk.String(p.policyName), + ServiceNamespace: aastypes.ServiceNamespace(ns), + ResourceId: awssdk.String(resourceID), + ScalableDimension: aastypes.ScalableDimension(dim), + } + + switch p.policyType { + case "TargetTrackingScaling": + if p.predefinedMetricType == "" { + return fmt.Errorf("put policy %q: predefined_metric_type is required for TargetTrackingScaling (e.g., ECSServiceAverageCPUUtilization)", p.policyName) + } + if p.targetValue <= 0 { + return fmt.Errorf("put policy %q: target_value must be > 0 for TargetTrackingScaling, got %v", p.policyName, p.targetValue) + } + in.PolicyType = aastypes.PolicyTypeTargetTrackingScaling + cfg := &aastypes.TargetTrackingScalingPolicyConfiguration{ + TargetValue: awssdk.Float64(p.targetValue), + PredefinedMetricSpecification: &aastypes.PredefinedMetricSpecification{ + PredefinedMetricType: aastypes.MetricType(p.predefinedMetricType), + }, + ScaleInCooldown: awssdk.Int32(p.scaleInCooldown), + ScaleOutCooldown: awssdk.Int32(p.scaleOutCooldown), + } + in.TargetTrackingScalingPolicyConfiguration = cfg + case "StepScaling": + if p.adjustmentType == "" { + return fmt.Errorf("put policy %q: adjustment_type is required for StepScaling (e.g., ChangeInCapacity)", p.policyName) + } + if len(p.stepAdjustments) == 0 { + return fmt.Errorf("put policy %q: step_adjustments is required for StepScaling (at least one step)", p.policyName) + } + in.PolicyType = aastypes.PolicyTypeStepScaling + steps := make([]aastypes.StepAdjustment, len(p.stepAdjustments)) + for i, sa := range p.stepAdjustments { + steps[i] = aastypes.StepAdjustment{ + ScalingAdjustment: awssdk.Int32(sa.scalingAdjustment), + MetricIntervalLowerBound: sa.metricIntervalLowerBound, + MetricIntervalUpperBound: sa.metricIntervalUpperBound, + } + } + cfg := &aastypes.StepScalingPolicyConfiguration{ + AdjustmentType: aastypes.AdjustmentType(p.adjustmentType), + StepAdjustments: steps, + Cooldown: awssdk.Int32(p.cooldown), + } + in.StepScalingPolicyConfiguration = cfg + default: + return fmt.Errorf("put policy %q: unsupported policy_type %q (must be TargetTrackingScaling or StepScaling)", p.policyName, p.policyType) + } + + if _, err := d.client.PutScalingPolicy(ctx, in); err != nil { + return fmt.Errorf("put policy %q: %w", p.policyName, err) + } + return nil +} + +var _ interfaces.ResourceDriver = (*AutoScalingGroupDriver)(nil) diff --git a/drivers/autoscaling_group_test.go b/drivers/autoscaling_group_test.go new file mode 100644 index 0000000..40b9353 --- /dev/null +++ b/drivers/autoscaling_group_test.go @@ -0,0 +1,877 @@ +package drivers_test + +import ( + "context" + "fmt" + "testing" + + awssdk "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/applicationautoscaling" + aastypes "github.com/aws/aws-sdk-go-v2/service/applicationautoscaling/types" + + "github.com/GoCodeAlone/workflow-plugin-aws/drivers" + "github.com/GoCodeAlone/workflow/interfaces" +) + +// mockAutoScalingClient implements AutoScalingClient for tests. +type mockAutoScalingClient struct { + registerOut *applicationautoscaling.RegisterScalableTargetOutput + registerErr error + describeOut *applicationautoscaling.DescribeScalableTargetsOutput + describeErr error + describePoliciesOut *applicationautoscaling.DescribeScalingPoliciesOutput + describePoliciesErr error + putPolicyOut *applicationautoscaling.PutScalingPolicyOutput + putPolicyErr error + deletePolicyErr error + deregisterErr error + + // Captured inputs for assertion. + capturedPutPolicyInput *applicationautoscaling.PutScalingPolicyInput +} + +func (m *mockAutoScalingClient) RegisterScalableTarget(_ context.Context, _ *applicationautoscaling.RegisterScalableTargetInput, _ ...func(*applicationautoscaling.Options)) (*applicationautoscaling.RegisterScalableTargetOutput, error) { + return m.registerOut, m.registerErr +} +func (m *mockAutoScalingClient) DescribeScalableTargets(_ context.Context, _ *applicationautoscaling.DescribeScalableTargetsInput, _ ...func(*applicationautoscaling.Options)) (*applicationautoscaling.DescribeScalableTargetsOutput, error) { + return m.describeOut, m.describeErr +} +func (m *mockAutoScalingClient) DescribeScalingPolicies(_ context.Context, _ *applicationautoscaling.DescribeScalingPoliciesInput, _ ...func(*applicationautoscaling.Options)) (*applicationautoscaling.DescribeScalingPoliciesOutput, error) { + return m.describePoliciesOut, m.describePoliciesErr +} +func (m *mockAutoScalingClient) PutScalingPolicy(_ context.Context, in *applicationautoscaling.PutScalingPolicyInput, _ ...func(*applicationautoscaling.Options)) (*applicationautoscaling.PutScalingPolicyOutput, error) { + m.capturedPutPolicyInput = in + return m.putPolicyOut, m.putPolicyErr +} +func (m *mockAutoScalingClient) DeleteScalingPolicy(_ context.Context, _ *applicationautoscaling.DeleteScalingPolicyInput, _ ...func(*applicationautoscaling.Options)) (*applicationautoscaling.DeleteScalingPolicyOutput, error) { + return &applicationautoscaling.DeleteScalingPolicyOutput{}, m.deletePolicyErr +} +func (m *mockAutoScalingClient) DeregisterScalableTarget(_ context.Context, _ *applicationautoscaling.DeregisterScalableTargetInput, _ ...func(*applicationautoscaling.Options)) (*applicationautoscaling.DeregisterScalableTargetOutput, error) { + return &applicationautoscaling.DeregisterScalableTargetOutput{}, m.deregisterErr +} + +// baseAutoScalingSpec returns a minimal valid ResourceSpec for infra.autoscaling_group. +func baseAutoScalingSpec(name string) interfaces.ResourceSpec { + return interfaces.ResourceSpec{ + Name: name, + Type: "infra.autoscaling_group", + Config: map[string]any{ + "service_namespace": "ecs", + "resource_id": "service/my-cluster/my-service", + "scalable_dimension": "ecs:service:DesiredCount", + "min_capacity": 1, + "max_capacity": 10, + }, + } +} + +// baseProviderID is the encoded ProviderID for baseAutoScalingSpec. +const baseProviderID = "ecs|service/my-cluster/my-service|ecs:service:DesiredCount" + +// ---- ResourceType ---- + +func TestAutoScalingGroupDriver_ResourceType(t *testing.T) { + d := drivers.NewAutoScalingGroupDriverWithClient(&mockAutoScalingClient{}) + if d.ResourceType() != "infra.autoscaling_group" { + t.Errorf("expected infra.autoscaling_group, got %s", d.ResourceType()) + } +} + +// ---- Create happy path ---- + +func TestAutoScalingGroupDriver_Create(t *testing.T) { + mock := &mockAutoScalingClient{ + registerOut: &applicationautoscaling.RegisterScalableTargetOutput{ + ScalableTargetARN: awssdk.String("arn:aws:application-autoscaling:us-east-1:123:scalable-target/abc"), + }, + describePoliciesOut: &applicationautoscaling.DescribeScalingPoliciesOutput{}, + } + d := drivers.NewAutoScalingGroupDriverWithClient(mock) + out, err := d.Create(context.Background(), baseAutoScalingSpec("my-asg")) + if err != nil { + t.Fatalf("Create failed: %v", err) + } + if out.Name != "my-asg" { + t.Errorf("expected name my-asg, got %s", out.Name) + } + if out.Type != "infra.autoscaling_group" { + t.Errorf("expected type infra.autoscaling_group, got %s", out.Type) + } + if out.ProviderID == "" { + t.Error("expected non-empty ProviderID") + } +} + +// ---- Create missing required fields ---- + +func TestAutoScalingGroupDriver_Create_MissingServiceNamespace(t *testing.T) { + d := drivers.NewAutoScalingGroupDriverWithClient(&mockAutoScalingClient{}) + spec := interfaces.ResourceSpec{ + Name: "asg", + Config: map[string]any{"resource_id": "service/c/s", "scalable_dimension": "ecs:service:DesiredCount", "min_capacity": 1, "max_capacity": 5}, + } + _, err := d.Create(context.Background(), spec) + if err == nil { + t.Fatal("expected error for missing service_namespace") + } +} + +func TestAutoScalingGroupDriver_Create_MissingResourceID(t *testing.T) { + d := drivers.NewAutoScalingGroupDriverWithClient(&mockAutoScalingClient{}) + spec := interfaces.ResourceSpec{ + Name: "asg", + Config: map[string]any{"service_namespace": "ecs", "scalable_dimension": "ecs:service:DesiredCount", "min_capacity": 1, "max_capacity": 5}, + } + _, err := d.Create(context.Background(), spec) + if err == nil { + t.Fatal("expected error for missing resource_id") + } +} + +func TestAutoScalingGroupDriver_Create_MissingScalableDimension(t *testing.T) { + d := drivers.NewAutoScalingGroupDriverWithClient(&mockAutoScalingClient{}) + spec := interfaces.ResourceSpec{ + Name: "asg", + Config: map[string]any{"service_namespace": "ecs", "resource_id": "service/c/s", "min_capacity": 1, "max_capacity": 5}, + } + _, err := d.Create(context.Background(), spec) + if err == nil { + t.Fatal("expected error for missing scalable_dimension") + } +} + +func TestAutoScalingGroupDriver_Create_MissingMinCapacity(t *testing.T) { + d := drivers.NewAutoScalingGroupDriverWithClient(&mockAutoScalingClient{}) + spec := interfaces.ResourceSpec{ + Name: "asg", + Config: map[string]any{"service_namespace": "ecs", "resource_id": "service/c/s", "scalable_dimension": "ecs:service:DesiredCount", "max_capacity": 5}, + } + _, err := d.Create(context.Background(), spec) + if err == nil { + t.Fatal("expected error for missing min_capacity") + } +} + +func TestAutoScalingGroupDriver_Create_MissingMaxCapacity(t *testing.T) { + d := drivers.NewAutoScalingGroupDriverWithClient(&mockAutoScalingClient{}) + spec := interfaces.ResourceSpec{ + Name: "asg", + Config: map[string]any{"service_namespace": "ecs", "resource_id": "service/c/s", "scalable_dimension": "ecs:service:DesiredCount", "min_capacity": 1}, + } + _, err := d.Create(context.Background(), spec) + if err == nil { + t.Fatal("expected error for missing max_capacity") + } +} + +func TestAutoScalingGroupDriver_Create_InvalidCapacityRange(t *testing.T) { + d := drivers.NewAutoScalingGroupDriverWithClient(&mockAutoScalingClient{}) + spec := interfaces.ResourceSpec{ + Name: "asg", + Config: map[string]any{"service_namespace": "ecs", "resource_id": "service/c/s", "scalable_dimension": "ecs:service:DesiredCount", "min_capacity": 10, "max_capacity": 1}, + } + _, err := d.Create(context.Background(), spec) + if err == nil { + t.Fatal("expected error when min_capacity > max_capacity") + } +} + +// ---- Create with scaling policies ---- + +func TestAutoScalingGroupDriver_Create_WithTargetTrackingPolicy(t *testing.T) { + mock := &mockAutoScalingClient{ + registerOut: &applicationautoscaling.RegisterScalableTargetOutput{ + ScalableTargetARN: awssdk.String("arn:aws:application-autoscaling:us-east-1:123:scalable-target/abc"), + }, + putPolicyOut: &applicationautoscaling.PutScalingPolicyOutput{PolicyARN: awssdk.String("arn:aws:autoscaling:policy/xyz")}, + describePoliciesOut: &applicationautoscaling.DescribeScalingPoliciesOutput{}, + } + d := drivers.NewAutoScalingGroupDriverWithClient(mock) + spec := baseAutoScalingSpec("my-asg") + spec.Config["policies"] = []any{ + map[string]any{ + "policy_name": "cpu-tracking", + "policy_type": "TargetTrackingScaling", + "target_value": float64(75), + "predefined_metric_type": "ECSServiceAverageCPUUtilization", + "scale_in_cooldown": int(300), + "scale_out_cooldown": int(60), + }, + } + out, err := d.Create(context.Background(), spec) + if err != nil { + t.Fatalf("Create with policy failed: %v", err) + } + if out == nil { + t.Fatal("expected non-nil output") + } + // Assert the PutScalingPolicyInput was built correctly. + captured := mock.capturedPutPolicyInput + if captured == nil { + t.Fatal("expected PutScalingPolicy to have been called") + } + if captured.PolicyType != aastypes.PolicyTypeTargetTrackingScaling { + t.Errorf("expected TargetTrackingScaling, got %v", captured.PolicyType) + } + ttCfg := captured.TargetTrackingScalingPolicyConfiguration + if ttCfg == nil { + t.Fatal("expected TargetTrackingScalingPolicyConfiguration to be set") + } + if awssdk.ToFloat64(ttCfg.TargetValue) != 75 { + t.Errorf("expected TargetValue=75, got %v", awssdk.ToFloat64(ttCfg.TargetValue)) + } + if ttCfg.PredefinedMetricSpecification == nil { + t.Fatal("expected PredefinedMetricSpecification to be set") + } + if string(ttCfg.PredefinedMetricSpecification.PredefinedMetricType) != "ECSServiceAverageCPUUtilization" { + t.Errorf("expected ECSServiceAverageCPUUtilization, got %v", ttCfg.PredefinedMetricSpecification.PredefinedMetricType) + } +} + +func TestAutoScalingGroupDriver_Create_TargetTracking_MissingMetricType(t *testing.T) { + mock := &mockAutoScalingClient{ + registerOut: &applicationautoscaling.RegisterScalableTargetOutput{ScalableTargetARN: awssdk.String("arn:aws:application-autoscaling:us-east-1:123:scalable-target/abc")}, + describePoliciesOut: &applicationautoscaling.DescribeScalingPoliciesOutput{}, + } + d := drivers.NewAutoScalingGroupDriverWithClient(mock) + spec := baseAutoScalingSpec("my-asg") + spec.Config["policies"] = []any{ + map[string]any{ + "policy_name": "cpu-tracking", + "policy_type": "TargetTrackingScaling", + "target_value": float64(75), + // predefined_metric_type intentionally omitted + }, + } + _, err := d.Create(context.Background(), spec) + if err == nil { + t.Fatal("expected error when predefined_metric_type is missing for TargetTrackingScaling") + } +} + +func TestAutoScalingGroupDriver_Create_WithStepScalingPolicy(t *testing.T) { + mock := &mockAutoScalingClient{ + registerOut: &applicationautoscaling.RegisterScalableTargetOutput{ + ScalableTargetARN: awssdk.String("arn:aws:application-autoscaling:us-east-1:123:scalable-target/abc"), + }, + putPolicyOut: &applicationautoscaling.PutScalingPolicyOutput{PolicyARN: awssdk.String("arn:aws:autoscaling:policy/step")}, + describePoliciesOut: &applicationautoscaling.DescribeScalingPoliciesOutput{}, + } + d := drivers.NewAutoScalingGroupDriverWithClient(mock) + spec := baseAutoScalingSpec("my-asg") + spec.Config["policies"] = []any{ + map[string]any{ + "policy_name": "step-out", + "policy_type": "StepScaling", + "adjustment_type": "ChangeInCapacity", + "step_adjustments": []any{ + map[string]any{ + "metric_interval_lower_bound": float64(0), + "scaling_adjustment": int(2), + }, + }, + "cooldown": int(60), + }, + } + out, err := d.Create(context.Background(), spec) + if err != nil { + t.Fatalf("Create with step policy failed: %v", err) + } + if out == nil { + t.Fatal("expected non-nil output") + } + // Assert PutScalingPolicyInput fields for StepScaling. + captured := mock.capturedPutPolicyInput + if captured == nil { + t.Fatal("expected PutScalingPolicy to have been called") + } + if captured.PolicyType != aastypes.PolicyTypeStepScaling { + t.Errorf("expected StepScaling, got %v", captured.PolicyType) + } + stCfg := captured.StepScalingPolicyConfiguration + if stCfg == nil { + t.Fatal("expected StepScalingPolicyConfiguration to be set") + } + if string(stCfg.AdjustmentType) != "ChangeInCapacity" { + t.Errorf("expected AdjustmentType=ChangeInCapacity, got %v", stCfg.AdjustmentType) + } + if len(stCfg.StepAdjustments) != 1 { + t.Errorf("expected 1 step adjustment, got %d", len(stCfg.StepAdjustments)) + } +} + +func TestAutoScalingGroupDriver_Create_StepScaling_MissingAdjustmentType(t *testing.T) { + mock := &mockAutoScalingClient{ + registerOut: &applicationautoscaling.RegisterScalableTargetOutput{ScalableTargetARN: awssdk.String("arn:aws:application-autoscaling:us-east-1:123:scalable-target/abc")}, + describePoliciesOut: &applicationautoscaling.DescribeScalingPoliciesOutput{}, + } + d := drivers.NewAutoScalingGroupDriverWithClient(mock) + spec := baseAutoScalingSpec("my-asg") + spec.Config["policies"] = []any{ + map[string]any{ + "policy_name": "step-out", + "policy_type": "StepScaling", + // adjustment_type intentionally omitted + "step_adjustments": []any{ + map[string]any{"metric_interval_lower_bound": float64(0), "scaling_adjustment": int(2)}, + }, + }, + } + _, err := d.Create(context.Background(), spec) + if err == nil { + t.Fatal("expected error when adjustment_type is missing for StepScaling") + } +} + +func TestAutoScalingGroupDriver_Create_StepScaling_MissingStepAdjustments(t *testing.T) { + mock := &mockAutoScalingClient{ + registerOut: &applicationautoscaling.RegisterScalableTargetOutput{ScalableTargetARN: awssdk.String("arn:aws:application-autoscaling:us-east-1:123:scalable-target/abc")}, + describePoliciesOut: &applicationautoscaling.DescribeScalingPoliciesOutput{}, + } + d := drivers.NewAutoScalingGroupDriverWithClient(mock) + spec := baseAutoScalingSpec("my-asg") + spec.Config["policies"] = []any{ + map[string]any{ + "policy_name": "step-out", + "policy_type": "StepScaling", + "adjustment_type": "ChangeInCapacity", + // step_adjustments intentionally omitted + }, + } + _, err := d.Create(context.Background(), spec) + if err == nil { + t.Fatal("expected error when step_adjustments is missing for StepScaling") + } +} + +// ---- Create API error ---- + +func TestAutoScalingGroupDriver_Create_RegisterError(t *testing.T) { + mock := &mockAutoScalingClient{ + registerErr: fmt.Errorf("validation exception: invalid resource"), + } + d := drivers.NewAutoScalingGroupDriverWithClient(mock) + _, err := d.Create(context.Background(), baseAutoScalingSpec("my-asg")) + if err == nil { + t.Fatal("expected error when RegisterScalableTarget fails") + } +} + +func TestAutoScalingGroupDriver_Create_PutPolicyError(t *testing.T) { + mock := &mockAutoScalingClient{ + registerOut: &applicationautoscaling.RegisterScalableTargetOutput{ + ScalableTargetARN: awssdk.String("arn:aws:application-autoscaling:us-east-1:123:scalable-target/abc"), + }, + putPolicyErr: fmt.Errorf("invalid policy configuration"), + describePoliciesOut: &applicationautoscaling.DescribeScalingPoliciesOutput{}, + } + d := drivers.NewAutoScalingGroupDriverWithClient(mock) + spec := baseAutoScalingSpec("my-asg") + spec.Config["policies"] = []any{ + map[string]any{ + "policy_name": "bad-policy", + "policy_type": "TargetTrackingScaling", + "target_value": float64(50), + "predefined_metric_type": "ECSServiceAverageCPUUtilization", + }, + } + _, err := d.Create(context.Background(), spec) + if err == nil { + t.Fatal("expected error when PutScalingPolicy fails") + } +} + +// ---- Read happy path ---- + +func TestAutoScalingGroupDriver_Read(t *testing.T) { + mock := &mockAutoScalingClient{ + describeOut: &applicationautoscaling.DescribeScalableTargetsOutput{ + ScalableTargets: []aastypes.ScalableTarget{ + { + ResourceId: awssdk.String("service/my-cluster/my-service"), + ScalableDimension: aastypes.ScalableDimensionECSServiceDesiredCount, + ServiceNamespace: aastypes.ServiceNamespaceEcs, + MinCapacity: awssdk.Int32(1), + MaxCapacity: awssdk.Int32(10), + ScalableTargetARN: awssdk.String("arn:aws:application-autoscaling:us-east-1:123:scalable-target/abc"), + }, + }, + }, + describePoliciesOut: &applicationautoscaling.DescribeScalingPoliciesOutput{}, + } + d := drivers.NewAutoScalingGroupDriverWithClient(mock) + // ProviderID is required for Read; pass a realistic encoded value. + out, err := d.Read(context.Background(), interfaces.ResourceRef{ + Name: "my-asg", + Type: "infra.autoscaling_group", + ProviderID: baseProviderID, + }) + if err != nil { + t.Fatalf("Read failed: %v", err) + } + if out.Name != "my-asg" { + t.Errorf("expected name my-asg, got %s", out.Name) + } +} + +func TestAutoScalingGroupDriver_Read_MissingProviderID(t *testing.T) { + d := drivers.NewAutoScalingGroupDriverWithClient(&mockAutoScalingClient{}) + // Read without ProviderID must return an error. + _, err := d.Read(context.Background(), interfaces.ResourceRef{Name: "my-asg", Type: "infra.autoscaling_group"}) + if err == nil { + t.Fatal("expected error when ProviderID is missing") + } +} + +func TestAutoScalingGroupDriver_Read_NotFound(t *testing.T) { + mock := &mockAutoScalingClient{ + describeOut: &applicationautoscaling.DescribeScalableTargetsOutput{ + ScalableTargets: []aastypes.ScalableTarget{}, + }, + } + d := drivers.NewAutoScalingGroupDriverWithClient(mock) + _, err := d.Read(context.Background(), interfaces.ResourceRef{Name: "missing-asg", Type: "infra.autoscaling_group", ProviderID: baseProviderID}) + if err == nil { + t.Fatal("expected error for not-found scalable target") + } +} + +func TestAutoScalingGroupDriver_Read_DescribeError(t *testing.T) { + mock := &mockAutoScalingClient{ + describeErr: fmt.Errorf("service unavailable"), + } + d := drivers.NewAutoScalingGroupDriverWithClient(mock) + _, err := d.Read(context.Background(), interfaces.ResourceRef{Name: "my-asg", Type: "infra.autoscaling_group", ProviderID: baseProviderID}) + if err == nil { + t.Fatal("expected error when DescribeScalableTargets fails") + } +} + +// ---- Update happy path ---- + +func TestAutoScalingGroupDriver_Update(t *testing.T) { + mock := &mockAutoScalingClient{ + registerOut: &applicationautoscaling.RegisterScalableTargetOutput{ + ScalableTargetARN: awssdk.String("arn:aws:application-autoscaling:us-east-1:123:scalable-target/abc"), + }, + describeOut: &applicationautoscaling.DescribeScalableTargetsOutput{ + ScalableTargets: []aastypes.ScalableTarget{ + { + ResourceId: awssdk.String("service/my-cluster/my-service"), + ScalableDimension: aastypes.ScalableDimensionECSServiceDesiredCount, + ServiceNamespace: aastypes.ServiceNamespaceEcs, + MinCapacity: awssdk.Int32(1), + MaxCapacity: awssdk.Int32(10), + ScalableTargetARN: awssdk.String("arn:aws:application-autoscaling:us-east-1:123:scalable-target/abc"), + }, + }, + }, + describePoliciesOut: &applicationautoscaling.DescribeScalingPoliciesOutput{ + ScalingPolicies: []aastypes.ScalingPolicy{}, + }, + } + d := drivers.NewAutoScalingGroupDriverWithClient(mock) + spec := baseAutoScalingSpec("my-asg") + spec.Config["max_capacity"] = 20 + out, err := d.Update(context.Background(), interfaces.ResourceRef{Name: "my-asg"}, spec) + if err != nil { + t.Fatalf("Update failed: %v", err) + } + if out == nil { + t.Fatal("expected non-nil output") + } +} + +// ---- Update removes stale policies ---- + +func TestAutoScalingGroupDriver_Update_RemovesStalePolicies(t *testing.T) { + mock := &mockAutoScalingClient{ + registerOut: &applicationautoscaling.RegisterScalableTargetOutput{ + ScalableTargetARN: awssdk.String("arn:aws:application-autoscaling:us-east-1:123:scalable-target/abc"), + }, + describeOut: &applicationautoscaling.DescribeScalableTargetsOutput{ + ScalableTargets: []aastypes.ScalableTarget{ + { + ResourceId: awssdk.String("service/my-cluster/my-service"), + ScalableDimension: aastypes.ScalableDimensionECSServiceDesiredCount, + ServiceNamespace: aastypes.ServiceNamespaceEcs, + MinCapacity: awssdk.Int32(1), + MaxCapacity: awssdk.Int32(10), + ScalableTargetARN: awssdk.String("arn:aws:application-autoscaling:us-east-1:123:scalable-target/abc"), + }, + }, + }, + // Current live policies include "old-policy" which is absent from spec + describePoliciesOut: &applicationautoscaling.DescribeScalingPoliciesOutput{ + ScalingPolicies: []aastypes.ScalingPolicy{ + { + PolicyName: awssdk.String("old-policy"), + PolicyARN: awssdk.String("arn:aws:autoscaling:policy/old"), + ResourceId: awssdk.String("service/my-cluster/my-service"), + ScalableDimension: aastypes.ScalableDimensionECSServiceDesiredCount, + ServiceNamespace: aastypes.ServiceNamespaceEcs, + }, + }, + }, + } + d := drivers.NewAutoScalingGroupDriverWithClient(mock) + // No policies in the desired spec — old-policy should be deleted + out, err := d.Update(context.Background(), interfaces.ResourceRef{Name: "my-asg"}, baseAutoScalingSpec("my-asg")) + if err != nil { + t.Fatalf("Update (remove stale policies) failed: %v", err) + } + if out == nil { + t.Fatal("expected non-nil output") + } +} + +func TestAutoScalingGroupDriver_Update_Error(t *testing.T) { + mock := &mockAutoScalingClient{ + registerErr: fmt.Errorf("invalid parameter combination"), + } + d := drivers.NewAutoScalingGroupDriverWithClient(mock) + _, err := d.Update(context.Background(), interfaces.ResourceRef{Name: "my-asg"}, baseAutoScalingSpec("my-asg")) + if err == nil { + t.Fatal("expected error when RegisterScalableTarget fails during update") + } +} + +// ---- Delete happy path ---- + +func TestAutoScalingGroupDriver_Delete(t *testing.T) { + mock := &mockAutoScalingClient{ + describePoliciesOut: &applicationautoscaling.DescribeScalingPoliciesOutput{ + ScalingPolicies: []aastypes.ScalingPolicy{ + { + PolicyName: awssdk.String("my-policy"), + PolicyARN: awssdk.String("arn:aws:autoscaling:policy/p1"), + ResourceId: awssdk.String("service/my-cluster/my-service"), + ScalableDimension: aastypes.ScalableDimensionECSServiceDesiredCount, + ServiceNamespace: aastypes.ServiceNamespaceEcs, + }, + }, + }, + } + d := drivers.NewAutoScalingGroupDriverWithClient(mock) + err := d.Delete(context.Background(), interfaces.ResourceRef{ + Name: "my-asg", + Type: "infra.autoscaling_group", + ProviderID: baseProviderID, + }) + if err != nil { + t.Fatalf("Delete failed: %v", err) + } +} + +func TestAutoScalingGroupDriver_Delete_Error(t *testing.T) { + mock := &mockAutoScalingClient{ + describePoliciesOut: &applicationautoscaling.DescribeScalingPoliciesOutput{}, + deregisterErr: fmt.Errorf("scalable target not found"), + } + d := drivers.NewAutoScalingGroupDriverWithClient(mock) + err := d.Delete(context.Background(), interfaces.ResourceRef{ + Name: "my-asg", + ProviderID: baseProviderID, + }) + if err == nil { + t.Fatal("expected error when DeregisterScalableTarget fails") + } +} + +func TestAutoScalingGroupDriver_Delete_FetchPoliciesError(t *testing.T) { + mock := &mockAutoScalingClient{ + describePoliciesErr: fmt.Errorf("api throttled"), + } + d := drivers.NewAutoScalingGroupDriverWithClient(mock) + err := d.Delete(context.Background(), interfaces.ResourceRef{ + Name: "my-asg", + ProviderID: baseProviderID, + }) + if err == nil { + t.Fatal("expected error when DescribeScalingPolicies fails during delete") + } +} + +// ---- Diff ---- + +func TestAutoScalingGroupDriver_Diff_NilCurrent(t *testing.T) { + d := drivers.NewAutoScalingGroupDriverWithClient(&mockAutoScalingClient{}) + diff, err := d.Diff(context.Background(), baseAutoScalingSpec("asg"), nil) + if err != nil { + t.Fatal(err) + } + if !diff.NeedsUpdate { + t.Error("expected NeedsUpdate=true for nil current") + } +} + +func TestAutoScalingGroupDriver_Diff_HasChanges(t *testing.T) { + d := drivers.NewAutoScalingGroupDriverWithClient(&mockAutoScalingClient{}) + current := &interfaces.ResourceOutput{ + Name: "asg", + Type: "infra.autoscaling_group", + ProviderID: baseProviderID, + Outputs: map[string]any{"min_capacity": 1, "max_capacity": 5, "policy_names": ""}, + } + spec := baseAutoScalingSpec("asg") + spec.Config["max_capacity"] = 20 + diff, err := d.Diff(context.Background(), spec, current) + if err != nil { + t.Fatal(err) + } + if !diff.NeedsUpdate { + t.Error("expected NeedsUpdate=true when max_capacity changes") + } +} + +func TestAutoScalingGroupDriver_Diff_NoChanges(t *testing.T) { + d := drivers.NewAutoScalingGroupDriverWithClient(&mockAutoScalingClient{}) + current := &interfaces.ResourceOutput{ + Name: "asg", + Type: "infra.autoscaling_group", + ProviderID: baseProviderID, // matches baseAutoScalingSpec identity + Outputs: map[string]any{"min_capacity": 1, "max_capacity": 10, "policy_names": ""}, + } + diff, err := d.Diff(context.Background(), baseAutoScalingSpec("asg"), current) + if err != nil { + t.Fatal(err) + } + if diff.NeedsUpdate { + t.Error("expected NeedsUpdate=false when config unchanged") + } +} + +func TestAutoScalingGroupDriver_Diff_PolicyChange(t *testing.T) { + d := drivers.NewAutoScalingGroupDriverWithClient(&mockAutoScalingClient{}) + current := &interfaces.ResourceOutput{ + Name: "asg", + Type: "infra.autoscaling_group", + ProviderID: baseProviderID, + Outputs: map[string]any{"min_capacity": 1, "max_capacity": 10, "policy_names": []string{"old-policy"}}, + } + // Desired spec has a different policy set. + spec := baseAutoScalingSpec("asg") + spec.Config["policies"] = []any{ + map[string]any{ + "policy_name": "new-policy", + "policy_type": "TargetTrackingScaling", + "target_value": float64(75), + "predefined_metric_type": "ECSServiceAverageCPUUtilization", + }, + } + diff, err := d.Diff(context.Background(), spec, current) + if err != nil { + t.Fatal(err) + } + if !diff.NeedsUpdate { + t.Error("expected NeedsUpdate=true when policy set changes") + } +} + +// ---- HealthCheck ---- + +func TestAutoScalingGroupDriver_HealthCheck_Healthy(t *testing.T) { + mock := &mockAutoScalingClient{ + describeOut: &applicationautoscaling.DescribeScalableTargetsOutput{ + ScalableTargets: []aastypes.ScalableTarget{ + { + ResourceId: awssdk.String("service/my-cluster/my-service"), + ScalableDimension: aastypes.ScalableDimensionECSServiceDesiredCount, + ServiceNamespace: aastypes.ServiceNamespaceEcs, + MinCapacity: awssdk.Int32(1), + MaxCapacity: awssdk.Int32(10), + ScalableTargetARN: awssdk.String("arn:aws:application-autoscaling:us-east-1:123:scalable-target/abc"), + }, + }, + }, + describePoliciesOut: &applicationautoscaling.DescribeScalingPoliciesOutput{}, + } + d := drivers.NewAutoScalingGroupDriverWithClient(mock) + health, err := d.HealthCheck(context.Background(), interfaces.ResourceRef{Name: "my-asg", ProviderID: baseProviderID}) + if err != nil { + t.Fatalf("HealthCheck failed: %v", err) + } + if !health.Healthy { + t.Errorf("expected healthy, got: %s", health.Message) + } +} + +func TestAutoScalingGroupDriver_HealthCheck_Unhealthy(t *testing.T) { + mock := &mockAutoScalingClient{ + describeErr: fmt.Errorf("resource not found"), + } + d := drivers.NewAutoScalingGroupDriverWithClient(mock) + health, err := d.HealthCheck(context.Background(), interfaces.ResourceRef{Name: "my-asg", ProviderID: baseProviderID}) + if err != nil { + t.Fatalf("HealthCheck returned unexpected error: %v", err) + } + if health.Healthy { + t.Error("expected unhealthy when Read fails") + } +} + +func TestAutoScalingGroupDriver_HealthCheck_MissingProviderID(t *testing.T) { + d := drivers.NewAutoScalingGroupDriverWithClient(&mockAutoScalingClient{}) + health, err := d.HealthCheck(context.Background(), interfaces.ResourceRef{Name: "my-asg"}) + if err != nil { + t.Fatalf("HealthCheck returned unexpected error: %v", err) + } + // HealthCheck should return Healthy=false (not an error) when ProviderID is missing. + if health.Healthy { + t.Error("expected unhealthy when ProviderID is missing") + } +} + +// ---- SensitiveKeys ---- + +func TestAutoScalingGroupDriver_SensitiveKeys(t *testing.T) { + d := drivers.NewAutoScalingGroupDriverWithClient(&mockAutoScalingClient{}) + if keys := d.SensitiveKeys(); keys != nil { + t.Errorf("expected nil sensitive keys, got %v", keys) + } +} + +// ---- Diff policy config change ---- + +func TestAutoScalingGroupDriver_Diff_PolicyConfigChange(t *testing.T) { + d := drivers.NewAutoScalingGroupDriverWithClient(&mockAutoScalingClient{}) + + // Build a "current" output as if Create had run with target_value=75. + specWithPolicy := baseAutoScalingSpec("asg") + specWithPolicy.Config["policies"] = []any{ + map[string]any{ + "policy_name": "cpu-policy", + "policy_type": "TargetTrackingScaling", + "target_value": float64(75), + "predefined_metric_type": "ECSServiceAverageCPUUtilization", + }, + } + // Simulate what Create stores: same policy name but the fingerprint encodes 75. + current := &interfaces.ResourceOutput{ + Name: "asg", + Type: "infra.autoscaling_group", + ProviderID: baseProviderID, + Outputs: map[string]any{ + "min_capacity": 1, + "max_capacity": 10, + "policy_names": "cpu-policy", + "policy_fingerprint": "cpu-policy:TT:75.00:ECSServiceAverageCPUUtilization:300:300", + }, + } + + // Desired spec changes target_value to 60 — same policy name, different config. + desired := baseAutoScalingSpec("asg") + desired.Config["policies"] = []any{ + map[string]any{ + "policy_name": "cpu-policy", + "policy_type": "TargetTrackingScaling", + "target_value": float64(60), + "predefined_metric_type": "ECSServiceAverageCPUUtilization", + }, + } + + diff, err := d.Diff(context.Background(), desired, current) + if err != nil { + t.Fatal(err) + } + if !diff.NeedsUpdate { + t.Error("expected NeedsUpdate=true when policy target_value changes (same name)") + } +} + +// ---- Diff identity drift ---- + +func TestAutoScalingGroupDriver_Diff_IdentityDrift(t *testing.T) { + d := drivers.NewAutoScalingGroupDriverWithClient(&mockAutoScalingClient{}) + // Current output has a different ProviderID (different resource_id). + current := &interfaces.ResourceOutput{ + Name: "asg", + Type: "infra.autoscaling_group", + ProviderID: "ecs|service/old-cluster/old-service|ecs:service:DesiredCount", + Outputs: map[string]any{"min_capacity": 1, "max_capacity": 10, "policy_names": ""}, + } + // Desired spec has a different identity — should trigger NeedsUpdate. + diff, err := d.Diff(context.Background(), baseAutoScalingSpec("asg"), current) + if err != nil { + t.Fatal(err) + } + if !diff.NeedsUpdate { + t.Error("expected NeedsUpdate=true when scalable target identity changes") + } +} + +// ---- Update identity mismatch ---- + +func TestAutoScalingGroupDriver_Update_IdentityMismatch(t *testing.T) { + d := drivers.NewAutoScalingGroupDriverWithClient(&mockAutoScalingClient{}) + // ref.ProviderID encodes a different identity than the spec — should error. + _, err := d.Update(context.Background(), interfaces.ResourceRef{ + Name: "my-asg", + ProviderID: "ecs|service/old-cluster/old-service|ecs:service:DesiredCount", + }, baseAutoScalingSpec("my-asg")) + if err == nil { + t.Fatal("expected error when ProviderID does not match spec identity") + } +} + +// ---- Create with int target_value ---- + +func TestAutoScalingGroupDriver_Create_TargetTrackingPolicy_IntTargetValue(t *testing.T) { + mock := &mockAutoScalingClient{ + registerOut: &applicationautoscaling.RegisterScalableTargetOutput{ + ScalableTargetARN: awssdk.String("arn:aws:application-autoscaling:us-east-1:123:scalable-target/abc"), + }, + putPolicyOut: &applicationautoscaling.PutScalingPolicyOutput{PolicyARN: awssdk.String("arn:aws:autoscaling:policy/xyz")}, + describePoliciesOut: &applicationautoscaling.DescribeScalingPoliciesOutput{}, + } + d := drivers.NewAutoScalingGroupDriverWithClient(mock) + spec := baseAutoScalingSpec("my-asg") + // Use int (common in YAML decoding) instead of float64. + spec.Config["policies"] = []any{ + map[string]any{ + "policy_name": "cpu-tracking", + "policy_type": "TargetTrackingScaling", + "target_value": int(75), // int, not float64 + "predefined_metric_type": "ECSServiceAverageCPUUtilization", + }, + } + out, err := d.Create(context.Background(), spec) + if err != nil { + t.Fatalf("Create with int target_value failed: %v", err) + } + if out == nil { + t.Fatal("expected non-nil output") + } + captured := mock.capturedPutPolicyInput + if captured == nil { + t.Fatal("expected PutScalingPolicy to have been called") + } + if awssdk.ToFloat64(captured.TargetTrackingScalingPolicyConfiguration.TargetValue) != 75 { + t.Errorf("expected TargetValue=75, got %v", awssdk.ToFloat64(captured.TargetTrackingScalingPolicyConfiguration.TargetValue)) + } +} + +// ---- parsePolicies malformed input ---- + +func TestAutoScalingGroupDriver_Create_MalformedPoliciesType(t *testing.T) { + mock := &mockAutoScalingClient{ + registerOut: &applicationautoscaling.RegisterScalableTargetOutput{ + ScalableTargetARN: awssdk.String("arn:aws:application-autoscaling:us-east-1:123:scalable-target/abc"), + }, + describePoliciesOut: &applicationautoscaling.DescribeScalingPoliciesOutput{}, + } + d := drivers.NewAutoScalingGroupDriverWithClient(mock) + spec := baseAutoScalingSpec("my-asg") + // policies is a string, not a list — should fail safe rather than delete-all. + spec.Config["policies"] = "not-a-list" + _, err := d.Create(context.Background(), spec) + if err == nil { + t.Fatal("expected error when policies is malformed (wrong type)") + } +} + +// ---- Interface compliance ---- + +func TestAutoScalingGroupDriver_ImplementsResourceDriver(t *testing.T) { + var _ interfaces.ResourceDriver = (*drivers.AutoScalingGroupDriver)(nil) +} diff --git a/go.mod b/go.mod index e9267ae..707734a 100644 --- a/go.mod +++ b/go.mod @@ -4,11 +4,12 @@ go 1.26.0 require ( github.com/GoCodeAlone/workflow v0.19.2 - github.com/aws/aws-sdk-go-v2 v1.41.5 + github.com/aws/aws-sdk-go-v2 v1.41.7 github.com/aws/aws-sdk-go-v2/config v1.32.12 github.com/aws/aws-sdk-go-v2/credentials v1.19.12 github.com/aws/aws-sdk-go-v2/service/acm v1.32.1 github.com/aws/aws-sdk-go-v2/service/apigatewayv2 v1.33.8 + github.com/aws/aws-sdk-go-v2/service/applicationautoscaling v1.41.16 github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.55.2 github.com/aws/aws-sdk-go-v2/service/ec2 v1.296.0 github.com/aws/aws-sdk-go-v2/service/ecr v1.44.2 @@ -56,11 +57,10 @@ require ( github.com/armon/go-metrics v0.4.1 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.21 // indirect - github.com/aws/aws-sdk-go-v2/service/applicationautoscaling v1.41.13 // indirect github.com/aws/aws-sdk-go-v2/service/codebuild v1.68.12 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.12 // indirect @@ -71,7 +71,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 // indirect - github.com/aws/smithy-go v1.24.2 // indirect + github.com/aws/smithy-go v1.25.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bits-and-blooms/bitset v1.24.4 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect diff --git a/go.sum b/go.sum index 278bde3..90f2856 100644 --- a/go.sum +++ b/go.sum @@ -90,8 +90,8 @@ github.com/antithesishq/antithesis-sdk-go v0.7.0 h1:uWDG8BqLD1lI2ps38WDz2vXflrTX github.com/antithesishq/antithesis-sdk-go v0.7.0/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJA= github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4= -github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY= -github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= +github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8= +github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI= github.com/aws/aws-sdk-go-v2/config v1.32.12 h1:O3csC7HUGn2895eNrLytOJQdoL2xyJy0iYXhoZ1OmP0= @@ -100,10 +100,10 @@ github.com/aws/aws-sdk-go-v2/credentials v1.19.12 h1:oqtA6v+y5fZg//tcTWahyN9PEn5 github.com/aws/aws-sdk-go-v2/credentials v1.19.12/go.mod h1:U3R1RtSHx6NB0DvEQFGyf/0sbrpJrluENHdPy1j/3TE= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 h1:zOgq3uezl5nznfoK3ODuqbhVg1JzAGDUhXOsU0IDCAo= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20/go.mod h1:z/MVwUARehy6GAg/yQ1GO2IMl0k++cu1ohP9zo887wE= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.21 h1:SwGMTMLIlvDNyhMteQ6r8IJSBPlRdXX5d4idhIGbkXA= @@ -112,8 +112,8 @@ github.com/aws/aws-sdk-go-v2/service/acm v1.32.1 h1:KAK08un+8LhHlG6OEUmDTqFpQth2 github.com/aws/aws-sdk-go-v2/service/acm v1.32.1/go.mod h1:3sKYAgRbuBa2QMYGh/WEclwnmfx+QoPhhX25PdSQSQM= github.com/aws/aws-sdk-go-v2/service/apigatewayv2 v1.33.8 h1:I0AMtyv5tqQ/VNDDalbbujALCWl64TP3F61bBw4U8Qs= github.com/aws/aws-sdk-go-v2/service/apigatewayv2 v1.33.8/go.mod h1:qnrKR+Jzg9NbZqy+YusE7frSZUaYQ7EPJvki4+SwS3U= -github.com/aws/aws-sdk-go-v2/service/applicationautoscaling v1.41.13 h1:juPaAcploym78WhVwleVHNLPmgURO6gkObC442Hal1s= -github.com/aws/aws-sdk-go-v2/service/applicationautoscaling v1.41.13/go.mod h1:HjgDVqI6lGR0azGz1GKmZTzGHkXuzhKzRUfG/p5Ug8s= +github.com/aws/aws-sdk-go-v2/service/applicationautoscaling v1.41.16 h1:9ePxWacyZEGJQIBfYxumxYjDZvvpCcUAnFQUQy4GI2U= +github.com/aws/aws-sdk-go-v2/service/applicationautoscaling v1.41.16/go.mod h1:gR1tnThD1DBemyG1rmZ9U5+WbfGoiLUaZDvsQ6wbAjM= github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.55.2 h1:mleWBVIxwceEzyItUVoqMFiv6TmOP6ECPoN6WB/VWXc= github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.55.2/go.mod h1:cMApt548kNgu87UsBTNWVv+fpzjbUTFRSFjD1688SBs= github.com/aws/aws-sdk-go-v2/service/codebuild v1.68.12 h1:lQTVEv/YAk8Rw1Yf4XZS/jNNxF9klCN10WcSR3xlMtU= @@ -156,8 +156,8 @@ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 h1:jzKAXIlhZhJbnYwHbvUQZEB github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17/go.mod h1:Al9fFsXjv4KfbzQHGe6V4NZSZQXecFcvaIF4e70FoRA= github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 h1:Cng+OOwCHmFljXIxpEVXAGMnBia8MSU6Ch5i9PgBkcU= github.com/aws/aws-sdk-go-v2/service/sts v1.41.9/go.mod h1:LrlIndBDdjA/EeXeyNBle+gyCwTlizzW5ycgWnvIxkk= -github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= -github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI= +github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= diff --git a/plugin.json b/plugin.json index 8c4ae59..a05a377 100644 --- a/plugin.json +++ b/plugin.json @@ -2,12 +2,12 @@ "name": "workflow-plugin-aws", "version": "0.1.0", "author": "GoCodeAlone", - "description": "AWS provider plugin for workflow IaC — manages ECS, EKS, RDS, ElastiCache, VPC, ALB, Route53, ECR, API Gateway, Security Groups, IAM, S3, and ACM resources", + "description": "AWS provider plugin for workflow IaC — manages ECS, EKS, RDS, ElastiCache, VPC, ALB, Route53, ECR, API Gateway, Security Groups, IAM, S3, ACM, and AutoScaling Group resources", "license": "MIT", "type": "external", "tier": "community", "minEngineVersion": "0.19.0", - "keywords": ["aws", "iac", "infrastructure", "ecs", "eks", "rds", "vpc", "s3"], + "keywords": ["aws", "iac", "infrastructure", "ecs", "eks", "rds", "vpc", "s3", "autoscaling"], "homepage": "https://github.com/GoCodeAlone/workflow-plugin-aws", "repository": "https://github.com/GoCodeAlone/workflow-plugin-aws", "capabilities": { diff --git a/provider/provider.go b/provider/provider.go index e124f75..ee196c6 100644 --- a/provider/provider.go +++ b/provider/provider.go @@ -93,6 +93,7 @@ func (p *AWSProvider) registerDrivers(cfg awssdk.Config, ecsCluster, region stri drivers.NewIAMDriver(cfg), drivers.NewS3Driver(cfg, region), drivers.NewACMDriver(cfg), + drivers.NewAutoScalingGroupDriver(cfg), } for _, d := range driverList { p.driverMap[d.ResourceType()] = d @@ -114,6 +115,7 @@ func (p *AWSProvider) Capabilities() []interfaces.IaCCapabilityDeclaration { {ResourceType: "infra.iam_role", Tier: 1, Operations: []string{"create", "read", "update", "delete"}}, {ResourceType: "infra.storage", Tier: 2, Operations: []string{"create", "read", "update", "delete"}}, {ResourceType: "infra.certificate", Tier: 2, Operations: []string{"create", "read", "update", "delete"}}, + {ResourceType: "infra.autoscaling_group", Tier: 2, Operations: []string{"create", "read", "update", "delete"}}, } } diff --git a/provider/provider_test.go b/provider/provider_test.go index f3af0df..d8675fb 100644 --- a/provider/provider_test.go +++ b/provider/provider_test.go @@ -25,8 +25,8 @@ func TestNewAWSProvider(t *testing.T) { func TestAWSProvider_Capabilities(t *testing.T) { p := provider.NewAWSProvider() caps := p.Capabilities() - if len(caps) != 13 { - t.Errorf("expected 13 capabilities, got %d", len(caps)) + if len(caps) != 14 { + t.Errorf("expected 14 capabilities, got %d", len(caps)) } // Verify all required resource types are present @@ -44,6 +44,7 @@ func TestAWSProvider_Capabilities(t *testing.T) { "infra.iam_role", "infra.storage", "infra.certificate", + "infra.autoscaling_group", } capSet := make(map[string]bool) for _, c := range caps {