diff --git a/CHANGELOG.md b/CHANGELOG.md index 5dd4475..6db5d0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file. The format is loosely based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- AWS ownership tagging support via `IaCProviderOwnership`, using the + `workflow-owner` tag key for ARN-backed resources. +- Manifest parity coverage for root and embedded `iacServices` declarations. + +### Changed + +- `minEngineVersion` now requires workflow `0.69.1+`, matching the + ownership-service contract. + ## [2.0.0-rc1] — 2026-05-17 ### Breaking changes (workflow#699) diff --git a/cmd/workflow-plugin-aws/plugin.json b/cmd/workflow-plugin-aws/plugin.json index f9e43bf..c79f23d 100644 --- a/cmd/workflow-plugin-aws/plugin.json +++ b/cmd/workflow-plugin-aws/plugin.json @@ -6,7 +6,7 @@ "license": "MIT", "type": "external", "tier": "community", - "minEngineVersion": "0.68.2", + "minEngineVersion": "0.69.1", "iacServices": [ "workflow.plugin.external.iac.IaCProviderRequired", "workflow.plugin.external.iac.IaCProviderEnumerator", @@ -16,6 +16,8 @@ "workflow.plugin.external.iac.IaCProviderValidator", "workflow.plugin.external.iac.IaCProviderDriftConfigDetector", "workflow.plugin.external.iac.IaCProviderRequirementMapper", + "workflow.plugin.external.iac.IaCProviderRegionLister", + "workflow.plugin.external.iac.IaCProviderOwnership", "workflow.plugin.external.iac.ResourceDriver", "workflow.plugin.external.iac.IaCStateBackend" ], diff --git a/go.mod b/go.mod index fea772e..201694c 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,8 @@ module github.com/GoCodeAlone/workflow-plugin-aws go 1.26.0 require ( - github.com/GoCodeAlone/workflow v0.68.2 - github.com/aws/aws-sdk-go-v2 v1.41.7 + github.com/GoCodeAlone/workflow v0.69.7 + github.com/aws/aws-sdk-go-v2 v1.41.9 github.com/aws/aws-sdk-go-v2/config v1.32.16 github.com/aws/aws-sdk-go-v2/credentials v1.19.15 github.com/aws/aws-sdk-go-v2/service/acm v1.32.1 @@ -19,6 +19,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2 v1.54.6 github.com/aws/aws-sdk-go-v2/service/iam v1.53.7 github.com/aws/aws-sdk-go-v2/service/rds v1.115.0 + github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.32.2 github.com/aws/aws-sdk-go-v2/service/route53 v1.62.5 github.com/aws/aws-sdk-go-v2/service/s3 v1.97.2 github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 @@ -46,8 +47,8 @@ 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.22 // 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/configsources v1.4.25 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 // indirect github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.12 // indirect @@ -57,7 +58,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/signin v1.0.10 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.30.16 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20 // indirect - github.com/aws/smithy-go v1.25.1 // indirect + github.com/aws/smithy-go v1.26.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bits-and-blooms/bitset v1.24.4 // indirect github.com/bytedance/gopkg v0.1.4 // indirect diff --git a/go.sum b/go.sum index d741a86..a93dcb5 100644 --- a/go.sum +++ b/go.sum @@ -22,8 +22,8 @@ github.com/GoCodeAlone/modular/modules/jsonschema v1.17.0 h1:zoWioqUvuNNDfnjHA1s github.com/GoCodeAlone/modular/modules/jsonschema v1.17.0/go.mod h1:GDU/jsD6AddmXKedj0wZwieUIaQsTBSGMzuj+XHXMrw= github.com/GoCodeAlone/modular/modules/reverseproxy/v2 v2.10.0 h1:+2M/ecyCxDiXfJM4ibcERuu/BBeIbLTQNcVgRsllR64= github.com/GoCodeAlone/modular/modules/reverseproxy/v2 v2.10.0/go.mod h1:tlVH1mA5yuU8CB7R7+HXIRaBixZoNid6h+5tew5u3FU= -github.com/GoCodeAlone/workflow v0.68.2 h1:U0ksQOkIwDReuw+nz4kRoCeYwahoBaItqLzwYIRm758= -github.com/GoCodeAlone/workflow v0.68.2/go.mod h1:4UwFYm1cM8a/AvGNb1CZAuob0b0gq7552sxcNMdDALA= +github.com/GoCodeAlone/workflow v0.69.7 h1:LgRTJtbicyOeucyQmHw/F7rjfYP8T15C01p7jNm6kP0= +github.com/GoCodeAlone/workflow v0.69.7/go.mod h1:nWB662ILBUUjL2NBlj7RchyiI4CZ2+UxnpQcbIA2tWE= github.com/GoCodeAlone/yaegi v0.17.2 h1:WK6Y6e0t1a6U7r+S2dN3CGWW1PizYD3zO0zneToZPxM= github.com/GoCodeAlone/yaegi v0.17.2/go.mod h1:z5Pr6Wse6QJcQvpgxTxzMAevFarH0N37TG88Y9dprx0= github.com/IBM/sarama v1.47.0 h1:GcQFEd12+KzfPYeLgN69Fh7vLCtYRhVIx0rO4TZO318= @@ -48,8 +48,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.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 v1.41.9 h1:/rYeyO2+HrMztAmxAq9++XJtFMqSIpSsNA0yDGALYq4= +github.com/aws/aws-sdk-go-v2 v1.41.9/go.mod h1:+HsoOEX80qAVUitj1A2DhCNTjmb3edVyuDypb6LNEeo= 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.16 h1:Q0iQ7quUgJP0F/SCRTieScnaMdXr9h/2+wze1u3cNeM= @@ -58,10 +58,10 @@ github.com/aws/aws-sdk-go-v2/credentials v1.19.15 h1:fyvgWTszojq8hEnMi8PPBTvZdTt github.com/aws/aws-sdk-go-v2/credentials v1.19.15/go.mod h1:gJiYyMOjNg8OEdRWOf3CrFQxM2a98qmrtjx1zuiQfB8= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22 h1:IOGsJ1xVWhsi+ZO7/NW8OuZZBtMJLZbk4P5HDjJO0jQ= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22/go.mod h1:b+hYdbU+jGKfXE8kKM6g1+h+L/Go3vMvzlxBsiuGsxg= -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/configsources v1.4.25 h1:Uii3frf9ztec/ABM2/FSH9/z7PLzxfpG8h4RpkUFflQ= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25/go.mod h1:G6kntsA2GorAxDPbap6xgB2F+amSLUF8GJTi7PUoX44= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25 h1:r1+/l6m+WaUJF9HISEsNOLHSNj5EXYQxK8VX6Cz9NlA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25/go.mod h1:cKf+D+NMDK1LndD7BowHbBZPgR9V0/5HubH0PFWvA+c= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 h1:FPXsW9+gMuIeKmz7j6ENWcWtBGTe1kH8r9thNt5Uxx4= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23/go.mod h1:7J8iGMdRKk6lw2C+cMIphgAnT8uTwBwNOsGkyOCm80U= github.com/aws/aws-sdk-go-v2/service/acm v1.32.1 h1:KAK08un+8LhHlG6OEUmDTqFpQth2tYA+6EX0NNocgl4= @@ -98,6 +98,8 @@ github.com/aws/aws-sdk-go-v2/service/kinesis v1.43.5 h1:LxgRVyuY+5DEPSX7kmin/V7t github.com/aws/aws-sdk-go-v2/service/kinesis v1.43.5/go.mod h1:eUebEBEqVfOwEyDDDbGauH4PNqDCuepRvTaNbJeWr5w= github.com/aws/aws-sdk-go-v2/service/rds v1.115.0 h1:oNl6YghOtxu3MiFk1tQ86QlrYMIEJazGUDbBCg9nxLA= github.com/aws/aws-sdk-go-v2/service/rds v1.115.0/go.mod h1:JBRYWpz5oXQtHgQC+X8LX9lh0FBCwRHJlWEIT+TTLaE= +github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.32.2 h1:LC3ALu3cQVkh7umM+x8zE0UxVWS/gllEt5VuNchyUW8= +github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.32.2/go.mod h1:gBZ5iZqcOsvR8pIZS0CsbGfoUUEyiS8qjxQXRjdsxZA= github.com/aws/aws-sdk-go-v2/service/route53 v1.62.5 h1:Z+/OLsb85Kpq7TVLCspskqePaf68Tdv6GfmJP4kH6i0= github.com/aws/aws-sdk-go-v2/service/route53 v1.62.5/go.mod h1:TmxGowuBYwjmHFOsEDxaZdsQE62JJzOmtiWafTi/czg= github.com/aws/aws-sdk-go-v2/service/s3 v1.97.2 h1:MRNiP6nqa20aEl8fQ6PJpEq11b2d40b16sm4WD7QgMU= @@ -110,8 +112,8 @@ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20 h1:oK/njaL8GtyEihkWMD4k3Vg github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20/go.mod h1:JHs8/y1f3zY7U5WcuzoJ/yAYGYtNIVPKLIbp61euvmg= github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 h1:ks8KBcZPh3PYISr5dAiXCM5/Thcuxk8l+PG4+A0exds= github.com/aws/aws-sdk-go-v2/service/sts v1.42.0/go.mod h1:pFw33T0WLvXU3rw1WBkpMlkgIn54eCB5FYLhjDc9Foo= -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/aws/smithy-go v1.26.0 h1:9ouqbi+NyKP7fV3Te7UElCwdAb6Y8uk7LGwPE5tVe/s= +github.com/aws/smithy-go v1.26.0/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/internal/host_conformance_test.go b/internal/host_conformance_test.go index f2531ad..d9fbf08 100644 --- a/internal/host_conformance_test.go +++ b/internal/host_conformance_test.go @@ -6,7 +6,9 @@ import ( "os" "os/exec" "path/filepath" + "reflect" "runtime" + "sort" "testing" "time" @@ -139,6 +141,32 @@ func capabilitiesHasResource(capabilities *pb.CapabilitiesResponse, resourceType return false } +func TestEmbeddedManifestIaCServicesMatchRootManifest(t *testing.T) { + repoRoot := hostConformanceRepoRoot(t) + rootServices := readManifestIaCServices(t, filepath.Join(repoRoot, "plugin.json")) + embeddedServices := readManifestIaCServices(t, filepath.Join(repoRoot, "cmd", "workflow-plugin-aws", "plugin.json")) + sort.Strings(rootServices) + sort.Strings(embeddedServices) + if !reflect.DeepEqual(rootServices, embeddedServices) { + t.Fatalf("embedded manifest iacServices = %v, want root manifest services %v", embeddedServices, rootServices) + } +} + +func readManifestIaCServices(t *testing.T, path string) []string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + var manifest struct { + IaCServices []string `json:"iacServices"` + } + if err := json.Unmarshal(data, &manifest); err != nil { + t.Fatalf("parse %s: %v", path, err) + } + return append([]string(nil), manifest.IaCServices...) +} + // TestCapabilityParity_IaCStateBackends asserts that every iac.state backend // name declared in plugin.json capabilities.iacStateBackends is actually // served by the plugin — i.e. returned by NewIaCServer().ListBackendNames. diff --git a/internal/iacserver.go b/internal/iacserver.go index 8c3e36a..f45210d 100644 --- a/internal/iacserver.go +++ b/internal/iacserver.go @@ -49,6 +49,7 @@ type awsIaCServer struct { pb.UnimplementedIaCProviderDriftConfigDetectorServer pb.UnimplementedIaCProviderRequirementMapperServer pb.UnimplementedIaCProviderRegionListerServer + pb.UnimplementedIaCProviderOwnershipServer pb.UnimplementedResourceDriverServer pb.UnimplementedIaCStateBackendServer @@ -89,6 +90,7 @@ var ( _ pb.ResourceDriverServer = (*awsIaCServer)(nil) _ pb.IaCProviderRequirementMapperServer = (*awsIaCServer)(nil) _ pb.IaCProviderRegionListerServer = (*awsIaCServer)(nil) + _ pb.IaCProviderOwnershipServer = (*awsIaCServer)(nil) // awsIaCServer also SERVES the typed IaC state-backend contract (s3 // backend). The SDK serve hook auto-registers this via type-assertion at // plugin startup — see cmd/workflow-plugin-aws/main.go. @@ -250,6 +252,42 @@ func (s *awsIaCServer) DetectDriftWithSpecs(ctx context.Context, req *pb.DetectD return &pb.DetectDriftWithSpecsResponse{Drifts: pbDrifts}, nil } +// ── Optional: Ownership ─────────────────────────────────────────────────── + +func (s *awsIaCServer) GetOwner(ctx context.Context, req *pb.GetOwnerRequest) (*pb.GetOwnerResponse, error) { + owner, err := s.provider.GetOwner(ctx, refFromPB(req.GetRef())) + if err != nil { + return nil, err + } + return &pb.GetOwnerResponse{Owner: owner.Owner, Source: owner.Source}, nil +} + +func (s *awsIaCServer) SetOwner(ctx context.Context, req *pb.SetOwnerRequest) (*pb.SetOwnerResponse, error) { + if err := s.provider.SetOwner(ctx, refFromPB(req.GetRef()), req.GetOwner()); err != nil { + return nil, err + } + return &pb.SetOwnerResponse{}, nil +} + +func (s *awsIaCServer) ListOwners(ctx context.Context, req *pb.ListOwnersRequest) (*pb.ListOwnersResponse, error) { + owners, err := s.provider.ListOwners(ctx, interfaces.OwnerFilter{ + Owner: req.GetOwner(), + ResourceType: req.GetResourceType(), + }) + if err != nil { + return nil, err + } + out := make([]*pb.OwnedResource, 0, len(owners)) + for _, owner := range owners { + out = append(out, &pb.OwnedResource{ + Ref: refToPB(owner.Ref), + Owner: owner.Owner, + Source: owner.Source, + }) + } + return &pb.ListOwnersResponse{Resources: out}, nil +} + // ── Marshalling helpers (pb ↔ Go) ─────────────────────────────────────────── // // These mirror the inverse-direction helpers in cmd/wfctl/iac_typed_adapter.go diff --git a/internal/iacserver_mapper_test.go b/internal/iacserver_mapper_test.go index d7083f7..d108b29 100644 --- a/internal/iacserver_mapper_test.go +++ b/internal/iacserver_mapper_test.go @@ -185,16 +185,24 @@ func TestPluginManifestAdvertisesRequirementMapper(t *testing.T) { if err := json.Unmarshal(data, &manifest); err != nil { t.Fatalf("parse plugin.json: %v", err) } - if manifest.MinEngineVersion != "0.68.2" { - t.Fatalf("minEngineVersion = %q, want 0.68.2", manifest.MinEngineVersion) - } - const mapperService = "workflow.plugin.external.iac.IaCProviderRequirementMapper" - for _, svc := range manifest.IaCServices { - if svc == mapperService { - return + if manifest.MinEngineVersion != "0.69.1" { + t.Fatalf("minEngineVersion = %q, want 0.69.1", manifest.MinEngineVersion) + } + for _, want := range []string{ + "workflow.plugin.external.iac.IaCProviderRequirementMapper", + "workflow.plugin.external.iac.IaCProviderOwnership", + } { + found := false + for _, svc := range manifest.IaCServices { + if svc == want { + found = true + break + } + } + if !found { + t.Fatalf("iacServices missing %s: %v", want, manifest.IaCServices) } } - t.Fatalf("iacServices missing %s: %v", mapperService, manifest.IaCServices) } func newMapperTestConn(t *testing.T) *grpc.ClientConn { diff --git a/internal/iacserver_test.go b/internal/iacserver_test.go index fb4be63..7ac8611 100644 --- a/internal/iacserver_test.go +++ b/internal/iacserver_test.go @@ -75,6 +75,7 @@ func TestIaCServer_CompileTimeGuards(t *testing.T) { // If any of the interface assertions below fail to compile, this file will not build. var _ pb.IaCProviderRequiredServer = (*awsIaCServer)(nil) var _ pb.IaCProviderDriftDetectorServer = (*awsIaCServer)(nil) + var _ pb.IaCProviderOwnershipServer = (*awsIaCServer)(nil) var _ pb.ResourceDriverServer = (*awsIaCServer)(nil) } diff --git a/plugin.json b/plugin.json index 74a6842..f0f58e2 100644 --- a/plugin.json +++ b/plugin.json @@ -6,7 +6,7 @@ "license": "MIT", "type": "external", "tier": "community", - "minEngineVersion": "0.68.2", + "minEngineVersion": "0.69.1", "required_secrets": [ { "name": "AWS_ACCESS_KEY_ID", @@ -31,6 +31,7 @@ "workflow.plugin.external.iac.IaCProviderDriftConfigDetector", "workflow.plugin.external.iac.IaCProviderRequirementMapper", "workflow.plugin.external.iac.IaCProviderRegionLister", + "workflow.plugin.external.iac.IaCProviderOwnership", "workflow.plugin.external.iac.ResourceDriver", "workflow.plugin.external.iac.IaCStateBackend" ], diff --git a/provider/ownership.go b/provider/ownership.go new file mode 100644 index 0000000..052880e --- /dev/null +++ b/provider/ownership.go @@ -0,0 +1,233 @@ +package provider + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/GoCodeAlone/workflow/interfaces" + awssdk "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi" + tagtypes "github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi/types" +) + +const ( + ownershipTagKey = "workflow-owner" + ownershipTagSource = "tag:workflow-owner" +) + +var ErrOwnershipARNRequired = errors.New("aws ownership requires ResourceRef.ProviderID to be an ARN") + +type ownershipTaggingClient interface { + GetResources(context.Context, *resourcegroupstaggingapi.GetResourcesInput, ...func(*resourcegroupstaggingapi.Options)) (*resourcegroupstaggingapi.GetResourcesOutput, error) + TagResources(context.Context, *resourcegroupstaggingapi.TagResourcesInput, ...func(*resourcegroupstaggingapi.Options)) (*resourcegroupstaggingapi.TagResourcesOutput, error) + UntagResources(context.Context, *resourcegroupstaggingapi.UntagResourcesInput, ...func(*resourcegroupstaggingapi.Options)) (*resourcegroupstaggingapi.UntagResourcesOutput, error) +} + +func (p *AWSProvider) GetOwner(ctx context.Context, ref interfaces.ResourceRef) (*interfaces.ResourceOwner, error) { + p.mu.RLock() + client, err := p.ownershipClientLocked() + p.mu.RUnlock() + if err != nil { + return nil, err + } + arn, err := ownershipARN(ref) + if err != nil { + return nil, err + } + out, err := client.GetResources(ctx, &resourcegroupstaggingapi.GetResourcesInput{ResourceARNList: []string{arn}}) + if err != nil { + return nil, fmt.Errorf("aws: get ownership tags for %q: %w", ref.Name, err) + } + for _, mapping := range out.ResourceTagMappingList { + if awssdk.ToString(mapping.ResourceARN) == arn { + return &interfaces.ResourceOwner{Ref: ref, Owner: ownerFromTags(mapping.Tags), Source: ownershipTagSource}, nil + } + } + return &interfaces.ResourceOwner{Ref: ref, Source: ownershipTagSource}, nil +} + +func (p *AWSProvider) SetOwner(ctx context.Context, ref interfaces.ResourceRef, owner string) error { + if strings.TrimSpace(owner) == "" { + return fmt.Errorf("aws: owner must be non-empty") + } + p.mu.RLock() + client, err := p.ownershipClientLocked() + p.mu.RUnlock() + if err != nil { + return err + } + arn, err := ownershipARN(ref) + if err != nil { + return err + } + if _, err := client.TagResources(ctx, &resourcegroupstaggingapi.TagResourcesInput{ + ResourceARNList: []string{arn}, + Tags: map[string]string{ownershipTagKey: owner}, + }); err != nil { + return fmt.Errorf("aws: tag %s/%s with owner %q: %w", ref.Type, ref.Name, owner, err) + } + return nil +} + +func (p *AWSProvider) ListOwners(ctx context.Context, filter interfaces.OwnerFilter) ([]interfaces.ResourceOwner, error) { + p.mu.RLock() + client, err := p.ownershipClientLocked() + p.mu.RUnlock() + if err != nil { + return nil, err + } + + tagFilter := tagtypes.TagFilter{Key: awssdk.String(ownershipTagKey)} + if filter.Owner != "" { + tagFilter.Values = []string{filter.Owner} + } + in := &resourcegroupstaggingapi.GetResourcesInput{TagFilters: []tagtypes.TagFilter{tagFilter}} + + var owners []interfaces.ResourceOwner + for { + resp, err := client.GetResources(ctx, in) + if err != nil { + return nil, fmt.Errorf("aws: list owner tags: %w", err) + } + for _, mapping := range resp.ResourceTagMappingList { + owner := ownerFromTags(mapping.Tags) + if owner == "" { + continue + } + ref := refFromOwnershipARN(awssdk.ToString(mapping.ResourceARN)) + if ref.ProviderID == "" { + continue + } + if filter.ResourceType != "" && ref.Type != filter.ResourceType { + continue + } + owners = append(owners, interfaces.ResourceOwner{Ref: ref, Owner: owner, Source: ownershipTagSource}) + } + if awssdk.ToString(resp.PaginationToken) == "" { + break + } + in.PaginationToken = resp.PaginationToken + } + return owners, nil +} + +func (p *AWSProvider) ownershipClientLocked() (ownershipTaggingClient, error) { + if !p.initialized { + return nil, fmt.Errorf("aws: provider not initialized") + } + if p.ownershipClient == nil { + return nil, fmt.Errorf("aws: ownership tagging client not initialized") + } + return p.ownershipClient, nil +} + +func ownershipARN(ref interfaces.ResourceRef) (string, error) { + if strings.HasPrefix(ref.ProviderID, "arn:") { + return ref.ProviderID, nil + } + return "", fmt.Errorf("%w for %s/%s: got %q", ErrOwnershipARNRequired, ref.Type, ref.Name, ref.ProviderID) +} + +func ownerFromTags(tags []tagtypes.Tag) string { + for _, tag := range tags { + if awssdk.ToString(tag.Key) == ownershipTagKey { + return awssdk.ToString(tag.Value) + } + } + return "" +} + +func refFromOwnershipARN(arn string) interfaces.ResourceRef { + parts := strings.SplitN(arn, ":", 6) + if len(parts) != 6 || parts[0] != "arn" { + return interfaces.ResourceRef{} + } + resourceType := resourceTypeFromARN(parts[2], parts[5]) + if resourceType == "" { + return interfaces.ResourceRef{} + } + return interfaces.ResourceRef{ + Name: nameFromARNResource(parts[2], parts[5]), + Type: resourceType, + ProviderID: arn, + } +} + +func resourceTypeFromARN(service, resource string) string { + switch service { + case "ecs": + if strings.HasPrefix(resource, "service/") { + return "infra.container_service" + } + case "eks": + if strings.HasPrefix(resource, "cluster/") { + return "infra.k8s_cluster" + } + case "rds": + if strings.HasPrefix(resource, "db:") { + return "infra.database" + } + case "elasticache": + return "infra.cache" + case "ec2": + if strings.HasPrefix(resource, "vpc/") { + return "infra.vpc" + } + if strings.HasPrefix(resource, "security-group/") { + return "infra.firewall" + } + case "elasticloadbalancing": + if strings.HasPrefix(resource, "loadbalancer/") { + return "infra.load_balancer" + } + case "ecr": + if strings.HasPrefix(resource, "repository/") { + return "infra.registry" + } + case "apigateway": + return "infra.api_gateway" + case "iam": + if strings.HasPrefix(resource, "role/") { + return "infra.iam_role" + } + case "s3": + return "infra.storage" + case "acm": + if strings.HasPrefix(resource, "certificate/") { + return "infra.certificate" + } + case "application-autoscaling": + if strings.HasPrefix(resource, "scalable-target/") { + return "infra.autoscaling_group" + } + } + return "" +} + +func nameFromARNResource(service, resource string) string { + switch service { + case "ecs": + parts := strings.Split(resource, "/") + return parts[len(parts)-1] + case "rds": + return strings.TrimPrefix(resource, "db:") + case "elasticloadbalancing": + parts := strings.Split(resource, "/") + if len(parts) >= 3 && parts[0] == "loadbalancer" { + return parts[len(parts)-2] + } + case "application-autoscaling": + return strings.TrimPrefix(resource, "scalable-target/") + } + parts := strings.FieldsFunc(resource, func(r rune) bool { + return r == '/' || r == ':' + }) + if len(parts) == 0 { + return resource + } + return parts[len(parts)-1] +} + +var _ interfaces.OwnershipProvider = (*AWSProvider)(nil) diff --git a/provider/ownership_test.go b/provider/ownership_test.go new file mode 100644 index 0000000..57cb3e8 --- /dev/null +++ b/provider/ownership_test.go @@ -0,0 +1,163 @@ +package provider + +import ( + "context" + "errors" + "testing" + + "github.com/GoCodeAlone/workflow/interfaces" + awssdk "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi" + tagtypes "github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi/types" +) + +type fakeOwnershipTaggingClient struct { + getInputs []*resourcegroupstaggingapi.GetResourcesInput + getOutputs []*resourcegroupstaggingapi.GetResourcesOutput + tagInputs []*resourcegroupstaggingapi.TagResourcesInput + untagInputs []*resourcegroupstaggingapi.UntagResourcesInput +} + +func (f *fakeOwnershipTaggingClient) GetResources(ctx context.Context, in *resourcegroupstaggingapi.GetResourcesInput, optFns ...func(*resourcegroupstaggingapi.Options)) (*resourcegroupstaggingapi.GetResourcesOutput, error) { + f.getInputs = append(f.getInputs, in) + if len(f.getOutputs) == 0 { + return &resourcegroupstaggingapi.GetResourcesOutput{}, nil + } + out := f.getOutputs[0] + f.getOutputs = f.getOutputs[1:] + return out, nil +} + +func (f *fakeOwnershipTaggingClient) TagResources(ctx context.Context, in *resourcegroupstaggingapi.TagResourcesInput, optFns ...func(*resourcegroupstaggingapi.Options)) (*resourcegroupstaggingapi.TagResourcesOutput, error) { + f.tagInputs = append(f.tagInputs, in) + return &resourcegroupstaggingapi.TagResourcesOutput{}, nil +} + +func (f *fakeOwnershipTaggingClient) UntagResources(ctx context.Context, in *resourcegroupstaggingapi.UntagResourcesInput, optFns ...func(*resourcegroupstaggingapi.Options)) (*resourcegroupstaggingapi.UntagResourcesOutput, error) { + f.untagInputs = append(f.untagInputs, in) + return &resourcegroupstaggingapi.UntagResourcesOutput{}, nil +} + +func TestOwnershipProviderCompileGuard(t *testing.T) { + var _ interfaces.OwnershipProvider = (*AWSProvider)(nil) +} + +func TestSetOwnerTagsARNWithWorkflowOwnerKey(t *testing.T) { + client := &fakeOwnershipTaggingClient{} + p := initializedOwnershipProvider(client) + arn := "arn:aws:ecs:us-east-1:123456789012:service/default/api" + + if err := p.SetOwner(context.Background(), interfaces.ResourceRef{Name: "api", Type: "infra.container_service", ProviderID: arn}, "workflow"); err != nil { + t.Fatalf("SetOwner: %v", err) + } + + if len(client.tagInputs) != 1 { + t.Fatalf("TagResources calls = %d, want 1", len(client.tagInputs)) + } + got := client.tagInputs[0] + if len(got.ResourceARNList) != 1 || got.ResourceARNList[0] != arn { + t.Fatalf("ResourceARNList = %v, want [%s]", got.ResourceARNList, arn) + } + if got.Tags[ownershipTagKey] != "workflow" { + t.Fatalf("Tags[%q] = %q, want workflow", ownershipTagKey, got.Tags[ownershipTagKey]) + } +} + +func TestSetOwnerRejectsNonARNProviderID(t *testing.T) { + p := initializedOwnershipProvider(&fakeOwnershipTaggingClient{}) + + err := p.SetOwner(context.Background(), interfaces.ResourceRef{Name: "vpc", Type: "infra.vpc", ProviderID: "vpc-123"}, "workflow") + if err == nil { + t.Fatal("SetOwner returned nil, want unsupported non-ARN error") + } + if !errors.Is(err, ErrOwnershipARNRequired) { + t.Fatalf("SetOwner error = %v, want ErrOwnershipARNRequired", err) + } +} + +func TestGetOwnerReadsWorkflowOwnerTag(t *testing.T) { + arn := "arn:aws:rds:us-east-1:123456789012:db:orders" + client := &fakeOwnershipTaggingClient{ + getOutputs: []*resourcegroupstaggingapi.GetResourcesOutput{ + { + ResourceTagMappingList: []tagtypes.ResourceTagMapping{ + { + ResourceARN: awssdk.String(arn), + Tags: []tagtypes.Tag{ + {Key: awssdk.String("Name"), Value: awssdk.String("orders")}, + {Key: awssdk.String(ownershipTagKey), Value: awssdk.String("payments")}, + }, + }, + }, + }, + }, + } + p := initializedOwnershipProvider(client) + + owner, err := p.GetOwner(context.Background(), interfaces.ResourceRef{Name: "orders", Type: "infra.database", ProviderID: arn}) + if err != nil { + t.Fatalf("GetOwner: %v", err) + } + if owner.Owner != "payments" { + t.Fatalf("Owner = %q, want payments", owner.Owner) + } + if owner.Source != ownershipTagSource { + t.Fatalf("Source = %q, want %q", owner.Source, ownershipTagSource) + } + if owner.Ref.ProviderID != arn { + t.Fatalf("Ref.ProviderID = %q, want %q", owner.Ref.ProviderID, arn) + } +} + +func TestListOwnersMapsTaggedARNsAndFiltersResourceType(t *testing.T) { + ecsARN := "arn:aws:ecs:us-east-1:123456789012:service/default/api" + rdsARN := "arn:aws:rds:us-east-1:123456789012:db:orders" + client := &fakeOwnershipTaggingClient{ + getOutputs: []*resourcegroupstaggingapi.GetResourcesOutput{ + { + ResourceTagMappingList: []tagtypes.ResourceTagMapping{ + { + ResourceARN: awssdk.String(ecsARN), + Tags: []tagtypes.Tag{{Key: awssdk.String(ownershipTagKey), Value: awssdk.String("workflow")}}, + }, + { + ResourceARN: awssdk.String(rdsARN), + Tags: []tagtypes.Tag{{Key: awssdk.String(ownershipTagKey), Value: awssdk.String("workflow")}}, + }, + }, + }, + }, + } + p := initializedOwnershipProvider(client) + + owners, err := p.ListOwners(context.Background(), interfaces.OwnerFilter{Owner: "workflow", ResourceType: "infra.container_service"}) + if err != nil { + t.Fatalf("ListOwners: %v", err) + } + if len(owners) != 1 { + t.Fatalf("owners len = %d, want 1: %#v", len(owners), owners) + } + got := owners[0] + if got.Owner != "workflow" || got.Source != ownershipTagSource { + t.Fatalf("owner metadata = %#v, want owner workflow source %q", got, ownershipTagSource) + } + if got.Ref.Name != "api" || got.Ref.Type != "infra.container_service" || got.Ref.ProviderID != ecsARN { + t.Fatalf("ref = %#v, want api infra.container_service %s", got.Ref, ecsARN) + } + if len(client.getInputs) != 1 { + t.Fatalf("GetResources calls = %d, want 1", len(client.getInputs)) + } + if len(client.getInputs[0].TagFilters) != 1 || awssdk.ToString(client.getInputs[0].TagFilters[0].Key) != ownershipTagKey { + t.Fatalf("TagFilters = %#v, want %q filter", client.getInputs[0].TagFilters, ownershipTagKey) + } + if gotValues := client.getInputs[0].TagFilters[0].Values; len(gotValues) != 1 || gotValues[0] != "workflow" { + t.Fatalf("TagFilter values = %v, want [workflow]", gotValues) + } +} + +func initializedOwnershipProvider(client ownershipTaggingClient) *AWSProvider { + return &AWSProvider{ + initialized: true, + ownershipClient: client, + } +} diff --git a/provider/provider.go b/provider/provider.go index d450655..6e80c0e 100644 --- a/provider/provider.go +++ b/provider/provider.go @@ -8,6 +8,7 @@ import ( "time" awssdk "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi" "github.com/GoCodeAlone/workflow-plugin-aws/drivers" "github.com/GoCodeAlone/workflow-plugin-aws/internal/awscreds" @@ -30,6 +31,8 @@ type AWSProvider struct { region string cfg awssdk.Config driverMap map[string]interfaces.ResourceDriver + + ownershipClient ownershipTaggingClient } // NewAWSProvider creates a new AWS provider. @@ -125,6 +128,7 @@ func (p *AWSProvider) Initialize(ctx context.Context, config map[string]any) err return fmt.Errorf("aws: load config: %w", err) } p.cfg = cfg + p.ownershipClient = resourcegroupstaggingapi.NewFromConfig(cfg) ecsCluster, _ := config["ecs_cluster"].(string) p.registerDrivers(cfg, ecsCluster, region)