diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 81502bf..395b7ae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,3 +29,19 @@ jobs: - name: Vet run: go vet ./... + + wfctl-strict-contracts: + name: Strict Contract Validation + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Validate strict plugin contracts + run: go run github.com/GoCodeAlone/workflow/cmd/wfctl@v0.20.1 plugin validate --file plugin.json --strict-contracts diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 6f12f25..aff5d26 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -6,6 +6,7 @@ before: hooks: - go mod tidy - "sed -i.bak 's/\"version\": \".*\"/\"version\": \"{{ .Version }}\"/' plugin.json && rm -f plugin.json.bak" + - "sed -E -i.bak 's|releases/download/v[0-9]+\\.[0-9]+\\.[0-9]+/workflow-plugin-aws_[0-9]+\\.[0-9]+\\.[0-9]+_|releases/download/v{{ .Version }}/workflow-plugin-aws_{{ .Version }}_|g' plugin.json && rm -f plugin.json.bak" builds: - id: workflow-plugin-aws @@ -28,6 +29,7 @@ archives: name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" files: - plugin.json + - plugin.contracts.json - LICENSE checksum: diff --git a/drivers/acm.go b/drivers/acm.go index 83b55a7..4586729 100644 --- a/drivers/acm.go +++ b/drivers/acm.go @@ -21,6 +21,7 @@ type ACMClient interface { // ACMDriver manages ACM certificates (infra.certificate). type ACMDriver struct { + noSensitiveKeys client ACMClient } diff --git a/drivers/alb.go b/drivers/alb.go index 903bdef..92dbb7c 100644 --- a/drivers/alb.go +++ b/drivers/alb.go @@ -21,6 +21,7 @@ type ELBv2Client interface { // ALBDriver manages Application/Network Load Balancers (infra.load_balancer). type ALBDriver struct { + noSensitiveKeys client ELBv2Client } diff --git a/drivers/apigateway.go b/drivers/apigateway.go index b653112..3405d2d 100644 --- a/drivers/apigateway.go +++ b/drivers/apigateway.go @@ -22,6 +22,7 @@ type APIGatewayClient interface { // APIGatewayDriver manages API Gateway v2 APIs (infra.api_gateway). type APIGatewayDriver struct { + noSensitiveKeys client APIGatewayClient } diff --git a/drivers/ecr.go b/drivers/ecr.go index 5d7062f..428ecd9 100644 --- a/drivers/ecr.go +++ b/drivers/ecr.go @@ -21,6 +21,7 @@ type ECRClient interface { // ECRDriver manages ECR repositories (infra.registry). type ECRDriver struct { + noSensitiveKeys client ECRClient } diff --git a/drivers/ecs.go b/drivers/ecs.go index d9e7e3f..c39ab24 100644 --- a/drivers/ecs.go +++ b/drivers/ecs.go @@ -23,6 +23,7 @@ type ECSClient interface { // ECSDriver manages ECS Fargate services (infra.container_service). type ECSDriver struct { + noSensitiveKeys client ECSClient cluster string } diff --git a/drivers/eks.go b/drivers/eks.go index 01bc191..e0c1335 100644 --- a/drivers/eks.go +++ b/drivers/eks.go @@ -21,6 +21,7 @@ type EKSClient interface { // EKSDriver manages EKS clusters (infra.k8s_cluster). type EKSDriver struct { + noSensitiveKeys client EKSClient } diff --git a/drivers/elasticache.go b/drivers/elasticache.go index 7c1a4c2..309febb 100644 --- a/drivers/elasticache.go +++ b/drivers/elasticache.go @@ -21,6 +21,7 @@ type ElastiCacheClient interface { // ElastiCacheDriver manages ElastiCache replication groups (infra.cache). type ElastiCacheDriver struct { + noSensitiveKeys client ElastiCacheClient } diff --git a/drivers/helpers.go b/drivers/helpers.go index 10ecddc..8cf0828 100644 --- a/drivers/helpers.go +++ b/drivers/helpers.go @@ -6,6 +6,12 @@ import ( "github.com/GoCodeAlone/workflow/interfaces" ) +// noSensitiveKeys is a zero-size mixin that satisfies the SensitiveKeys method +// of interfaces.ResourceDriver for drivers that have no sensitive output keys. +type noSensitiveKeys struct{} + +func (noSensitiveKeys) SensitiveKeys() []string { return nil } + // strPtr returns a pointer to the given string. func strPtr(s string) *string { return &s } diff --git a/drivers/iam.go b/drivers/iam.go index 91c8df9..882bc34 100644 --- a/drivers/iam.go +++ b/drivers/iam.go @@ -24,6 +24,7 @@ type IAMClient interface { // IAMDriver manages IAM roles and policies (infra.iam_role). type IAMDriver struct { + noSensitiveKeys client IAMClient } diff --git a/drivers/rds.go b/drivers/rds.go index 5b415e6..d93592a 100644 --- a/drivers/rds.go +++ b/drivers/rds.go @@ -21,6 +21,7 @@ type RDSClient interface { // RDSDriver manages RDS database instances (infra.database). type RDSDriver struct { + noSensitiveKeys client RDSClient } diff --git a/drivers/route53.go b/drivers/route53.go index 6a62c2e..2876d24 100644 --- a/drivers/route53.go +++ b/drivers/route53.go @@ -22,6 +22,7 @@ type Route53Client interface { // Route53Driver manages Route53 hosted zones (infra.dns). type Route53Driver struct { + noSensitiveKeys client Route53Client } diff --git a/drivers/s3.go b/drivers/s3.go index 607ea80..78d37dc 100644 --- a/drivers/s3.go +++ b/drivers/s3.go @@ -23,6 +23,7 @@ type S3Client interface { // S3Driver manages S3 buckets (infra.storage). type S3Driver struct { + noSensitiveKeys client S3Client region string } diff --git a/drivers/sg.go b/drivers/sg.go index b9db268..70d60d9 100644 --- a/drivers/sg.go +++ b/drivers/sg.go @@ -23,6 +23,7 @@ type SGClient interface { // SecurityGroupDriver manages EC2 security groups (infra.firewall). type SecurityGroupDriver struct { + noSensitiveKeys client SGClient } diff --git a/drivers/vpc.go b/drivers/vpc.go index 7d4e9d3..2a50c17 100644 --- a/drivers/vpc.go +++ b/drivers/vpc.go @@ -21,6 +21,7 @@ type VPCClient interface { // VPCDriver manages AWS VPC resources (infra.vpc). type VPCDriver struct { + noSensitiveKeys client VPCClient } diff --git a/go.mod b/go.mod index 9de8270..e9267ae 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/rds v1.115.0 github.com/aws/aws-sdk-go-v2/service/route53 v1.62.5 github.com/aws/aws-sdk-go-v2/service/s3 v1.97.2 + google.golang.org/protobuf v1.36.11 ) require ( @@ -226,7 +227,6 @@ require ( google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d // indirect google.golang.org/grpc v1.80.0 // indirect - google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/libc v1.70.0 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/internal/contracts/aws.pb.go b/internal/contracts/aws.pb.go new file mode 100644 index 0000000..a8147c9 --- /dev/null +++ b/internal/contracts/aws.pb.go @@ -0,0 +1,169 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.4 +// protoc v3.21.12 +// source: internal/contracts/aws.proto + +package contracts + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// AWSProviderConfig is the typed configuration for the iac.provider module +// provided by workflow-plugin-aws. All fields correspond to the map keys +// accepted by the legacy Initialize(ctx, map[string]any) path. +type AWSProviderConfig struct { + state protoimpl.MessageState `protogen:"open.v1"` + // region is the AWS region (default: us-east-1). + Region string `protobuf:"bytes,1,opt,name=region,proto3" json:"region,omitempty"` + // access_key_id is the AWS access key ID for static credentials. + AccessKeyId string `protobuf:"bytes,2,opt,name=access_key_id,json=accessKeyId,proto3" json:"access_key_id,omitempty"` + // secret_access_key is the AWS secret access key for static credentials. + SecretAccessKey string `protobuf:"bytes,3,opt,name=secret_access_key,json=secretAccessKey,proto3" json:"secret_access_key,omitempty"` + // ecs_cluster is the default ECS cluster name used by the ECS driver. + EcsCluster string `protobuf:"bytes,4,opt,name=ecs_cluster,json=ecsCluster,proto3" json:"ecs_cluster,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AWSProviderConfig) Reset() { + *x = AWSProviderConfig{} + mi := &file_internal_contracts_aws_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AWSProviderConfig) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AWSProviderConfig) ProtoMessage() {} + +func (x *AWSProviderConfig) ProtoReflect() protoreflect.Message { + mi := &file_internal_contracts_aws_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AWSProviderConfig.ProtoReflect.Descriptor instead. +func (*AWSProviderConfig) Descriptor() ([]byte, []int) { + return file_internal_contracts_aws_proto_rawDescGZIP(), []int{0} +} + +func (x *AWSProviderConfig) GetRegion() string { + if x != nil { + return x.Region + } + return "" +} + +func (x *AWSProviderConfig) GetAccessKeyId() string { + if x != nil { + return x.AccessKeyId + } + return "" +} + +func (x *AWSProviderConfig) GetSecretAccessKey() string { + if x != nil { + return x.SecretAccessKey + } + return "" +} + +func (x *AWSProviderConfig) GetEcsCluster() string { + if x != nil { + return x.EcsCluster + } + return "" +} + +var File_internal_contracts_aws_proto protoreflect.FileDescriptor + +var file_internal_contracts_aws_proto_rawDesc = string([]byte{ + 0x0a, 0x1c, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x63, 0x6f, 0x6e, 0x74, 0x72, + 0x61, 0x63, 0x74, 0x73, 0x2f, 0x61, 0x77, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x17, + 0x77, 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x73, + 0x2e, 0x61, 0x77, 0x73, 0x2e, 0x76, 0x31, 0x22, 0x9c, 0x01, 0x0a, 0x11, 0x41, 0x57, 0x53, 0x50, + 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x16, 0x0a, + 0x06, 0x72, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, + 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x12, 0x22, 0x0a, 0x0d, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x5f, + 0x6b, 0x65, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x61, 0x63, + 0x63, 0x65, 0x73, 0x73, 0x4b, 0x65, 0x79, 0x49, 0x64, 0x12, 0x2a, 0x0a, 0x11, 0x73, 0x65, 0x63, + 0x72, 0x65, 0x74, 0x5f, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x41, 0x63, 0x63, 0x65, + 0x73, 0x73, 0x4b, 0x65, 0x79, 0x12, 0x1f, 0x0a, 0x0b, 0x65, 0x63, 0x73, 0x5f, 0x63, 0x6c, 0x75, + 0x73, 0x74, 0x65, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x65, 0x63, 0x73, 0x43, + 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x42, 0x49, 0x5a, 0x47, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, + 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x47, 0x6f, 0x43, 0x6f, 0x64, 0x65, 0x41, 0x6c, 0x6f, 0x6e, 0x65, + 0x2f, 0x77, 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x2d, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, + 0x2d, 0x61, 0x77, 0x73, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x63, 0x6f, + 0x6e, 0x74, 0x72, 0x61, 0x63, 0x74, 0x73, 0x3b, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x61, 0x63, 0x74, + 0x73, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +}) + +var ( + file_internal_contracts_aws_proto_rawDescOnce sync.Once + file_internal_contracts_aws_proto_rawDescData []byte +) + +func file_internal_contracts_aws_proto_rawDescGZIP() []byte { + file_internal_contracts_aws_proto_rawDescOnce.Do(func() { + file_internal_contracts_aws_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_internal_contracts_aws_proto_rawDesc), len(file_internal_contracts_aws_proto_rawDesc))) + }) + return file_internal_contracts_aws_proto_rawDescData +} + +var file_internal_contracts_aws_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_internal_contracts_aws_proto_goTypes = []any{ + (*AWSProviderConfig)(nil), // 0: workflow.plugins.aws.v1.AWSProviderConfig +} +var file_internal_contracts_aws_proto_depIdxs = []int32{ + 0, // [0:0] is the sub-list for method output_type + 0, // [0:0] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_internal_contracts_aws_proto_init() } +func file_internal_contracts_aws_proto_init() { + if File_internal_contracts_aws_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_internal_contracts_aws_proto_rawDesc), len(file_internal_contracts_aws_proto_rawDesc)), + NumEnums: 0, + NumMessages: 1, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_internal_contracts_aws_proto_goTypes, + DependencyIndexes: file_internal_contracts_aws_proto_depIdxs, + MessageInfos: file_internal_contracts_aws_proto_msgTypes, + }.Build() + File_internal_contracts_aws_proto = out.File + file_internal_contracts_aws_proto_goTypes = nil + file_internal_contracts_aws_proto_depIdxs = nil +} diff --git a/internal/contracts/aws.proto b/internal/contracts/aws.proto new file mode 100644 index 0000000..f5c1c9b --- /dev/null +++ b/internal/contracts/aws.proto @@ -0,0 +1,19 @@ +syntax = "proto3"; + +package workflow.plugins.aws.v1; + +option go_package = "github.com/GoCodeAlone/workflow-plugin-aws/internal/contracts;contracts"; + +// AWSProviderConfig is the typed configuration for the iac.provider module +// provided by workflow-plugin-aws. All fields correspond to the map keys +// accepted by the legacy Initialize(ctx, map[string]any) path. +message AWSProviderConfig { + // region is the AWS region (default: us-east-1). + string region = 1; + // access_key_id is the AWS access key ID for static credentials. + string access_key_id = 2; + // secret_access_key is the AWS secret access key for static credentials. + string secret_access_key = 3; + // ecs_cluster is the default ECS cluster name used by the ECS driver. + string ecs_cluster = 4; +} diff --git a/internal/plugin.go b/internal/plugin.go index eeb56ad..f287b9c 100644 --- a/internal/plugin.go +++ b/internal/plugin.go @@ -4,10 +4,21 @@ package internal import ( "fmt" + "github.com/GoCodeAlone/workflow-plugin-aws/internal/contracts" "github.com/GoCodeAlone/workflow-plugin-aws/provider" + pb "github.com/GoCodeAlone/workflow/plugin/external/proto" sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" + "google.golang.org/protobuf/reflect/protodesc" + "google.golang.org/protobuf/types/descriptorpb" + "google.golang.org/protobuf/types/known/anypb" ) +// moduleTypeIaCProvider is the canonical module-type name for the AWS IaC +// provider. It is used in ModuleTypes, TypedModuleTypes, CreateModule, +// CreateTypedModule, ContractRegistry, plugin.json, and plugin.contracts.json. +// Keeping it in one place prevents the names from drifting. +const moduleTypeIaCProvider = "iac.provider" + // Version is set at build time via -ldflags // "-X github.com/GoCodeAlone/workflow-plugin-aws/internal.Version=X.Y.Z". // Default is a bare semver so plugin loaders that validate semver accept @@ -30,15 +41,72 @@ func (p *awsPlugin) Manifest() sdk.PluginManifest { } } +// ModuleTypes returns the module type names this plugin provides. func (p *awsPlugin) ModuleTypes() []string { - return []string{"iac.provider"} + return []string{moduleTypeIaCProvider} } +// CreateModule creates a module instance of the given type using a legacy +// map-based config. Prefer CreateTypedModule for strict typed config. func (p *awsPlugin) CreateModule(typeName, name string, config map[string]any) (sdk.ModuleInstance, error) { switch typeName { - case "iac.provider": + case moduleTypeIaCProvider: return newIaCProviderModule(name, config), nil default: return nil, fmt.Errorf("unknown module type: %s", typeName) } } + +// TypedModuleTypes returns the module type names for which strict typed config +// is supported. +func (p *awsPlugin) TypedModuleTypes() []string { + return []string{moduleTypeIaCProvider} +} + +// CreateTypedModule creates a typed module instance after unpacking and +// validating the AWSProviderConfig protobuf Any payload. +func (p *awsPlugin) CreateTypedModule(typeName, name string, config *anypb.Any) (sdk.ModuleInstance, error) { + factory := sdk.NewTypedModuleFactory( + moduleTypeIaCProvider, + &contracts.AWSProviderConfig{}, + func(name string, cfg *contracts.AWSProviderConfig) (sdk.ModuleInstance, error) { + // Reject a one-sided static-credential pair: supplying only one of + // access_key_id / secret_access_key would silently fall back to the + // ambient AWS credential chain and potentially deploy to the wrong + // account. + hasKey := cfg.GetAccessKeyId() != "" + hasSecret := cfg.GetSecretAccessKey() != "" + if hasKey != hasSecret { + return nil, fmt.Errorf("aws: access_key_id and secret_access_key must both be set or both be empty") + } + legacyConfig := map[string]any{ + "region": cfg.GetRegion(), + "access_key_id": cfg.GetAccessKeyId(), + "secret_access_key": cfg.GetSecretAccessKey(), + "ecs_cluster": cfg.GetEcsCluster(), + } + return newIaCProviderModule(name, legacyConfig), nil + }, + ) + return factory.CreateTypedModule(typeName, name, config) +} + +// ContractRegistry returns strict protobuf contract descriptors for every +// module type this plugin advertises. +func (p *awsPlugin) ContractRegistry() *pb.ContractRegistry { + return &pb.ContractRegistry{ + Contracts: []*pb.ContractDescriptor{ + { + Kind: pb.ContractKind_CONTRACT_KIND_MODULE, + ModuleType: moduleTypeIaCProvider, + ConfigMessage: "workflow.plugins.aws.v1.AWSProviderConfig", + Mode: pb.ContractMode_CONTRACT_MODE_STRICT_PROTO, + }, + }, + FileDescriptorSet: &descriptorpb.FileDescriptorSet{ + File: []*descriptorpb.FileDescriptorProto{ + protodesc.ToFileDescriptorProto(contracts.File_internal_contracts_aws_proto), + }, + }, + } +} diff --git a/internal/plugin_test.go b/internal/plugin_test.go new file mode 100644 index 0000000..89a1a7d --- /dev/null +++ b/internal/plugin_test.go @@ -0,0 +1,299 @@ +package internal + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/GoCodeAlone/workflow-plugin-aws/internal/contracts" + pb "github.com/GoCodeAlone/workflow/plugin/external/proto" + sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" + "google.golang.org/protobuf/reflect/protodesc" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/types/known/anypb" + "google.golang.org/protobuf/types/known/wrapperspb" +) + +func TestAWSPluginImplementsStrictContractProviders(t *testing.T) { + provider := NewAWSPlugin() + if _, ok := provider.(sdk.TypedModuleProvider); !ok { + t.Fatal("expected TypedModuleProvider") + } + if _, ok := provider.(sdk.ContractProvider); !ok { + t.Fatal("expected ContractProvider") + } +} + +func TestContractRegistryDeclaresStrictModuleContracts(t *testing.T) { + provider := NewAWSPlugin().(sdk.ContractProvider) + registry := provider.ContractRegistry() + if registry == nil { + t.Fatal("expected contract registry") + } + if registry.FileDescriptorSet == nil || len(registry.FileDescriptorSet.File) == 0 { + t.Fatal("expected file descriptor set") + } + files, err := protodesc.NewFiles(registry.FileDescriptorSet) + if err != nil { + t.Fatalf("descriptor set: %v", err) + } + + manifestContracts := loadManifestContracts(t) + contractsByKey := map[string]*pb.ContractDescriptor{} + for _, contract := range registry.Contracts { + if contract.Kind != pb.ContractKind_CONTRACT_KIND_MODULE { + t.Fatalf("unexpected contract kind %s", contract.Kind) + } + key := "module:" + contract.ModuleType + contractsByKey[key] = contract + if contract.Mode != pb.ContractMode_CONTRACT_MODE_STRICT_PROTO { + t.Fatalf("%s mode = %s, want strict proto", key, contract.Mode) + } + if contract.ConfigMessage == "" { + t.Fatalf("%s missing config message", key) + } + if _, err := files.FindDescriptorByName(protoreflect.FullName(contract.ConfigMessage)); err != nil { + t.Fatalf("%s references unknown config message %s: %v", key, contract.ConfigMessage, err) + } + if want, ok := manifestContracts[key]; !ok { + t.Fatalf("%s missing from plugin.contracts.json", key) + } else if want.ConfigMessage != contract.ConfigMessage { + t.Fatalf("%s manifest contract = %#v, runtime = %#v", key, want, contract) + } + } + + for _, moduleType := range pluginTypedModuleTypes() { + key := "module:" + moduleType + if _, ok := contractsByKey[key]; !ok { + t.Fatalf("missing contract %s", key) + } + } + if len(manifestContracts) != len(contractsByKey) { + t.Fatalf("plugin.contracts.json contract count = %d, runtime = %d", len(manifestContracts), len(contractsByKey)) + } +} + +func TestTypedModuleProviderValidatesTypedConfig(t *testing.T) { + provider := NewAWSPlugin().(sdk.TypedModuleProvider) + config, err := anypb.New(&contracts.AWSProviderConfig{ + Region: "us-east-1", + EcsCluster: "my-cluster", + }) + if err != nil { + t.Fatalf("pack config: %v", err) + } + module, err := provider.CreateTypedModule("iac.provider", "aws", config) + if err != nil { + t.Fatalf("CreateTypedModule: %v", err) + } + if module == nil { + t.Fatal("expected non-nil module") + } +} + +func TestTypedModuleProviderRejectsWrongType(t *testing.T) { + provider := NewAWSPlugin().(sdk.TypedModuleProvider) + config, err := anypb.New(&contracts.AWSProviderConfig{Region: "us-east-1"}) + if err != nil { + t.Fatalf("pack config: %v", err) + } + // Reject unknown module type name. + if _, err := provider.CreateTypedModule("iac.unknown", "x", config); err == nil { + t.Fatal("CreateTypedModule accepted unknown module type") + } + + // Reject correct module type but wrong proto message payload. + wrongConfig, err := anypb.New(wrapperspb.String("bad-payload")) + if err != nil { + t.Fatalf("pack wrong config: %v", err) + } + if _, err := provider.CreateTypedModule("iac.provider", "x", wrongConfig); err == nil { + t.Fatal("CreateTypedModule accepted wrong proto message type for iac.provider") + } +} + +func TestTypedModuleProviderConfigMapsToLegacyModule(t *testing.T) { + provider := NewAWSPlugin().(sdk.TypedModuleProvider) + config, err := anypb.New(&contracts.AWSProviderConfig{ + Region: "eu-west-1", + AccessKeyId: "AKID", + SecretAccessKey: "SECRET", + EcsCluster: "prod", + }) + if err != nil { + t.Fatalf("pack config: %v", err) + } + module, err := provider.CreateTypedModule("iac.provider", "aws", config) + if err != nil { + t.Fatalf("CreateTypedModule: %v", err) + } + wrapped, ok := module.(*sdk.TypedModuleInstance[*contracts.AWSProviderConfig]) + if !ok { + t.Fatalf("module type = %T, want *sdk.TypedModuleInstance[*contracts.AWSProviderConfig]", module) + } + legacy, ok := wrapped.ModuleInstance.(*iacProviderModule) + if !ok { + t.Fatalf("wrapped module type = %T, want *iacProviderModule", wrapped.ModuleInstance) + } + if got := legacy.config["region"]; got != "eu-west-1" { + t.Fatalf("region = %q, want eu-west-1", got) + } + if got := legacy.config["access_key_id"]; got != "AKID" { + t.Fatalf("access_key_id = %q, want AKID", got) + } + if got := legacy.config["ecs_cluster"]; got != "prod" { + t.Fatalf("ecs_cluster = %q, want prod", got) + } + if got := legacy.config["secret_access_key"]; got != "SECRET" { + t.Fatalf("secret_access_key = %q, want SECRET", got) + } +} + +// pluginTypedModuleTypes calls TypedModuleTypes() on a fresh plugin instance. +// It is called lazily within tests rather than at package init to avoid side +// effects during test binary loading. +func pluginTypedModuleTypes() []string { + return NewAWSPlugin().(sdk.TypedModuleProvider).TypedModuleTypes() +} + +// TestPluginManifestModuleTypesInSync verifies that plugin.json's +// capabilities.moduleTypes list exactly matches the runtime TypedModuleTypes(), +// so a future rename cannot leave the discovery metadata stale. +func TestPluginManifestModuleTypesInSync(t *testing.T) { + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + data, err := os.ReadFile(filepath.Join(filepath.Dir(file), "..", "plugin.json")) + if err != nil { + t.Fatalf("read plugin.json: %v", err) + } + var manifest struct { + Capabilities struct { + ModuleTypes []string `json:"moduleTypes"` + } `json:"capabilities"` + } + if err := json.Unmarshal(data, &manifest); err != nil { + t.Fatalf("parse plugin.json: %v", err) + } + runtimeTypes := pluginTypedModuleTypes() + manifestSet := make(map[string]bool, len(manifest.Capabilities.ModuleTypes)) + for _, mt := range manifest.Capabilities.ModuleTypes { + manifestSet[mt] = true + } + runtimeSet := make(map[string]bool, len(runtimeTypes)) + for _, mt := range runtimeTypes { + runtimeSet[mt] = true + } + for _, mt := range runtimeTypes { + if !manifestSet[mt] { + t.Errorf("TypedModuleTypes() has %q but plugin.json capabilities.moduleTypes does not", mt) + } + } + for _, mt := range manifest.Capabilities.ModuleTypes { + if !runtimeSet[mt] { + t.Errorf("plugin.json capabilities.moduleTypes has %q but TypedModuleTypes() does not", mt) + } + } +} + +// TestTypedModuleProviderRejectsPartialCredentials verifies that supplying +// only one of access_key_id / secret_access_key is rejected with an error, +// preventing a silent fallback to the ambient AWS credential chain. +func TestTypedModuleProviderRejectsPartialCredentials(t *testing.T) { + p := NewAWSPlugin().(sdk.TypedModuleProvider) + + // Only access_key_id set — should fail. + config, err := anypb.New(&contracts.AWSProviderConfig{ + Region: "us-east-1", + AccessKeyId: "AKID", + }) + if err != nil { + t.Fatalf("pack config: %v", err) + } + if _, err := p.CreateTypedModule("iac.provider", "x", config); err == nil { + t.Error("CreateTypedModule accepted config with access_key_id but no secret_access_key") + } + + // Only secret_access_key set — should fail. + config2, err := anypb.New(&contracts.AWSProviderConfig{ + Region: "us-east-1", + SecretAccessKey: "SECRET", + }) + if err != nil { + t.Fatalf("pack config2: %v", err) + } + if _, err := p.CreateTypedModule("iac.provider", "x", config2); err == nil { + t.Error("CreateTypedModule accepted config with secret_access_key but no access_key_id") + } + + // Both set — should succeed. + config3, err := anypb.New(&contracts.AWSProviderConfig{ + Region: "us-east-1", + AccessKeyId: "AKID", + SecretAccessKey: "SECRET", + }) + if err != nil { + t.Fatalf("pack config3: %v", err) + } + if _, err := p.CreateTypedModule("iac.provider", "x", config3); err != nil { + t.Errorf("CreateTypedModule rejected valid full credential pair: %v", err) + } + + // Neither set — should succeed (uses ambient credentials). + config4, err := anypb.New(&contracts.AWSProviderConfig{Region: "us-east-1"}) + if err != nil { + t.Fatalf("pack config4: %v", err) + } + if _, err := p.CreateTypedModule("iac.provider", "x", config4); err != nil { + t.Errorf("CreateTypedModule rejected config with no static credentials: %v", err) + } +} + +type manifestContract struct { + Mode string `json:"mode"` + ConfigMessage string `json:"config"` +} + +func loadManifestContracts(t *testing.T) map[string]manifestContract { + t.Helper() + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + data, err := os.ReadFile(filepath.Join(filepath.Dir(file), "..", "plugin.contracts.json")) + if err != nil { + t.Fatalf("read plugin.contracts.json: %v", err) + } + var manifest struct { + Version string `json:"version"` + Contracts []struct { + Kind string `json:"kind"` + Type string `json:"type"` + manifestContract + } `json:"contracts"` + } + if err := json.Unmarshal(data, &manifest); err != nil { + t.Fatalf("parse plugin.contracts.json: %v", err) + } + if manifest.Version != "v1" { + t.Fatalf("plugin.contracts.json version = %q, want v1", manifest.Version) + } + result := make(map[string]manifestContract, len(manifest.Contracts)) + for _, contract := range manifest.Contracts { + if contract.Kind != "module" { + t.Fatalf("unexpected contract kind %q in plugin.contracts.json", contract.Kind) + } + if contract.Mode != "strict" { + t.Fatalf("%s mode = %q, want strict", contract.Type, contract.Mode) + } + key := "module:" + contract.Type + if _, exists := result[key]; exists { + t.Fatalf("duplicate contract %q in plugin.contracts.json", key) + } + result[key] = contract.manifestContract + } + return result +} diff --git a/plugin.contracts.json b/plugin.contracts.json new file mode 100644 index 0000000..64dc0e5 --- /dev/null +++ b/plugin.contracts.json @@ -0,0 +1,11 @@ +{ + "version": "v1", + "contracts": [ + { + "kind": "module", + "type": "iac.provider", + "mode": "strict", + "config": "workflow.plugins.aws.v1.AWSProviderConfig" + } + ] +} diff --git a/plugin.json b/plugin.json index 1129cb6..8c4ae59 100644 --- a/plugin.json +++ b/plugin.json @@ -3,20 +3,51 @@ "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", - "provider": "aws", - "capabilities": [ - "infra.container_service", - "infra.k8s_cluster", - "infra.database", - "infra.cache", - "infra.vpc", - "infra.load_balancer", - "infra.dns", - "infra.registry", - "infra.api_gateway", - "infra.firewall", - "infra.iam_role", - "infra.storage", - "infra.certificate" + "license": "MIT", + "type": "external", + "tier": "community", + "minEngineVersion": "0.19.0", + "keywords": ["aws", "iac", "infrastructure", "ecs", "eks", "rds", "vpc", "s3"], + "homepage": "https://github.com/GoCodeAlone/workflow-plugin-aws", + "repository": "https://github.com/GoCodeAlone/workflow-plugin-aws", + "capabilities": { + "configProvider": false, + "moduleTypes": [ + "iac.provider" + ], + "stepTypes": [], + "triggerTypes": [] + }, + "downloads": [ + { + "os": "linux", + "arch": "amd64", + "url": "https://github.com/GoCodeAlone/workflow-plugin-aws/releases/download/v0.1.0/workflow-plugin-aws_0.1.0_linux_amd64.tar.gz" + }, + { + "os": "linux", + "arch": "arm64", + "url": "https://github.com/GoCodeAlone/workflow-plugin-aws/releases/download/v0.1.0/workflow-plugin-aws_0.1.0_linux_arm64.tar.gz" + }, + { + "os": "darwin", + "arch": "amd64", + "url": "https://github.com/GoCodeAlone/workflow-plugin-aws/releases/download/v0.1.0/workflow-plugin-aws_0.1.0_darwin_amd64.tar.gz" + }, + { + "os": "darwin", + "arch": "arm64", + "url": "https://github.com/GoCodeAlone/workflow-plugin-aws/releases/download/v0.1.0/workflow-plugin-aws_0.1.0_darwin_arm64.tar.gz" + }, + { + "os": "windows", + "arch": "amd64", + "url": "https://github.com/GoCodeAlone/workflow-plugin-aws/releases/download/v0.1.0/workflow-plugin-aws_0.1.0_windows_amd64.tar.gz" + }, + { + "os": "windows", + "arch": "arm64", + "url": "https://github.com/GoCodeAlone/workflow-plugin-aws/releases/download/v0.1.0/workflow-plugin-aws_0.1.0_windows_arm64.tar.gz" + } ] } diff --git a/provider/provider.go b/provider/provider.go index 7e9bdb6..e124f75 100644 --- a/provider/provider.go +++ b/provider/provider.go @@ -378,12 +378,16 @@ func (p *AWSProvider) ResolveSizing(resourceType string, size interfaces.Size, h return resolveSizing(resourceType, size, hints) } -// SupportedCanonicalKeys returns the full canonical IaC key set. Per the -// interfaces.IaCProvider doc, "built-in and stub providers return the full -// canonical key set"; this provider's drivers do not currently reject -// unsupported keys at the provider level. +// SupportedCanonicalKeys returns the full canonical IaC key set plus the +// AWS-specific keys accepted by this provider (access_key_id, secret_access_key, +// ecs_cluster). func (p *AWSProvider) SupportedCanonicalKeys() []string { - return interfaces.CanonicalKeys() + canonical := interfaces.CanonicalKeys() + awsSpecific := []string{"access_key_id", "secret_access_key", "ecs_cluster"} + result := make([]string, 0, len(canonical)+len(awsSpecific)) + result = append(result, canonical...) + result = append(result, awsSpecific...) + return result } // BootstrapStateBackend is a no-op for this provider; AWS S3 state backends diff --git a/provider/provider_test.go b/provider/provider_test.go index 9161c41..f3af0df 100644 --- a/provider/provider_test.go +++ b/provider/provider_test.go @@ -1,6 +1,7 @@ package provider_test import ( + "context" "strings" "testing" @@ -105,3 +106,38 @@ func TestAWSProvider_ResourceDriver_UnknownType(t *testing.T) { t.Errorf("expected 'no driver for resource type' error, got: %v", err) } } + +func TestAWSProvider_SupportedCanonicalKeys(t *testing.T) { + p := provider.NewAWSProvider() + keys := p.SupportedCanonicalKeys() + if len(keys) == 0 { + t.Fatal("expected at least one canonical key") + } + keySet := make(map[string]bool, len(keys)) + for _, k := range keys { + keySet[k] = true + } + // Must include the full canonical key set. + for _, required := range interfaces.CanonicalKeys() { + if !keySet[required] { + t.Errorf("SupportedCanonicalKeys missing canonical key %q", required) + } + } + // Must also include the AWS-specific credential and cluster keys. + for _, required := range []string{"access_key_id", "secret_access_key", "ecs_cluster"} { + if !keySet[required] { + t.Errorf("SupportedCanonicalKeys missing AWS-specific key %q", required) + } + } +} + +func TestAWSProvider_BootstrapStateBackend(t *testing.T) { + p := provider.NewAWSProvider() + result, err := p.BootstrapStateBackend(context.Background(), nil) + if err != nil { + t.Fatalf("BootstrapStateBackend: unexpected error: %v", err) + } + if result != nil { + t.Fatalf("BootstrapStateBackend: expected nil result for no-op provider, got %v", result) + } +}