diff --git a/cli.go b/cli.go index 23e2260d7..3f73977a8 100644 --- a/cli.go +++ b/cli.go @@ -189,6 +189,7 @@ import ( transcribebackend "github.com/blackbirdworks/gopherstack/services/transcribe" transferbackend "github.com/blackbirdworks/gopherstack/services/transfer" verifiedpermissionsbackend "github.com/blackbirdworks/gopherstack/services/verifiedpermissions" + vpclatticebackend "github.com/blackbirdworks/gopherstack/services/vpclattice" wafbackend "github.com/blackbirdworks/gopherstack/services/waf" wafv2backend "github.com/blackbirdworks/gopherstack/services/wafv2" workmailbackend "github.com/blackbirdworks/gopherstack/services/workmail" @@ -2764,6 +2765,7 @@ func getMostRecentServiceProviders() []service.Provider { &detectivebackend.Provider{}, &datasyncbackend.Provider{}, &fsxbackend.Provider{}, + &vpclatticebackend.Provider{}, &omicsbackend.Provider{}, } } diff --git a/go.mod b/go.mod index e46977fd7..555657a79 100644 --- a/go.mod +++ b/go.mod @@ -202,6 +202,8 @@ require ( github.com/aws/aws-sdk-go-v2/service/appstream v1.60.3 ) +require github.com/aws/aws-sdk-go-v2/service/vpclattice v1.22.2 + require github.com/aws/aws-sdk-go-v2/service/networkmonitor v1.14.6 require github.com/aws/aws-sdk-go-v2/service/omics v1.45.0 diff --git a/go.sum b/go.sum index 9df608d85..381a7914a 100644 --- a/go.sum +++ b/go.sum @@ -348,6 +348,8 @@ github.com/aws/aws-sdk-go-v2/service/translate v1.34.2 h1:wOeKaa4YkLvAfueCoVNGb6 github.com/aws/aws-sdk-go-v2/service/translate v1.34.2/go.mod h1:9sMvZzh9f34gFXrARkjL4FRKlBAwZWsrPWWq+oAUg/k= github.com/aws/aws-sdk-go-v2/service/verifiedpermissions v1.31.4 h1:n0jsEM3jPz0dtujoqreiuUJAEgGHIQcxekY7/kMqHqI= github.com/aws/aws-sdk-go-v2/service/verifiedpermissions v1.31.4/go.mod h1:cyCWU2gte7GgK6ALIQDeN/Uza9RvxcZCc1BcHQaYeTg= +github.com/aws/aws-sdk-go-v2/service/vpclattice v1.22.2 h1:q9xVCezxBqFAv/sJakd63MZ564MxOY8CDfWuiih9awM= +github.com/aws/aws-sdk-go-v2/service/vpclattice v1.22.2/go.mod h1:iMZnTuSYmfTgpqnzEni1JqeO0Ar/ZItPumptLBMb8/A= github.com/aws/aws-sdk-go-v2/service/waf v1.30.24 h1:oxcv9gZ55iV4WYDPwQ1k2HFro19Ik3vImFyNGM/K2CU= github.com/aws/aws-sdk-go-v2/service/waf v1.30.24/go.mod h1:ER7o7K1CKXhuDN4R/z+/qw+C9yo2CCgF9N9H3QXgZZk= github.com/aws/aws-sdk-go-v2/service/wafv2 v1.71.2 h1:1ZdTCoMSPSrxNCVL65/Qnn0VVMJUtoJKf2Pqnp3hddo= diff --git a/internal/teststack/teststack.go b/internal/teststack/teststack.go index 179f1bf01..c9461db7c 100644 --- a/internal/teststack/teststack.go +++ b/internal/teststack/teststack.go @@ -134,6 +134,7 @@ import ( transcribebackend "github.com/blackbirdworks/gopherstack/services/transcribe" transferbackend "github.com/blackbirdworks/gopherstack/services/transfer" verifiedpermissionsbackend "github.com/blackbirdworks/gopherstack/services/verifiedpermissions" + vpclatticebackend "github.com/blackbirdworks/gopherstack/services/vpclattice" wafv2backend "github.com/blackbirdworks/gopherstack/services/wafv2" xraybackend "github.com/blackbirdworks/gopherstack/services/xray" ) @@ -310,6 +311,8 @@ type Stack struct { TransferHandler *transferbackend.Handler // VerifiedPermissionsHandler provides access to the Verified Permissions backend. VerifiedPermissionsHandler *verifiedpermissionsbackend.Handler + // VpcLatticeHandler provides access to the VPC Lattice backend. + VpcLatticeHandler *vpclatticebackend.Handler // Wafv2Handler provides access to the WAFv2 backend. Wafv2Handler *wafv2backend.Handler // XrayHandler provides access to the X-Ray backend. @@ -561,6 +564,7 @@ func registerLatestServices(registry *service.Registry, h handlers) { _ = registry.Register(h.timestreamquery) _ = registry.Register(h.transfer) _ = registry.Register(h.verifiedpermissions) + _ = registry.Register(h.vpclattice) _ = registry.Register(h.wafv2) _ = registry.Register(h.xray) _ = registry.Register(h.s3tables) @@ -684,6 +688,7 @@ type handlers struct { timestreamquery *timestreamquerybackend.Handler transfer *transferbackend.Handler verifiedpermissions *verifiedpermissionsbackend.Handler + vpclattice *vpclatticebackend.Handler wafv2 *wafv2backend.Handler xray *xraybackend.Handler s3tables *s3tablesbackend.Handler @@ -1044,6 +1049,9 @@ func populateTransferHandlers(h *handlers) { h.verifiedpermissions = verifiedpermissionsbackend.NewHandler( verifiedpermissionsbackend.NewInMemoryBackend(config.DefaultAccountID, config.DefaultRegion), ) + h.vpclattice = vpclatticebackend.NewHandler( + vpclatticebackend.NewInMemoryBackend(config.DefaultAccountID, config.DefaultRegion), + ) h.xray = xraybackend.NewHandler(xraybackend.NewInMemoryBackend()) h.s3tables = s3tablesbackend.NewHandler( s3tablesbackend.NewInMemoryBackend(config.DefaultAccountID, config.DefaultRegion), @@ -1329,6 +1337,7 @@ func setNewestStackHandlers(s *Stack, h handlers) { s.TimestreamQueryHandler = h.timestreamquery s.TransferHandler = h.transfer s.VerifiedPermissionsHandler = h.verifiedpermissions + s.VpcLatticeHandler = h.vpclattice s.Wafv2Handler = h.wafv2 s.XrayHandler = h.xray s.S3TablesHandler = h.s3tables diff --git a/services/vpclattice/backend.go b/services/vpclattice/backend.go new file mode 100644 index 000000000..bc88e7ee5 --- /dev/null +++ b/services/vpclattice/backend.go @@ -0,0 +1,2252 @@ +package vpclattice + +import ( + "encoding/json" + "fmt" + "maps" + "slices" + "sort" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/blackbirdworks/gopherstack/pkgs/arn" + "github.com/blackbirdworks/gopherstack/pkgs/awserr" + "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" + "github.com/blackbirdworks/gopherstack/pkgs/page" +) + +const ( + arnService = "vpc-lattice" + resourceService = "service" + resourceServiceNetwork = "servicenetwork" + resourceServiceNetworkSvcAssoc = "servicenetworkserviceassociation" + resourceServiceNetworkVpcAssoc = "servicenetworkvpcassociation" + resourceListener = "listener" + resourceRule = "rule" + resourceTargetGroup = "targetgroup" + resourceAccessLogSubscription = "accesslogsubscription" + + idPrefixService = "svc-" + idPrefixNetwork = "sn-" + idPrefixSNSA = "snsa-" + idPrefixSNVA = "snva-" + idPrefixListener = "listener-" + idPrefixRule = "rule-" + idPrefixTargetGroup = "tg-" + idPrefixALS = "als-" + + statusActive = "ACTIVE" + statusInactive = "INACTIVE" + statusCreateInProgress = "CREATE_IN_PROGRESS" + statusDeleteInProgress = "DELETE_IN_PROGRESS" + statusDeleted = "DELETED" + statusCreateFailed = "CREATE_FAILED" + + authTypeNone = "NONE" + protocolHTTP = "HTTP" + protocolHTTPS = "HTTPS" + + tgStatusActive = "ACTIVE" + + targetStatusHealthy = "HEALTHY" + + authPolicyStateActive = "Active" + + defaultRulePriority = 100 + + defaultMaxResults = 100 +) + +var ( + // ErrNotFound is returned when a resource does not exist. + ErrNotFound = awserr.New("ResourceNotFoundException", awserr.ErrNotFound) + // ErrAlreadyExists is returned when a resource already exists with the same name. + ErrAlreadyExists = awserr.New("ConflictException", awserr.ErrAlreadyExists) + // ErrInvalidParameter is returned for invalid input. + ErrInvalidParameter = awserr.New("ValidationException", awserr.ErrInvalidParameter) +) + +// storedService holds a service with all fields. +type storedService struct { + CreatedAt time.Time `json:"createdAt"` + LastUpdatedAt time.Time `json:"lastUpdatedAt"` + Tags map[string]string `json:"tags"` + ARN string `json:"arn"` + ID string `json:"id"` + Name string `json:"name"` + AuthType string `json:"authType"` + CertificateArn string `json:"certificateArn"` + CustomDomainName string `json:"customDomainName"` + DNSName string `json:"dnsName"` + Status string `json:"status"` +} + +func (s *storedService) toService() *Service { + return &Service{ + ARN: s.ARN, + ID: s.ID, + Name: s.Name, + AuthType: s.AuthType, + CertificateArn: s.CertificateArn, + CustomDomainName: s.CustomDomainName, + DNSName: s.DNSName, + Status: s.Status, + CreatedAt: s.CreatedAt, + LastUpdatedAt: s.LastUpdatedAt, + } +} + +func (s *storedService) toSummary() *ServiceSummary { + return &ServiceSummary{ + ARN: s.ARN, + ID: s.ID, + Name: s.Name, + CustomDomainName: s.CustomDomainName, + DNSName: s.DNSName, + Status: s.Status, + CreatedAt: s.CreatedAt, + LastUpdatedAt: s.LastUpdatedAt, + } +} + +// storedServiceNetwork holds a service network. +type storedServiceNetwork struct { + CreatedAt time.Time `json:"createdAt"` + LastUpdatedAt time.Time `json:"lastUpdatedAt"` + Tags map[string]string `json:"tags"` + ARN string `json:"arn"` + ID string `json:"id"` + Name string `json:"name"` + AuthType string `json:"authType"` + NumberOfAssociatedServices int64 `json:"numberOfAssociatedServices"` + NumberOfAssociatedVPCs int64 `json:"numberOfAssociatedVpcs"` +} + +func (s *storedServiceNetwork) toServiceNetwork() *ServiceNetwork { + return &ServiceNetwork{ + ARN: s.ARN, + ID: s.ID, + Name: s.Name, + AuthType: s.AuthType, + NumberOfAssociatedServices: s.NumberOfAssociatedServices, + NumberOfAssociatedVPCs: s.NumberOfAssociatedVPCs, + CreatedAt: s.CreatedAt, + LastUpdatedAt: s.LastUpdatedAt, + } +} + +func (s *storedServiceNetwork) toSummary() *ServiceNetworkSummary { + return &ServiceNetworkSummary{ + ARN: s.ARN, + ID: s.ID, + Name: s.Name, + NumberOfAssociatedServices: s.NumberOfAssociatedServices, + NumberOfAssociatedVPCs: s.NumberOfAssociatedVPCs, + CreatedAt: s.CreatedAt, + } +} + +// storedSNSA holds a service network service association. +type storedSNSA struct { + CreatedAt time.Time `json:"createdAt"` + Tags map[string]string `json:"tags"` + ARN string `json:"arn"` + ID string `json:"id"` + ServiceARN string `json:"serviceArn"` + ServiceID string `json:"serviceId"` + ServiceName string `json:"serviceName"` + ServiceNetworkARN string `json:"serviceNetworkArn"` + ServiceNetworkID string `json:"serviceNetworkId"` + ServiceNetworkName string `json:"serviceNetworkName"` + Status string `json:"status"` + CreatedBy string `json:"createdBy"` + CustomDomainName string `json:"customDomainName"` + DNSName string `json:"dnsName"` +} + +func (s *storedSNSA) toAssociation() *ServiceNetworkServiceAssociation { + return &ServiceNetworkServiceAssociation{ + ARN: s.ARN, + ID: s.ID, + ServiceARN: s.ServiceARN, + ServiceID: s.ServiceID, + ServiceName: s.ServiceName, + ServiceNetworkARN: s.ServiceNetworkARN, + ServiceNetworkID: s.ServiceNetworkID, + ServiceNetworkName: s.ServiceNetworkName, + Status: s.Status, + CreatedBy: s.CreatedBy, + CustomDomainName: s.CustomDomainName, + DNSName: s.DNSName, + CreatedAt: s.CreatedAt, + } +} + +func (s *storedSNSA) toSummary() *ServiceNetworkServiceAssociationSummary { + return &ServiceNetworkServiceAssociationSummary{ + ARN: s.ARN, + ID: s.ID, + ServiceARN: s.ServiceARN, + ServiceID: s.ServiceID, + ServiceName: s.ServiceName, + ServiceNetworkARN: s.ServiceNetworkARN, + ServiceNetworkID: s.ServiceNetworkID, + ServiceNetworkName: s.ServiceNetworkName, + Status: s.Status, + CustomDomainName: s.CustomDomainName, + DNSName: s.DNSName, + CreatedAt: s.CreatedAt, + } +} + +// storedSNVA holds a service network VPC association. +type storedSNVA struct { + CreatedAt time.Time `json:"createdAt"` + LastUpdatedAt time.Time `json:"lastUpdatedAt"` + Tags map[string]string `json:"tags"` + ARN string `json:"arn"` + ID string `json:"id"` + VpcID string `json:"vpcId"` + ServiceNetworkARN string `json:"serviceNetworkArn"` + ServiceNetworkID string `json:"serviceNetworkId"` + ServiceNetworkName string `json:"serviceNetworkName"` + Status string `json:"status"` + CreatedBy string `json:"createdBy"` + SecurityGroupIDs []string `json:"securityGroupIds"` +} + +func (s *storedSNVA) toAssociation() *ServiceNetworkVpcAssociation { + sgs := make([]string, len(s.SecurityGroupIDs)) + copy(sgs, s.SecurityGroupIDs) + + return &ServiceNetworkVpcAssociation{ + ARN: s.ARN, + ID: s.ID, + VpcID: s.VpcID, + ServiceNetworkARN: s.ServiceNetworkARN, + ServiceNetworkID: s.ServiceNetworkID, + ServiceNetworkName: s.ServiceNetworkName, + SecurityGroupIDs: sgs, + Status: s.Status, + CreatedBy: s.CreatedBy, + CreatedAt: s.CreatedAt, + LastUpdatedAt: s.LastUpdatedAt, + } +} + +func (s *storedSNVA) toSummary() *ServiceNetworkVpcAssociationSummary { + return &ServiceNetworkVpcAssociationSummary{ + ARN: s.ARN, + ID: s.ID, + VpcID: s.VpcID, + ServiceNetworkARN: s.ServiceNetworkARN, + ServiceNetworkID: s.ServiceNetworkID, + ServiceNetworkName: s.ServiceNetworkName, + Status: s.Status, + CreatedAt: s.CreatedAt, + } +} + +// storedListener holds a listener. +type storedListener struct { + Tags map[string]string `json:"tags"` + DefaultAction *RuleAction `json:"defaultAction"` + CreatedAt time.Time `json:"createdAt"` + LastUpdatedAt time.Time `json:"lastUpdatedAt"` + ARN string `json:"arn"` + ID string `json:"id"` + ServiceARN string `json:"serviceArn"` + ServiceID string `json:"serviceId"` + Name string `json:"name"` + Protocol string `json:"protocol"` + Port int32 `json:"port"` +} + +func (l *storedListener) toListener() *Listener { + return &Listener{ + ARN: l.ARN, + ID: l.ID, + ServiceARN: l.ServiceARN, + ServiceID: l.ServiceID, + Name: l.Name, + Protocol: l.Protocol, + Port: l.Port, + DefaultAction: l.DefaultAction, + CreatedAt: l.CreatedAt, + LastUpdatedAt: l.LastUpdatedAt, + } +} + +func (l *storedListener) toSummary() *ListenerSummary { + return &ListenerSummary{ + ARN: l.ARN, + ID: l.ID, + Name: l.Name, + Protocol: l.Protocol, + Port: l.Port, + CreatedAt: l.CreatedAt, + LastUpdatedAt: l.LastUpdatedAt, + } +} + +// storedRule holds a listener rule. +type storedRule struct { + Tags map[string]string `json:"tags"` + Action *RuleAction `json:"action"` + Match *RuleMatch `json:"match"` + CreatedAt time.Time `json:"createdAt"` + LastUpdatedAt time.Time `json:"lastUpdatedAt"` + ARN string `json:"arn"` + ID string `json:"id"` + ListenerID string `json:"listenerId"` + ServiceID string `json:"serviceId"` + Name string `json:"name"` + Priority int32 `json:"priority"` + IsDefault bool `json:"isDefault"` +} + +func (r *storedRule) toRule() *Rule { + return &Rule{ + ARN: r.ARN, + ID: r.ID, + Name: r.Name, + Priority: r.Priority, + Action: r.Action, + Match: r.Match, + IsDefault: r.IsDefault, + CreatedAt: r.CreatedAt, + LastUpdatedAt: r.LastUpdatedAt, + } +} + +func (r *storedRule) toSummary() *RuleSummary { + return &RuleSummary{ + ARN: r.ARN, + ID: r.ID, + Name: r.Name, + Priority: r.Priority, + IsDefault: r.IsDefault, + } +} + +// storedTargetGroup holds a target group. +type storedTargetGroup struct { + CreatedAt time.Time `json:"createdAt"` + LastUpdatedAt time.Time `json:"lastUpdatedAt"` + Tags map[string]string `json:"tags"` + Config *TargetGroupConfig `json:"config"` + ARN string `json:"arn"` + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Status string `json:"status"` + ServiceARNs []string `json:"serviceArns"` +} + +func (tg *storedTargetGroup) toTargetGroup() *TargetGroup { + arns := make([]string, len(tg.ServiceARNs)) + copy(arns, tg.ServiceARNs) + + return &TargetGroup{ + ARN: tg.ARN, + ID: tg.ID, + Name: tg.Name, + Type: tg.Type, + Status: tg.Status, + Config: tg.Config, + ServiceARNs: arns, + CreatedAt: tg.CreatedAt, + LastUpdatedAt: tg.LastUpdatedAt, + } +} + +func (tg *storedTargetGroup) toSummary() *TargetGroupSummary { + s := &TargetGroupSummary{ + ARN: tg.ARN, + ID: tg.ID, + Name: tg.Name, + Type: tg.Type, + Status: tg.Status, + CreatedAt: tg.CreatedAt, + ServiceARNs: make([]string, len(tg.ServiceARNs)), + } + copy(s.ServiceARNs, tg.ServiceARNs) + + if tg.Config != nil { + s.Port = tg.Config.Port + s.Protocol = tg.Config.Protocol + s.VpcID = tg.Config.VpcID + } + + return s +} + +// storedTarget holds a registered target. +type storedTarget struct { + ID string `json:"id"` + Status string `json:"status"` + Port int32 `json:"port"` +} + +// storedALS holds an access log subscription. +type storedALS struct { + CreatedAt time.Time `json:"createdAt"` + LastUpdatedAt time.Time `json:"lastUpdatedAt"` + Tags map[string]string `json:"tags"` + ARN string `json:"arn"` + ID string `json:"id"` + ResourceARN string `json:"resourceArn"` + ResourceID string `json:"resourceId"` + DestinationARN string `json:"destinationArn"` + ServiceNetworkLogType string `json:"serviceNetworkLogType"` +} + +func (a *storedALS) toALS() *AccessLogSubscription { + return &AccessLogSubscription{ + ARN: a.ARN, + ID: a.ID, + ResourceARN: a.ResourceARN, + ResourceID: a.ResourceID, + DestinationARN: a.DestinationARN, + ServiceNetworkLogType: a.ServiceNetworkLogType, + CreatedAt: a.CreatedAt, + LastUpdatedAt: a.LastUpdatedAt, + } +} + +func (a *storedALS) toSummary() *AccessLogSubscriptionSummary { + return &AccessLogSubscriptionSummary{ + ARN: a.ARN, + ID: a.ID, + ResourceARN: a.ResourceARN, + ResourceID: a.ResourceID, + DestinationARN: a.DestinationARN, + CreatedAt: a.CreatedAt, + LastUpdatedAt: a.LastUpdatedAt, + } +} + +// snapshot is the serializable form of InMemoryBackend. +type snapshot struct { + Services map[string]*storedService `json:"services"` + ServiceNetworks map[string]*storedServiceNetwork `json:"serviceNetworks"` + SNSAs map[string]*storedSNSA `json:"snsas"` + SNVAs map[string]*storedSNVA `json:"snvas"` + Listeners map[string]*storedListener `json:"listeners"` + Rules map[string]*storedRule `json:"rules"` + TargetGroups map[string]*storedTargetGroup `json:"targetGroups"` + Targets map[string][]*storedTarget `json:"targets"` + ALSs map[string]*storedALS `json:"alss"` + AuthPolicies map[string]string `json:"authPolicies"` + ResourcePolicies map[string]string `json:"resourcePolicies"` + Tags map[string]map[string]string `json:"tags"` +} + +// InMemoryBackend is an in-memory implementation of StorageBackend. +type InMemoryBackend struct { + mu *lockmetrics.RWMutex + services map[string]*storedService + servicesByName map[string]string + serviceNetworks map[string]*storedServiceNetwork + networksByName map[string]string + snsas map[string]*storedSNSA + snvas map[string]*storedSNVA + listeners map[string]*storedListener + rules map[string]*storedRule + targetGroups map[string]*storedTargetGroup + tgsByName map[string]string + targets map[string][]*storedTarget + alss map[string]*storedALS + authPolicies map[string]string + resourcePolicies map[string]string + tags map[string]map[string]string + accountID string + region string +} + +// NewInMemoryBackend creates a new InMemoryBackend. +func NewInMemoryBackend(accountID, region string) *InMemoryBackend { + b := &InMemoryBackend{ + mu: lockmetrics.New("vpclattice"), + accountID: accountID, + region: region, + } + b.initMaps() + + return b +} + +func (b *InMemoryBackend) initMaps() { + b.services = make(map[string]*storedService) + b.servicesByName = make(map[string]string) + b.serviceNetworks = make(map[string]*storedServiceNetwork) + b.networksByName = make(map[string]string) + b.snsas = make(map[string]*storedSNSA) + b.snvas = make(map[string]*storedSNVA) + b.listeners = make(map[string]*storedListener) + b.rules = make(map[string]*storedRule) + b.targetGroups = make(map[string]*storedTargetGroup) + b.tgsByName = make(map[string]string) + b.targets = make(map[string][]*storedTarget) + b.alss = make(map[string]*storedALS) + b.authPolicies = make(map[string]string) + b.resourcePolicies = make(map[string]string) + b.tags = make(map[string]map[string]string) +} + +// AccountID returns the configured account ID. +func (b *InMemoryBackend) AccountID() string { return b.accountID } + +// Region returns the configured region. +func (b *InMemoryBackend) Region() string { return b.region } + +// Reset clears all stored data. +func (b *InMemoryBackend) Reset() { + b.mu.Lock("Reset") + defer b.mu.Unlock() + b.initMaps() +} + +// Snapshot serializes the backend state. +func (b *InMemoryBackend) Snapshot() []byte { + b.mu.RLock("Snapshot") + defer b.mu.RUnlock() + + s := snapshot{ + Services: b.services, + ServiceNetworks: b.serviceNetworks, + SNSAs: b.snsas, + SNVAs: b.snvas, + Listeners: b.listeners, + Rules: b.rules, + TargetGroups: b.targetGroups, + Targets: b.targets, + ALSs: b.alss, + AuthPolicies: b.authPolicies, + ResourcePolicies: b.resourcePolicies, + Tags: b.tags, + } + + data, _ := json.Marshal(s) + + return data +} + +// Restore deserializes backend state. +func (b *InMemoryBackend) Restore(data []byte) error { + var s snapshot + if err := json.Unmarshal(data, &s); err != nil { + return err + } + + b.mu.Lock("Restore") + defer b.mu.Unlock() + + b.services = s.Services + b.serviceNetworks = s.ServiceNetworks + b.snsas = s.SNSAs + b.snvas = s.SNVAs + b.listeners = s.Listeners + b.rules = s.Rules + b.targetGroups = s.TargetGroups + b.targets = s.Targets + b.alss = s.ALSs + b.authPolicies = s.AuthPolicies + b.resourcePolicies = s.ResourcePolicies + b.tags = s.Tags + + b.servicesByName = make(map[string]string) + for id, svc := range b.services { + b.servicesByName[svc.Name] = id + } + + b.networksByName = make(map[string]string) + for id, sn := range b.serviceNetworks { + b.networksByName[sn.Name] = id + } + + b.tgsByName = make(map[string]string) + for id, tg := range b.targetGroups { + b.tgsByName[tg.Name] = id + } + + return nil +} + +func (b *InMemoryBackend) buildARN(resourceType, resourceID string) string { + return arn.Build(arnService, b.region, b.accountID, resourceType+"/"+resourceID) +} + +func (b *InMemoryBackend) buildListenerARN(serviceID, listenerID string) string { + return arn.Build(arnService, b.region, b.accountID, + fmt.Sprintf("%s/%s/%s/%s", resourceService, serviceID, resourceListener, listenerID)) +} + +func (b *InMemoryBackend) buildRuleARN(serviceID, listenerID, ruleID string) string { + return arn.Build( + arnService, + b.region, + b.accountID, + fmt.Sprintf( + "%s/%s/%s/%s/%s/%s", + resourceService, + serviceID, + resourceListener, + listenerID, + resourceRule, + ruleID, + ), + ) +} + +func newID(prefix string) string { + id := strings.ReplaceAll(uuid.NewString(), "-", "")[:17] + + return prefix + id +} + +func copyTags(src map[string]string) map[string]string { + if src == nil { + return make(map[string]string) + } + + dst := make(map[string]string, len(src)) + maps.Copy(dst, src) + + return dst +} + +// resolveServiceID resolves a service identifier (ID or ARN) to an ID. +func (b *InMemoryBackend) resolveServiceID(identifier string) (string, bool) { + if svc, ok := b.services[identifier]; ok { + return svc.ID, true + } + // check if it's an ARN + for id, svc := range b.services { + if svc.ARN == identifier { + return id, true + } + } + + return "", false +} + +// resolveServiceNetworkID resolves a service network identifier to an ID. +func (b *InMemoryBackend) resolveServiceNetworkID(identifier string) (string, bool) { + if _, ok := b.serviceNetworks[identifier]; ok { + return identifier, true + } + for id, sn := range b.serviceNetworks { + if sn.ARN == identifier || sn.Name == identifier { + return id, true + } + } + + return "", false +} + +// resolveListenerID resolves a listener identifier to (serviceID, listenerID). +func (b *InMemoryBackend) resolveListenerID(serviceID, identifier string) (string, bool) { + key := serviceID + "/" + identifier + if _, ok := b.listeners[key]; ok { + return identifier, true + } + for _, l := range b.listeners { + if l.ServiceID == serviceID && (l.ARN == identifier) { + return l.ID, true + } + } + + return "", false +} + +// resolveRuleID resolves a rule identifier within a listener to a rule ID. +func (b *InMemoryBackend) resolveRuleID(serviceID, listenerID, identifier string) (string, bool) { + key := serviceID + "/" + listenerID + "/" + identifier + if _, ok := b.rules[key]; ok { + return identifier, true + } + for _, r := range b.rules { + if r.ServiceID == serviceID && r.ListenerID == listenerID && r.ARN == identifier { + return r.ID, true + } + } + + return "", false +} + +// resolveTargetGroupID resolves a target group identifier to an ID. +func (b *InMemoryBackend) resolveTargetGroupID(identifier string) (string, bool) { + if _, ok := b.targetGroups[identifier]; ok { + return identifier, true + } + for id, tg := range b.targetGroups { + if tg.ARN == identifier { + return id, true + } + } + + return "", false +} + +// resolveALSID resolves an access log subscription identifier. +func (b *InMemoryBackend) resolveALSID(identifier string) (string, bool) { + if _, ok := b.alss[identifier]; ok { + return identifier, true + } + for id, a := range b.alss { + if a.ARN == identifier { + return id, true + } + } + + return "", false +} + +// resolveSNSAID resolves a SNSA identifier. +func (b *InMemoryBackend) resolveSNSAID(identifier string) (string, bool) { + if _, ok := b.snsas[identifier]; ok { + return identifier, true + } + for id, s := range b.snsas { + if s.ARN == identifier { + return id, true + } + } + + return "", false +} + +// resolveSNVAID resolves a SNVA identifier. +func (b *InMemoryBackend) resolveSNVAID(identifier string) (string, bool) { + if _, ok := b.snvas[identifier]; ok { + return identifier, true + } + for id, s := range b.snvas { + if s.ARN == identifier { + return id, true + } + } + + return "", false +} + +// ------- Service operations ------- + +// CreateService creates a new service. +func (b *InMemoryBackend) CreateService( + name, authType, certificateArn, customDomainName string, + tags map[string]string, +) (*Service, error) { + if name == "" { + return nil, ErrInvalidParameter + } + + b.mu.Lock("CreateService") + defer b.mu.Unlock() + + if _, exists := b.servicesByName[name]; exists { + return nil, ErrAlreadyExists + } + + now := time.Now().UTC() + id := newID(idPrefixService) + svcARN := b.buildARN(resourceService, id) + + if authType == "" { + authType = authTypeNone + } + + svc := &storedService{ + ARN: svcARN, + ID: id, + Name: name, + AuthType: authType, + CertificateArn: certificateArn, + CustomDomainName: customDomainName, + DNSName: id + ".vpc-lattice-svcs." + b.region + ".on.aws", + Status: statusActive, + Tags: copyTags(tags), + CreatedAt: now, + LastUpdatedAt: now, + } + + b.services[id] = svc + b.servicesByName[name] = id + b.tags[svcARN] = copyTags(tags) + + return svc.toService(), nil +} + +// GetService returns a service by ID or ARN. +func (b *InMemoryBackend) GetService(serviceID string) (*Service, error) { + b.mu.RLock("GetService") + defer b.mu.RUnlock() + + id, ok := b.resolveServiceID(serviceID) + if !ok { + return nil, ErrNotFound + } + + return b.services[id].toService(), nil +} + +// UpdateService updates a service. +func (b *InMemoryBackend) UpdateService( + serviceID, authType, certificateArn string, +) (*Service, error) { + b.mu.Lock("UpdateService") + defer b.mu.Unlock() + + id, ok := b.resolveServiceID(serviceID) + if !ok { + return nil, ErrNotFound + } + + svc := b.services[id] + if authType != "" { + svc.AuthType = authType + } + + svc.CertificateArn = certificateArn + svc.LastUpdatedAt = time.Now().UTC() + + return svc.toService(), nil +} + +// DeleteService deletes a service. +func (b *InMemoryBackend) DeleteService(serviceID string) (*Service, error) { + b.mu.Lock("DeleteService") + defer b.mu.Unlock() + + id, ok := b.resolveServiceID(serviceID) + if !ok { + return nil, ErrNotFound + } + + svc := b.services[id] + out := svc.toService() + out.Status = statusDeleted + + delete(b.servicesByName, svc.Name) + delete(b.services, id) + delete(b.tags, svc.ARN) + + return out, nil +} + +// ListServices returns a paginated list of services. +func (b *InMemoryBackend) ListServices( + maxResults int32, + nextToken string, +) ([]*ServiceSummary, string, error) { + b.mu.RLock("ListServices") + defer b.mu.RUnlock() + + all := make([]*ServiceSummary, 0, len(b.services)) + for _, svc := range b.services { + all = append(all, svc.toSummary()) + } + + sort.Slice(all, func(i, j int) bool { return all[i].ID < all[j].ID }) + + p := page.New(all, nextToken, int(maxResults), defaultMaxResults) + + return p.Data, p.Next, nil +} + +// ------- ServiceNetwork operations ------- + +// CreateServiceNetwork creates a new service network. +func (b *InMemoryBackend) CreateServiceNetwork( + name, authType string, + tags map[string]string, +) (*ServiceNetwork, error) { + if name == "" { + return nil, ErrInvalidParameter + } + + b.mu.Lock("CreateServiceNetwork") + defer b.mu.Unlock() + + if _, exists := b.networksByName[name]; exists { + return nil, ErrAlreadyExists + } + + now := time.Now().UTC() + id := newID(idPrefixNetwork) + snARN := b.buildARN(resourceServiceNetwork, id) + + if authType == "" { + authType = authTypeNone + } + + sn := &storedServiceNetwork{ + ARN: snARN, + ID: id, + Name: name, + AuthType: authType, + Tags: copyTags(tags), + CreatedAt: now, + LastUpdatedAt: now, + } + + b.serviceNetworks[id] = sn + b.networksByName[name] = id + b.tags[snARN] = copyTags(tags) + + return sn.toServiceNetwork(), nil +} + +// GetServiceNetwork returns a service network. +func (b *InMemoryBackend) GetServiceNetwork(snID string) (*ServiceNetwork, error) { + b.mu.RLock("GetServiceNetwork") + defer b.mu.RUnlock() + + id, ok := b.resolveServiceNetworkID(snID) + if !ok { + return nil, ErrNotFound + } + + sn := b.serviceNetworks[id] + + // compute counts + sn.NumberOfAssociatedServices = b.countSNSAs(id) + sn.NumberOfAssociatedVPCs = b.countSNVAs(id) + + return sn.toServiceNetwork(), nil +} + +func (b *InMemoryBackend) countSNSAs(snID string) int64 { + var count int64 + for _, s := range b.snsas { + if s.ServiceNetworkID == snID { + count++ + } + } + + return count +} + +func (b *InMemoryBackend) countSNVAs(snID string) int64 { + var count int64 + for _, s := range b.snvas { + if s.ServiceNetworkID == snID { + count++ + } + } + + return count +} + +// UpdateServiceNetwork updates a service network. +func (b *InMemoryBackend) UpdateServiceNetwork(snID, authType string) (*ServiceNetwork, error) { + b.mu.Lock("UpdateServiceNetwork") + defer b.mu.Unlock() + + id, ok := b.resolveServiceNetworkID(snID) + if !ok { + return nil, ErrNotFound + } + + sn := b.serviceNetworks[id] + if authType != "" { + sn.AuthType = authType + } + + sn.LastUpdatedAt = time.Now().UTC() + + return sn.toServiceNetwork(), nil +} + +// DeleteServiceNetwork deletes a service network. +func (b *InMemoryBackend) DeleteServiceNetwork(snID string) error { + b.mu.Lock("DeleteServiceNetwork") + defer b.mu.Unlock() + + id, ok := b.resolveServiceNetworkID(snID) + if !ok { + return ErrNotFound + } + + sn := b.serviceNetworks[id] + delete(b.networksByName, sn.Name) + delete(b.serviceNetworks, id) + delete(b.tags, sn.ARN) + + return nil +} + +// ListServiceNetworks returns a paginated list of service networks. +func (b *InMemoryBackend) ListServiceNetworks( + maxResults int32, + nextToken string, +) ([]*ServiceNetworkSummary, string, error) { + b.mu.RLock("ListServiceNetworks") + defer b.mu.RUnlock() + + all := make([]*ServiceNetworkSummary, 0, len(b.serviceNetworks)) + for _, sn := range b.serviceNetworks { + all = append(all, sn.toSummary()) + } + + sort.Slice(all, func(i, j int) bool { return all[i].ID < all[j].ID }) + + p := page.New(all, nextToken, int(maxResults), defaultMaxResults) + + return p.Data, p.Next, nil +} + +// ------- ServiceNetworkServiceAssociation operations ------- + +// CreateServiceNetworkServiceAssociation creates a service-to-network association. +func (b *InMemoryBackend) CreateServiceNetworkServiceAssociation( + serviceNetworkID, serviceID string, + tags map[string]string, +) (*ServiceNetworkServiceAssociation, error) { + b.mu.Lock("CreateServiceNetworkServiceAssociation") + defer b.mu.Unlock() + + snID, ok := b.resolveServiceNetworkID(serviceNetworkID) + if !ok { + return nil, ErrNotFound + } + + svcID, ok := b.resolveServiceID(serviceID) + if !ok { + return nil, ErrNotFound + } + + // check for existing association + for _, s := range b.snsas { + if s.ServiceNetworkID == snID && s.ServiceID == svcID { + return nil, ErrAlreadyExists + } + } + + now := time.Now().UTC() + id := newID(idPrefixSNSA) + assocARN := b.buildARN(resourceServiceNetworkSvcAssoc, id) + + sn := b.serviceNetworks[snID] + svc := b.services[svcID] + + snsa := &storedSNSA{ + ARN: assocARN, + ID: id, + ServiceARN: svc.ARN, + ServiceID: svcID, + ServiceName: svc.Name, + ServiceNetworkARN: sn.ARN, + ServiceNetworkID: snID, + ServiceNetworkName: sn.Name, + Status: statusActive, + CreatedBy: b.accountID, + CustomDomainName: svc.CustomDomainName, + DNSName: svc.DNSName, + Tags: copyTags(tags), + CreatedAt: now, + } + + b.snsas[id] = snsa + b.tags[assocARN] = copyTags(tags) + + return snsa.toAssociation(), nil +} + +// GetServiceNetworkServiceAssociation returns a SNSA by ID or ARN. +func (b *InMemoryBackend) GetServiceNetworkServiceAssociation( + snsaID string, +) (*ServiceNetworkServiceAssociation, error) { + b.mu.RLock("GetServiceNetworkServiceAssociation") + defer b.mu.RUnlock() + + id, ok := b.resolveSNSAID(snsaID) + if !ok { + return nil, ErrNotFound + } + + return b.snsas[id].toAssociation(), nil +} + +// DeleteServiceNetworkServiceAssociation deletes a SNSA. +func (b *InMemoryBackend) DeleteServiceNetworkServiceAssociation(snsaID string) error { + b.mu.Lock("DeleteServiceNetworkServiceAssociation") + defer b.mu.Unlock() + + id, ok := b.resolveSNSAID(snsaID) + if !ok { + return ErrNotFound + } + + s := b.snsas[id] + delete(b.snsas, id) + delete(b.tags, s.ARN) + + return nil +} + +// ListServiceNetworkServiceAssociations lists SNSAs with optional filters. +func (b *InMemoryBackend) ListServiceNetworkServiceAssociations( + serviceNetworkID, serviceID string, + maxResults int32, + nextToken string, +) ([]*ServiceNetworkServiceAssociationSummary, string, error) { + b.mu.RLock("ListServiceNetworkServiceAssociations") + defer b.mu.RUnlock() + + all := make([]*ServiceNetworkServiceAssociationSummary, 0) + + for _, s := range b.snsas { + if serviceNetworkID != "" && s.ServiceNetworkID != serviceNetworkID && + s.ServiceNetworkARN != serviceNetworkID { + continue + } + + if serviceID != "" && s.ServiceID != serviceID && s.ServiceARN != serviceID { + continue + } + + all = append(all, s.toSummary()) + } + + sort.Slice(all, func(i, j int) bool { return all[i].ID < all[j].ID }) + + p := page.New(all, nextToken, int(maxResults), defaultMaxResults) + + return p.Data, p.Next, nil +} + +// ------- ServiceNetworkVpcAssociation operations ------- + +// CreateServiceNetworkVpcAssociation creates a VPC-to-network association. +func (b *InMemoryBackend) CreateServiceNetworkVpcAssociation( + serviceNetworkID, vpcID string, + securityGroupIDs []string, + tags map[string]string, +) (*ServiceNetworkVpcAssociation, error) { + if vpcID == "" { + return nil, ErrInvalidParameter + } + + b.mu.Lock("CreateServiceNetworkVpcAssociation") + defer b.mu.Unlock() + + snID, ok := b.resolveServiceNetworkID(serviceNetworkID) + if !ok { + return nil, ErrNotFound + } + + // check for existing + for _, s := range b.snvas { + if s.ServiceNetworkID == snID && s.VpcID == vpcID { + return nil, ErrAlreadyExists + } + } + + now := time.Now().UTC() + id := newID(idPrefixSNVA) + assocARN := b.buildARN(resourceServiceNetworkVpcAssoc, id) + + sn := b.serviceNetworks[snID] + sgs := make([]string, len(securityGroupIDs)) + copy(sgs, securityGroupIDs) + + snva := &storedSNVA{ + ARN: assocARN, + ID: id, + VpcID: vpcID, + ServiceNetworkARN: sn.ARN, + ServiceNetworkID: snID, + ServiceNetworkName: sn.Name, + SecurityGroupIDs: sgs, + Status: statusActive, + CreatedBy: b.accountID, + Tags: copyTags(tags), + CreatedAt: now, + LastUpdatedAt: now, + } + + b.snvas[id] = snva + b.tags[assocARN] = copyTags(tags) + + return snva.toAssociation(), nil +} + +// GetServiceNetworkVpcAssociation returns a SNVA. +func (b *InMemoryBackend) GetServiceNetworkVpcAssociation( + snvaID string, +) (*ServiceNetworkVpcAssociation, error) { + b.mu.RLock("GetServiceNetworkVpcAssociation") + defer b.mu.RUnlock() + + id, ok := b.resolveSNVAID(snvaID) + if !ok { + return nil, ErrNotFound + } + + return b.snvas[id].toAssociation(), nil +} + +// UpdateServiceNetworkVpcAssociation updates security groups on a SNVA. +func (b *InMemoryBackend) UpdateServiceNetworkVpcAssociation( + snvaID string, + securityGroupIDs []string, +) (*ServiceNetworkVpcAssociation, error) { + b.mu.Lock("UpdateServiceNetworkVpcAssociation") + defer b.mu.Unlock() + + id, ok := b.resolveSNVAID(snvaID) + if !ok { + return nil, ErrNotFound + } + + snva := b.snvas[id] + sgs := make([]string, len(securityGroupIDs)) + copy(sgs, securityGroupIDs) + snva.SecurityGroupIDs = sgs + snva.LastUpdatedAt = time.Now().UTC() + + return snva.toAssociation(), nil +} + +// DeleteServiceNetworkVpcAssociation deletes a SNVA. +func (b *InMemoryBackend) DeleteServiceNetworkVpcAssociation(snvaID string) error { + b.mu.Lock("DeleteServiceNetworkVpcAssociation") + defer b.mu.Unlock() + + id, ok := b.resolveSNVAID(snvaID) + if !ok { + return ErrNotFound + } + + s := b.snvas[id] + delete(b.snvas, id) + delete(b.tags, s.ARN) + + return nil +} + +// ListServiceNetworkVpcAssociations lists SNVAs with optional filters. +func (b *InMemoryBackend) ListServiceNetworkVpcAssociations( + serviceNetworkID, vpcID string, + maxResults int32, + nextToken string, +) ([]*ServiceNetworkVpcAssociationSummary, string, error) { + b.mu.RLock("ListServiceNetworkVpcAssociations") + defer b.mu.RUnlock() + + all := make([]*ServiceNetworkVpcAssociationSummary, 0) + + for _, s := range b.snvas { + if serviceNetworkID != "" && s.ServiceNetworkID != serviceNetworkID && + s.ServiceNetworkARN != serviceNetworkID { + continue + } + + if vpcID != "" && s.VpcID != vpcID { + continue + } + + all = append(all, s.toSummary()) + } + + sort.Slice(all, func(i, j int) bool { return all[i].ID < all[j].ID }) + + p := page.New(all, nextToken, int(maxResults), defaultMaxResults) + + return p.Data, p.Next, nil +} + +// ------- Listener operations ------- + +// CreateListener creates a listener on a service. +func (b *InMemoryBackend) CreateListener( + serviceID, name, protocol string, + port int32, + defaultAction *RuleAction, + tags map[string]string, +) (*Listener, error) { + if name == "" || protocol == "" { + return nil, ErrInvalidParameter + } + + b.mu.Lock("CreateListener") + defer b.mu.Unlock() + + svcID, ok := b.resolveServiceID(serviceID) + if !ok { + return nil, ErrNotFound + } + + // check duplicate name within service + for _, l := range b.listeners { + if l.ServiceID == svcID && l.Name == name { + return nil, ErrAlreadyExists + } + } + + if port == 0 { + if protocol == protocolHTTPS { + port = 443 + } else { + port = 80 + } + } + + now := time.Now().UTC() + id := newID(idPrefixListener) + svc := b.services[svcID] + listenerARN := b.buildListenerARN(svcID, id) + key := svcID + "/" + id + + l := &storedListener{ + ARN: listenerARN, + ID: id, + ServiceARN: svc.ARN, + ServiceID: svcID, + Name: name, + Protocol: protocol, + Port: port, + DefaultAction: defaultAction, + Tags: copyTags(tags), + CreatedAt: now, + LastUpdatedAt: now, + } + + b.listeners[key] = l + b.tags[listenerARN] = copyTags(tags) + + // create the default rule + b.createDefaultRule(svcID, id, listenerARN, defaultAction, now) + + return l.toListener(), nil +} + +func (b *InMemoryBackend) createDefaultRule( + serviceID, listenerID, _ string, + action *RuleAction, + now time.Time, +) { + id := newID(idPrefixRule) + ruleARN := b.buildRuleARN(serviceID, listenerID, id) + key := serviceID + "/" + listenerID + "/" + id + + r := &storedRule{ + ARN: ruleARN, + ID: id, + ServiceID: serviceID, + ListenerID: listenerID, + Name: "default", + Priority: defaultRulePriority, + Action: action, + IsDefault: true, + Tags: make(map[string]string), + CreatedAt: now, + LastUpdatedAt: now, + } + + b.rules[key] = r +} + +// GetListener returns a listener. +func (b *InMemoryBackend) GetListener(serviceID, listenerID string) (*Listener, error) { + b.mu.RLock("GetListener") + defer b.mu.RUnlock() + + svcID, ok := b.resolveServiceID(serviceID) + if !ok { + return nil, ErrNotFound + } + + lID, ok := b.resolveListenerID(svcID, listenerID) + if !ok { + return nil, ErrNotFound + } + + key := svcID + "/" + lID + + return b.listeners[key].toListener(), nil +} + +// UpdateListener updates the default action of a listener. +func (b *InMemoryBackend) UpdateListener( + serviceID, listenerID string, + defaultAction *RuleAction, +) (*Listener, error) { + b.mu.Lock("UpdateListener") + defer b.mu.Unlock() + + svcID, ok := b.resolveServiceID(serviceID) + if !ok { + return nil, ErrNotFound + } + + lID, ok := b.resolveListenerID(svcID, listenerID) + if !ok { + return nil, ErrNotFound + } + + key := svcID + "/" + lID + l := b.listeners[key] + + if defaultAction != nil { + l.DefaultAction = defaultAction + } + + l.LastUpdatedAt = time.Now().UTC() + + return l.toListener(), nil +} + +// DeleteListener deletes a listener and its rules. +func (b *InMemoryBackend) DeleteListener(serviceID, listenerID string) error { + b.mu.Lock("DeleteListener") + defer b.mu.Unlock() + + svcID, ok := b.resolveServiceID(serviceID) + if !ok { + return ErrNotFound + } + + lID, ok := b.resolveListenerID(svcID, listenerID) + if !ok { + return ErrNotFound + } + + key := svcID + "/" + lID + l := b.listeners[key] + delete(b.listeners, key) + delete(b.tags, l.ARN) + + // delete all rules for this listener + prefix := svcID + "/" + lID + "/" + for k, r := range b.rules { + if strings.HasPrefix(k, prefix) { + delete(b.rules, k) + delete(b.tags, r.ARN) + } + } + + return nil +} + +// ListListeners lists listeners for a service. +func (b *InMemoryBackend) ListListeners( + serviceID string, + maxResults int32, + nextToken string, +) ([]*ListenerSummary, string, error) { + b.mu.RLock("ListListeners") + defer b.mu.RUnlock() + + svcID, ok := b.resolveServiceID(serviceID) + if !ok { + return nil, "", ErrNotFound + } + + all := make([]*ListenerSummary, 0) + + for _, l := range b.listeners { + if l.ServiceID == svcID { + all = append(all, l.toSummary()) + } + } + + sort.Slice(all, func(i, j int) bool { return all[i].ID < all[j].ID }) + + p := page.New(all, nextToken, int(maxResults), defaultMaxResults) + + return p.Data, p.Next, nil +} + +// ------- Rule operations ------- + +// CreateRule creates a listener rule. +func (b *InMemoryBackend) CreateRule( + serviceID, listenerID, name string, + priority int32, + action *RuleAction, + match *RuleMatch, + tags map[string]string, +) (*Rule, error) { + if name == "" { + return nil, ErrInvalidParameter + } + + b.mu.Lock("CreateRule") + defer b.mu.Unlock() + + svcID, ok := b.resolveServiceID(serviceID) + if !ok { + return nil, ErrNotFound + } + + lID, ok := b.resolveListenerID(svcID, listenerID) + if !ok { + return nil, ErrNotFound + } + + // check duplicate name within listener + for _, r := range b.rules { + if r.ServiceID == svcID && r.ListenerID == lID && r.Name == name { + return nil, ErrAlreadyExists + } + } + + now := time.Now().UTC() + id := newID(idPrefixRule) + ruleARN := b.buildRuleARN(svcID, lID, id) + key := svcID + "/" + lID + "/" + id + + r := &storedRule{ + ARN: ruleARN, + ID: id, + ServiceID: svcID, + ListenerID: lID, + Name: name, + Priority: priority, + Action: action, + Match: match, + Tags: copyTags(tags), + CreatedAt: now, + LastUpdatedAt: now, + } + + b.rules[key] = r + b.tags[ruleARN] = copyTags(tags) + + return r.toRule(), nil +} + +// GetRule returns a rule. +func (b *InMemoryBackend) GetRule(serviceID, listenerID, ruleID string) (*Rule, error) { + b.mu.RLock("GetRule") + defer b.mu.RUnlock() + + svcID, ok := b.resolveServiceID(serviceID) + if !ok { + return nil, ErrNotFound + } + + lID, ok := b.resolveListenerID(svcID, listenerID) + if !ok { + return nil, ErrNotFound + } + + rID, ok := b.resolveRuleID(svcID, lID, ruleID) + if !ok { + return nil, ErrNotFound + } + + key := svcID + "/" + lID + "/" + rID + + return b.rules[key].toRule(), nil +} + +// UpdateRule updates a rule. +func (b *InMemoryBackend) UpdateRule( + serviceID, listenerID, ruleID string, + priority int32, + action *RuleAction, + match *RuleMatch, +) (*Rule, error) { + b.mu.Lock("UpdateRule") + defer b.mu.Unlock() + + svcID, ok := b.resolveServiceID(serviceID) + if !ok { + return nil, ErrNotFound + } + + lID, ok := b.resolveListenerID(svcID, listenerID) + if !ok { + return nil, ErrNotFound + } + + rID, ok := b.resolveRuleID(svcID, lID, ruleID) + if !ok { + return nil, ErrNotFound + } + + key := svcID + "/" + lID + "/" + rID + r := b.rules[key] + + if priority != 0 { + r.Priority = priority + } + + if action != nil { + r.Action = action + } + + if match != nil { + r.Match = match + } + + r.LastUpdatedAt = time.Now().UTC() + + return r.toRule(), nil +} + +// DeleteRule deletes a rule. +func (b *InMemoryBackend) DeleteRule(serviceID, listenerID, ruleID string) error { + b.mu.Lock("DeleteRule") + defer b.mu.Unlock() + + svcID, ok := b.resolveServiceID(serviceID) + if !ok { + return ErrNotFound + } + + lID, ok := b.resolveListenerID(svcID, listenerID) + if !ok { + return ErrNotFound + } + + rID, ok := b.resolveRuleID(svcID, lID, ruleID) + if !ok { + return ErrNotFound + } + + key := svcID + "/" + lID + "/" + rID + r := b.rules[key] + + if r.IsDefault { + return ErrInvalidParameter + } + + delete(b.rules, key) + delete(b.tags, r.ARN) + + return nil +} + +// ListRules lists rules for a listener. +func (b *InMemoryBackend) ListRules( + serviceID, listenerID string, + maxResults int32, + nextToken string, +) ([]*RuleSummary, string, error) { + b.mu.RLock("ListRules") + defer b.mu.RUnlock() + + svcID, ok := b.resolveServiceID(serviceID) + if !ok { + return nil, "", ErrNotFound + } + + lID, ok := b.resolveListenerID(svcID, listenerID) + if !ok { + return nil, "", ErrNotFound + } + + all := make([]*RuleSummary, 0) + + for _, r := range b.rules { + if r.ServiceID == svcID && r.ListenerID == lID { + all = append(all, r.toSummary()) + } + } + + sort.Slice(all, func(i, j int) bool { return all[i].Priority < all[j].Priority }) + + p := page.New(all, nextToken, int(maxResults), defaultMaxResults) + + return p.Data, p.Next, nil +} + +// BatchUpdateRule updates multiple rules atomically. +func (b *InMemoryBackend) BatchUpdateRule( + serviceID, listenerID string, + updates []*RuleUpdate, +) ([]*RuleUpdateSuccess, []*RuleUpdateFailure, error) { + b.mu.Lock("BatchUpdateRule") + defer b.mu.Unlock() + + svcID, ok := b.resolveServiceID(serviceID) + if !ok { + return nil, nil, ErrNotFound + } + + lID, ok := b.resolveListenerID(svcID, listenerID) + if !ok { + return nil, nil, ErrNotFound + } + + successes := make([]*RuleUpdateSuccess, 0, len(updates)) + failures := make([]*RuleUpdateFailure, 0) + now := time.Now().UTC() + + for _, u := range updates { + rID, found := b.resolveRuleID(svcID, lID, u.RuleIdentifier) + if !found { + failures = append(failures, &RuleUpdateFailure{ + RuleIdentifier: u.RuleIdentifier, + Code: "NOT_FOUND", + Message: "Rule not found", + }) + + continue + } + + key := svcID + "/" + lID + "/" + rID + r := b.rules[key] + + if u.Priority != 0 { + r.Priority = u.Priority + } + + if u.Action != nil { + r.Action = u.Action + } + + if u.Match != nil { + r.Match = u.Match + } + + r.LastUpdatedAt = now + successes = append(successes, &RuleUpdateSuccess{ + ARN: r.ARN, + ID: r.ID, + Name: r.Name, + Priority: r.Priority, + IsDefault: r.IsDefault, + Action: r.Action, + Match: r.Match, + }) + } + + return successes, failures, nil +} + +// ------- TargetGroup operations ------- + +// CreateTargetGroup creates a target group. +func (b *InMemoryBackend) CreateTargetGroup( + name, tgType string, + config *TargetGroupConfig, + tags map[string]string, +) (*TargetGroup, error) { + if name == "" { + return nil, ErrInvalidParameter + } + + b.mu.Lock("CreateTargetGroup") + defer b.mu.Unlock() + + if _, exists := b.tgsByName[name]; exists { + return nil, ErrAlreadyExists + } + + now := time.Now().UTC() + id := newID(idPrefixTargetGroup) + tgARN := b.buildARN(resourceTargetGroup, id) + + tg := &storedTargetGroup{ + ARN: tgARN, + ID: id, + Name: name, + Type: tgType, + Status: tgStatusActive, + Config: config, + Tags: copyTags(tags), + CreatedAt: now, + LastUpdatedAt: now, + } + + b.targetGroups[id] = tg + b.tgsByName[name] = id + b.targets[id] = make([]*storedTarget, 0) + b.tags[tgARN] = copyTags(tags) + + return tg.toTargetGroup(), nil +} + +// GetTargetGroup returns a target group. +func (b *InMemoryBackend) GetTargetGroup(tgID string) (*TargetGroup, error) { + b.mu.RLock("GetTargetGroup") + defer b.mu.RUnlock() + + id, ok := b.resolveTargetGroupID(tgID) + if !ok { + return nil, ErrNotFound + } + + return b.targetGroups[id].toTargetGroup(), nil +} + +// UpdateTargetGroup updates a target group's health check config. +func (b *InMemoryBackend) UpdateTargetGroup( + tgID string, + healthCheck *HealthCheckConfig, +) (*TargetGroup, error) { + b.mu.Lock("UpdateTargetGroup") + defer b.mu.Unlock() + + id, ok := b.resolveTargetGroupID(tgID) + if !ok { + return nil, ErrNotFound + } + + tg := b.targetGroups[id] + if tg.Config == nil { + tg.Config = &TargetGroupConfig{} + } + + if healthCheck != nil { + tg.Config.HealthCheck = healthCheck + } + + tg.LastUpdatedAt = time.Now().UTC() + + return tg.toTargetGroup(), nil +} + +// DeleteTargetGroup deletes a target group. +func (b *InMemoryBackend) DeleteTargetGroup(tgID string) error { + b.mu.Lock("DeleteTargetGroup") + defer b.mu.Unlock() + + id, ok := b.resolveTargetGroupID(tgID) + if !ok { + return ErrNotFound + } + + tg := b.targetGroups[id] + delete(b.targetGroups, id) + delete(b.tgsByName, tg.Name) + delete(b.targets, id) + delete(b.tags, tg.ARN) + + return nil +} + +// ListTargetGroups lists target groups with optional filters. +func (b *InMemoryBackend) ListTargetGroups( + tgType, serviceArn string, + maxResults int32, + nextToken string, +) ([]*TargetGroupSummary, string, error) { + b.mu.RLock("ListTargetGroups") + defer b.mu.RUnlock() + + all := make([]*TargetGroupSummary, 0, len(b.targetGroups)) + + for _, tg := range b.targetGroups { + if tgType != "" && tg.Type != tgType { + continue + } + + if serviceArn != "" && !slices.Contains(tg.ServiceARNs, serviceArn) { + continue + } + + all = append(all, tg.toSummary()) + } + + sort.Slice(all, func(i, j int) bool { return all[i].ID < all[j].ID }) + + p := page.New(all, nextToken, int(maxResults), defaultMaxResults) + + return p.Data, p.Next, nil +} + +// RegisterTargets registers targets to a target group. +func (b *InMemoryBackend) RegisterTargets( + tgID string, + targets []*Target, +) ([]*TargetFailure, error) { + b.mu.Lock("RegisterTargets") + defer b.mu.Unlock() + + id, ok := b.resolveTargetGroupID(tgID) + if !ok { + return nil, ErrNotFound + } + + failures := make([]*TargetFailure, 0) + existing := b.targets[id] + + for _, t := range targets { + // check for duplicate + dup := false + for _, e := range existing { + if e.ID == t.ID && e.Port == t.Port { + dup = true + + break + } + } + + if dup { + failures = append(failures, &TargetFailure{ + ID: t.ID, + Port: t.Port, + Code: "TARGET_ALREADY_REGISTERED", + Message: "Target already registered", + }) + + continue + } + + existing = append(existing, &storedTarget{ + ID: t.ID, + Port: t.Port, + Status: targetStatusHealthy, + }) + } + + b.targets[id] = existing + + return failures, nil +} + +// DeregisterTargets deregisters targets from a target group. +func (b *InMemoryBackend) DeregisterTargets( //nolint:gocognit // target deregistration logic is inherently complex + tgID string, + targets []*Target, +) ([]*TargetFailure, error) { + b.mu.Lock("DeregisterTargets") + defer b.mu.Unlock() + + id, ok := b.resolveTargetGroupID(tgID) + if !ok { + return nil, ErrNotFound + } + + failures := make([]*TargetFailure, 0) + existing := b.targets[id] + + for _, t := range targets { + found := false + + for _, e := range existing { + if e.ID == t.ID && (t.Port == 0 || e.Port == t.Port) { + found = true + + break + } + } + + if !found { + failures = append(failures, &TargetFailure{ + ID: t.ID, + Port: t.Port, + Code: "TARGET_NOT_FOUND", + Message: "Target not registered", + }) + } + } + + // rebuild remaining with non-deregistered targets + remaining := make([]*storedTarget, 0, len(existing)) + + for _, e := range existing { + remove := false + + for _, t := range targets { + if e.ID == t.ID && (t.Port == 0 || e.Port == t.Port) { + remove = true + + break + } + } + + if !remove { + remaining = append(remaining, e) + } + } + + b.targets[id] = remaining + + return failures, nil +} + +// ListTargets lists registered targets for a target group. +func (b *InMemoryBackend) ListTargets( + tgID string, + maxResults int32, + nextToken string, +) ([]*TargetSummary, string, error) { + b.mu.RLock("ListTargets") + defer b.mu.RUnlock() + + id, ok := b.resolveTargetGroupID(tgID) + if !ok { + return nil, "", ErrNotFound + } + + targets := b.targets[id] + all := make([]*TargetSummary, 0, len(targets)) + + for _, t := range targets { + all = append(all, &TargetSummary{ + ID: t.ID, + Port: t.Port, + Status: t.Status, + }) + } + + p := page.New(all, nextToken, int(maxResults), defaultMaxResults) + + return p.Data, p.Next, nil +} + +// ------- AccessLogSubscription operations ------- + +// CreateAccessLogSubscription creates an access log subscription. +func (b *InMemoryBackend) CreateAccessLogSubscription( + resourceID, destinationArn, logType string, + tags map[string]string, +) (*AccessLogSubscription, error) { + if destinationArn == "" { + return nil, ErrInvalidParameter + } + + b.mu.Lock("CreateAccessLogSubscription") + defer b.mu.Unlock() + + // resolve resource ID (service or service network) + resourceARN := b.resolveResourceARN(resourceID) + + now := time.Now().UTC() + id := newID(idPrefixALS) + alsARN := b.buildARN(resourceAccessLogSubscription, id) + + als := &storedALS{ + ARN: alsARN, + ID: id, + ResourceARN: resourceARN, + ResourceID: resourceID, + DestinationARN: destinationArn, + ServiceNetworkLogType: logType, + Tags: copyTags(tags), + CreatedAt: now, + LastUpdatedAt: now, + } + + b.alss[id] = als + b.tags[alsARN] = copyTags(tags) + + return als.toALS(), nil +} + +func (b *InMemoryBackend) resolveResourceARN(resourceID string) string { + if svc, ok := b.services[resourceID]; ok { + return svc.ARN + } + + for _, svc := range b.services { + if svc.ARN == resourceID { + return svc.ARN + } + } + + if sn, ok := b.serviceNetworks[resourceID]; ok { + return sn.ARN + } + + for _, sn := range b.serviceNetworks { + if sn.ARN == resourceID { + return sn.ARN + } + } + + return resourceID +} + +// GetAccessLogSubscription returns an access log subscription. +func (b *InMemoryBackend) GetAccessLogSubscription(alsID string) (*AccessLogSubscription, error) { + b.mu.RLock("GetAccessLogSubscription") + defer b.mu.RUnlock() + + id, ok := b.resolveALSID(alsID) + if !ok { + return nil, ErrNotFound + } + + return b.alss[id].toALS(), nil +} + +// UpdateAccessLogSubscription updates the destination ARN. +func (b *InMemoryBackend) UpdateAccessLogSubscription( + alsID, destinationArn string, +) (*AccessLogSubscription, error) { + b.mu.Lock("UpdateAccessLogSubscription") + defer b.mu.Unlock() + + id, ok := b.resolveALSID(alsID) + if !ok { + return nil, ErrNotFound + } + + als := b.alss[id] + als.DestinationARN = destinationArn + als.LastUpdatedAt = time.Now().UTC() + + return als.toALS(), nil +} + +// DeleteAccessLogSubscription deletes an access log subscription. +func (b *InMemoryBackend) DeleteAccessLogSubscription(alsID string) error { + b.mu.Lock("DeleteAccessLogSubscription") + defer b.mu.Unlock() + + id, ok := b.resolveALSID(alsID) + if !ok { + return ErrNotFound + } + + a := b.alss[id] + delete(b.alss, id) + delete(b.tags, a.ARN) + + return nil +} + +// ListAccessLogSubscriptions lists access log subscriptions for a resource. +func (b *InMemoryBackend) ListAccessLogSubscriptions( + resourceID string, + maxResults int32, + nextToken string, +) ([]*AccessLogSubscriptionSummary, string, error) { + b.mu.RLock("ListAccessLogSubscriptions") + defer b.mu.RUnlock() + + all := make([]*AccessLogSubscriptionSummary, 0) + + for _, a := range b.alss { + if resourceID != "" && a.ResourceID != resourceID && a.ResourceARN != resourceID { + continue + } + + all = append(all, a.toSummary()) + } + + sort.Slice(all, func(i, j int) bool { return all[i].ID < all[j].ID }) + + p := page.New(all, nextToken, int(maxResults), defaultMaxResults) + + return p.Data, p.Next, nil +} + +// ------- Auth/Resource Policy operations ------- + +// PutAuthPolicy sets an auth policy on a resource. +func (b *InMemoryBackend) PutAuthPolicy(resourceID, policy string) (*AuthPolicy, error) { + b.mu.Lock("PutAuthPolicy") + defer b.mu.Unlock() + + b.authPolicies[resourceID] = policy + + return &AuthPolicy{Policy: policy, State: authPolicyStateActive}, nil +} + +// GetAuthPolicy returns the auth policy for a resource. +func (b *InMemoryBackend) GetAuthPolicy(resourceID string) (*AuthPolicy, error) { + b.mu.RLock("GetAuthPolicy") + defer b.mu.RUnlock() + + policy, ok := b.authPolicies[resourceID] + if !ok { + return &AuthPolicy{Policy: "", State: "Active"}, nil + } + + return &AuthPolicy{Policy: policy, State: authPolicyStateActive}, nil +} + +// DeleteAuthPolicy deletes the auth policy for a resource. +func (b *InMemoryBackend) DeleteAuthPolicy(resourceID string) error { + b.mu.Lock("DeleteAuthPolicy") + defer b.mu.Unlock() + + delete(b.authPolicies, resourceID) + + return nil +} + +// PutResourcePolicy sets a resource policy. +func (b *InMemoryBackend) PutResourcePolicy(resourceArn, policy string) error { + b.mu.Lock("PutResourcePolicy") + defer b.mu.Unlock() + + b.resourcePolicies[resourceArn] = policy + + return nil +} + +// GetResourcePolicy returns a resource policy. +func (b *InMemoryBackend) GetResourcePolicy(resourceArn string) (string, error) { + b.mu.RLock("GetResourcePolicy") + defer b.mu.RUnlock() + + policy, ok := b.resourcePolicies[resourceArn] + if !ok { + return "", ErrNotFound + } + + return policy, nil +} + +// DeleteResourcePolicy deletes a resource policy. +func (b *InMemoryBackend) DeleteResourcePolicy(resourceArn string) error { + b.mu.Lock("DeleteResourcePolicy") + defer b.mu.Unlock() + + if _, ok := b.resourcePolicies[resourceArn]; !ok { + return ErrNotFound + } + + delete(b.resourcePolicies, resourceArn) + + return nil +} + +// ------- Tagging operations ------- + +// TagResource adds tags to a resource. +func (b *InMemoryBackend) TagResource(resourceArn string, tags map[string]string) error { + b.mu.Lock("TagResource") + defer b.mu.Unlock() + + if _, ok := b.tags[resourceArn]; !ok { + b.tags[resourceArn] = make(map[string]string) + } + + maps.Copy(b.tags[resourceArn], tags) + + return nil +} + +// UntagResource removes tags from a resource. +func (b *InMemoryBackend) UntagResource(resourceArn string, keys []string) error { + b.mu.Lock("UntagResource") + defer b.mu.Unlock() + + if t, ok := b.tags[resourceArn]; ok { + for _, k := range keys { + delete(t, k) + } + } + + return nil +} + +// ListTagsForResource returns tags for a resource. +func (b *InMemoryBackend) ListTagsForResource(resourceArn string) (map[string]string, error) { + b.mu.RLock("ListTagsForResource") + defer b.mu.RUnlock() + + t, ok := b.tags[resourceArn] + if !ok { + return make(map[string]string), nil + } + + result := make(map[string]string, len(t)) + maps.Copy(result, t) + + return result, nil +} diff --git a/services/vpclattice/export_test.go b/services/vpclattice/export_test.go new file mode 100644 index 000000000..1e54844dc --- /dev/null +++ b/services/vpclattice/export_test.go @@ -0,0 +1,38 @@ +package vpclattice + +// ServiceCount returns the number of stored services. +func ServiceCount(b *InMemoryBackend) int { + b.mu.RLock("ServiceCount") + defer b.mu.RUnlock() + + return len(b.services) +} + +// ServiceNetworkCount returns the number of stored service networks. +func ServiceNetworkCount(b *InMemoryBackend) int { + b.mu.RLock("ServiceNetworkCount") + defer b.mu.RUnlock() + + return len(b.serviceNetworks) +} + +// TargetGroupCount returns the number of stored target groups. +func TargetGroupCount(b *InMemoryBackend) int { + b.mu.RLock("TargetGroupCount") + defer b.mu.RUnlock() + + return len(b.targetGroups) +} + +// ListenerCount returns the number of stored listeners. +func ListenerCount(b *InMemoryBackend) int { + b.mu.RLock("ListenerCount") + defer b.mu.RUnlock() + + return len(b.listeners) +} + +// HandlerOpsLen returns the count of GetSupportedOperations. +func HandlerOpsLen(h *Handler) int { + return len(h.GetSupportedOperations()) +} diff --git a/services/vpclattice/handler.go b/services/vpclattice/handler.go new file mode 100644 index 000000000..d49f3576d --- /dev/null +++ b/services/vpclattice/handler.go @@ -0,0 +1,2034 @@ +package vpclattice + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + "strings" + + "github.com/labstack/echo/v5" + + "github.com/blackbirdworks/gopherstack/pkgs/awserr" + "github.com/blackbirdworks/gopherstack/pkgs/service" +) + +const ( + matchPriority = service.PriorityPathVersioned + + pathServices = "/services" + pathServiceNetworks = "/servicenetworks" + pathServiceNetworkServiceAssociations = "/servicenetworkserviceassociations" + pathServiceNetworkVpcAssociations = "/servicenetworkvpcassociations" + pathTargetGroups = "/targetgroups" + pathAccessLogSubscriptions = "/accesslogsubscriptions" + pathAuthPolicy = "/authpolicy" + pathResourcePolicy = "/resourcepolicy" + pathTags = "/tags" + + opUnknown = "Unknown" + + keyMessage = "message" + keyStatus = "status" + + keyARN = "arn" + keyName = "name" + keyItems = "items" + keyCreatedAt = "createdAt" + keyLastUpdatedAt = "lastUpdatedAt" + keyServiceARN = "serviceArn" + keyServiceID = "serviceId" + keyServiceNetworkARN = "serviceNetworkArn" + keyServiceNetworkID = "serviceNetworkId" + keyServiceNetworkName = "serviceNetworkName" + keyVPCID = "vpcId" + keyProtocol = "protocol" + keyPort = "port" + keyPriority = "priority" + keyIsDefault = "isDefault" + keyPolicy = "policy" + keyUnsuccessful = "unsuccessful" + keyNameRequired = "name is required" + + opBatchUpdateRule = "BatchUpdateRule" + opCreateALS = "CreateAccessLogSubscription" + opCreateListener = "CreateListener" + opCreateRule = "CreateRule" + opCreateService = "CreateService" + opCreateSN = "CreateServiceNetwork" + opCreateSNSA = "CreateServiceNetworkServiceAssociation" + opCreateSNVA = "CreateServiceNetworkVpcAssociation" + opCreateTG = "CreateTargetGroup" + opDeleteALS = "DeleteAccessLogSubscription" + opDeleteAuthPolicy = "DeleteAuthPolicy" + opDeleteListener = "DeleteListener" + opDeleteResourcePolicy = "DeleteResourcePolicy" + opDeleteRule = "DeleteRule" + opDeleteService = "DeleteService" + opDeleteSN = "DeleteServiceNetwork" + opDeleteSNSA = "DeleteServiceNetworkServiceAssociation" + opDeleteSNVA = "DeleteServiceNetworkVpcAssociation" + opDeleteTG = "DeleteTargetGroup" + opDeregisterTargets = "DeregisterTargets" + opGetALS = "GetAccessLogSubscription" + opGetAuthPolicy = "GetAuthPolicy" + opGetListener = "GetListener" + opGetResourcePolicy = "GetResourcePolicy" + opGetRule = "GetRule" + opGetService = "GetService" + opGetSN = "GetServiceNetwork" + opGetSNSA = "GetServiceNetworkServiceAssociation" + opGetSNVA = "GetServiceNetworkVpcAssociation" + opGetTG = "GetTargetGroup" + opListALSs = "ListAccessLogSubscriptions" + opListListeners = "ListListeners" + opListRules = "ListRules" + opListSNSAs = "ListServiceNetworkServiceAssociations" + opListSNVAs = "ListServiceNetworkVpcAssociations" + opListSNs = "ListServiceNetworks" + opListServices = "ListServices" + opListTagsForResource = "ListTagsForResource" + opListTGs = "ListTargetGroups" + opListTargets = "ListTargets" + opPutAuthPolicy = "PutAuthPolicy" + opPutResourcePolicy = "PutResourcePolicy" + opRegisterTargets = "RegisterTargets" + opTagResource = "TagResource" + opUntagResource = "UntagResource" + opUpdateALS = "UpdateAccessLogSubscription" + opUpdateListener = "UpdateListener" + opUpdateRule = "UpdateRule" + opUpdateService = "UpdateService" + opUpdateSN = "UpdateServiceNetwork" + opUpdateSNVA = "UpdateServiceNetworkVpcAssociation" + opUpdateTG = "UpdateTargetGroup" +) + +// Handler handles VPC Lattice HTTP requests. +type Handler struct { + Backend StorageBackend +} + +// NewHandler constructs a new Handler. +func NewHandler(b StorageBackend) *Handler { + return &Handler{Backend: b} +} + +// Name returns the service name. +func (h *Handler) Name() string { return "VPCLattice" } + +// Reset resets the backend. +func (h *Handler) Reset() { h.Backend.Reset() } + +// GetSupportedOperations returns all supported operations. +func (h *Handler) GetSupportedOperations() []string { + return []string{ + opBatchUpdateRule, + opCreateALS, + opCreateListener, + opCreateRule, + opCreateService, + opCreateSN, + opCreateSNSA, + opCreateSNVA, + opCreateTG, + opDeleteALS, + opDeleteAuthPolicy, + opDeleteListener, + opDeleteResourcePolicy, + opDeleteRule, + opDeleteService, + opDeleteSN, + opDeleteSNSA, + opDeleteSNVA, + opDeleteTG, + opDeregisterTargets, + opGetALS, + opGetAuthPolicy, + opGetListener, + opGetResourcePolicy, + opGetRule, + opGetService, + opGetSN, + opGetSNSA, + opGetSNVA, + opGetTG, + opListALSs, + opListListeners, + opListRules, + opListSNSAs, + opListSNVAs, + opListSNs, + opListServices, + opListTagsForResource, + opListTGs, + opListTargets, + opPutAuthPolicy, + opPutResourcePolicy, + opRegisterTargets, + opTagResource, + opUntagResource, + opUpdateALS, + opUpdateListener, + opUpdateRule, + opUpdateService, + opUpdateSN, + opUpdateSNVA, + opUpdateTG, + } +} + +// RouteMatcher returns a function that matches VPC Lattice API requests. +func (h *Handler) RouteMatcher() service.Matcher { + return func(c *echo.Context) bool { + path := c.Request().URL.Path + + return path == pathServices || strings.HasPrefix(path, pathServices+"/") || + path == pathServiceNetworks || strings.HasPrefix(path, pathServiceNetworks+"/") || + path == pathServiceNetworkServiceAssociations || + strings.HasPrefix(path, pathServiceNetworkServiceAssociations+"/") || + path == pathServiceNetworkVpcAssociations || + strings.HasPrefix(path, pathServiceNetworkVpcAssociations+"/") || + path == pathTargetGroups || strings.HasPrefix(path, pathTargetGroups+"/") || + path == pathAccessLogSubscriptions || strings.HasPrefix(path, pathAccessLogSubscriptions+"/") || + strings.HasPrefix(path, pathAuthPolicy+"/") || + strings.HasPrefix(path, pathResourcePolicy+"/") || + isVPCLatticeTagPath(path) + } +} + +func isVPCLatticeTagPath(path string) bool { + rest, ok := strings.CutPrefix(path, pathTags+"/") + + return ok && strings.Contains(rest, ":vpc-lattice:") +} + +// MatchPriority returns the routing priority. +func (h *Handler) MatchPriority() int { return matchPriority } + +// ExtractOperation classifies the request into an operation name. +func (h *Handler) ExtractOperation(c *echo.Context) string { + op, _, _, _ := classifyPath(c.Request().Method, c.Request().URL.Path) + + return op +} + +// ExtractResource returns the primary resource identifier. +func (h *Handler) ExtractResource(c *echo.Context) string { + _, id, _, _ := classifyPath(c.Request().Method, c.Request().URL.Path) + + return id +} + +// Handler returns the Echo handler function for VPC Lattice requests. +func (h *Handler) Handler() echo.HandlerFunc { + return func(c *echo.Context) error { + return h.handleREST(c) + } +} + +func (h *Handler) handleREST(c *echo.Context) error { //nolint:gocyclo,cyclop,funlen // routing dispatch + op, id1, id2, id3 := classifyPath(c.Request().Method, c.Request().URL.Path) + + var body map[string]any + if c.Request().ContentLength != 0 { + if err := json.NewDecoder(c.Request().Body).Decode(&body); err != nil && + err.Error() != "EOF" { + return c.JSON(http.StatusBadRequest, map[string]any{keyMessage: "invalid JSON body"}) + } + } + + if body == nil { + body = map[string]any{} + } + + switch op { + case opCreateService: + return h.handleCreateService(c, body) + case opGetService: + return h.handleGetService(c, id1) + case opUpdateService: + return h.handleUpdateService(c, id1, body) + case opDeleteService: + return h.handleDeleteService(c, id1) + case opListServices: + return h.handleListServices(c) + case opCreateSN: + return h.handleCreateServiceNetwork(c, body) + case opGetSN: + return h.handleGetServiceNetwork(c, id1) + case opUpdateSN: + return h.handleUpdateServiceNetwork(c, id1, body) + case opDeleteSN: + return h.handleDeleteServiceNetwork(c, id1) + case opListSNs: + return h.handleListServiceNetworks(c) + case opCreateSNSA: + return h.handleCreateSNSA(c, body) + case opGetSNSA: + return h.handleGetSNSA(c, id1) + case opDeleteSNSA: + return h.handleDeleteSNSA(c, id1) + case opListSNSAs: + return h.handleListSNSAs(c) + case opCreateSNVA: + return h.handleCreateSNVA(c, body) + case opGetSNVA: + return h.handleGetSNVA(c, id1) + case opUpdateSNVA: + return h.handleUpdateSNVA(c, id1, body) + case opDeleteSNVA: + return h.handleDeleteSNVA(c, id1) + case opListSNVAs: + return h.handleListSNVAs(c) + case opCreateListener: + return h.handleCreateListener(c, id1, body) + case opGetListener: + return h.handleGetListener(c, id1, id2) + case opUpdateListener: + return h.handleUpdateListener(c, id1, id2, body) + case opDeleteListener: + return h.handleDeleteListener(c, id1, id2) + case opListListeners: + return h.handleListListeners(c, id1) + case opCreateRule: + return h.handleCreateRule(c, id1, id2, body) + case opGetRule: + return h.handleGetRule(c, id1, id2, id3) + case opUpdateRule: + return h.handleUpdateRule(c, id1, id2, id3, body) + case opDeleteRule: + return h.handleDeleteRule(c, id1, id2, id3) + case opListRules: + return h.handleListRules(c, id1, id2) + case opBatchUpdateRule: + return h.handleBatchUpdateRule(c, id1, id2, body) + case opCreateTG: + return h.handleCreateTargetGroup(c, body) + case opGetTG: + return h.handleGetTargetGroup(c, id1) + case opUpdateTG: + return h.handleUpdateTargetGroup(c, id1, body) + case opDeleteTG: + return h.handleDeleteTargetGroup(c, id1) + case opListTGs: + return h.handleListTargetGroups(c) + case opRegisterTargets: + return h.handleRegisterTargets(c, id1, body) + case opDeregisterTargets: + return h.handleDeregisterTargets(c, id1, body) + case opListTargets: + return h.handleListTargets(c, id1, body) + case opCreateALS: + return h.handleCreateALS(c, body) + case opGetALS: + return h.handleGetALS(c, id1) + case opUpdateALS: + return h.handleUpdateALS(c, id1, body) + case opDeleteALS: + return h.handleDeleteALS(c, id1) + case opListALSs: + return h.handleListALSs(c) + case opPutAuthPolicy: + return h.handlePutAuthPolicy(c, id1, body) + case opGetAuthPolicy: + return h.handleGetAuthPolicy(c, id1) + case opDeleteAuthPolicy: + return h.handleDeleteAuthPolicy(c, id1) + case opPutResourcePolicy: + return h.handlePutResourcePolicy(c, id1, body) + case opGetResourcePolicy: + return h.handleGetResourcePolicy(c, id1) + case opDeleteResourcePolicy: + return h.handleDeleteResourcePolicy(c, id1) + case opTagResource: + return h.handleTagResource(c, id1, body) + case opUntagResource: + return h.handleUntagResource(c, id1) + case opListTagsForResource: + return h.handleListTagsForResource(c, id1) + default: + return c.JSON(http.StatusNotFound, map[string]any{keyMessage: "unknown operation"}) + } +} + +// handleError converts backend errors to HTTP responses. +func (h *Handler) handleError(c *echo.Context, err error) error { + switch { + case errors.Is(err, awserr.ErrNotFound): + return c.JSON(http.StatusNotFound, map[string]any{keyMessage: err.Error()}) + case errors.Is(err, awserr.ErrAlreadyExists): + return c.JSON(http.StatusConflict, map[string]any{keyMessage: err.Error()}) + case errors.Is(err, awserr.ErrInvalidParameter): + return c.JSON(http.StatusBadRequest, map[string]any{keyMessage: err.Error()}) + } + + return c.JSON(http.StatusInternalServerError, map[string]any{keyMessage: err.Error()}) +} + +// ------- Service handlers ------- + +func (h *Handler) handleCreateService(c *echo.Context, body map[string]any) error { + name, _ := body[keyName].(string) + if name == "" { + return c.JSON(http.StatusBadRequest, map[string]any{keyMessage: keyNameRequired}) + } + + authType, _ := body["authType"].(string) + certArn, _ := body["certificateArn"].(string) + customDomain, _ := body["customDomainName"].(string) + tags := extractTags(body) + + svc, err := h.Backend.CreateService(name, authType, certArn, customDomain, tags) + if err != nil { + return h.handleError(c, err) + } + + return c.JSON(http.StatusCreated, serviceToJSON(svc)) +} + +func (h *Handler) handleGetService(c *echo.Context, id string) error { + svc, err := h.Backend.GetService(id) + if err != nil { + return h.handleError(c, err) + } + + return c.JSON(http.StatusOK, serviceToJSON(svc)) +} + +func (h *Handler) handleUpdateService(c *echo.Context, id string, body map[string]any) error { + authType, _ := body["authType"].(string) + certArn, _ := body["certificateArn"].(string) + + svc, err := h.Backend.UpdateService(id, authType, certArn) + if err != nil { + return h.handleError(c, err) + } + + return c.JSON(http.StatusOK, serviceToJSON(svc)) +} + +func (h *Handler) handleDeleteService(c *echo.Context, id string) error { + svc, err := h.Backend.DeleteService(id) + if err != nil { + return h.handleError(c, err) + } + + return c.JSON(http.StatusOK, serviceToJSON(svc)) +} + +func (h *Handler) handleListServices(c *echo.Context) error { + maxResults := queryInt32(c) + nextToken := c.QueryParam("nextToken") + + items, next, err := h.Backend.ListServices(maxResults, nextToken) + if err != nil { + return h.handleError(c, err) + } + + summaries := make([]any, 0, len(items)) + for _, s := range items { + summaries = append(summaries, serviceSummaryToJSON(s)) + } + + resp := map[string]any{keyItems: summaries} + if next != "" { + resp["nextToken"] = next + } + + return c.JSON(http.StatusOK, resp) +} + +// ------- ServiceNetwork handlers ------- + +func (h *Handler) handleCreateServiceNetwork(c *echo.Context, body map[string]any) error { + name, _ := body[keyName].(string) + if name == "" { + return c.JSON(http.StatusBadRequest, map[string]any{keyMessage: keyNameRequired}) + } + + authType, _ := body["authType"].(string) + tags := extractTags(body) + + sn, err := h.Backend.CreateServiceNetwork(name, authType, tags) + if err != nil { + return h.handleError(c, err) + } + + return c.JSON(http.StatusCreated, serviceNetworkToJSON(sn)) +} + +func (h *Handler) handleGetServiceNetwork(c *echo.Context, id string) error { + sn, err := h.Backend.GetServiceNetwork(id) + if err != nil { + return h.handleError(c, err) + } + + return c.JSON(http.StatusOK, serviceNetworkToJSON(sn)) +} + +func (h *Handler) handleUpdateServiceNetwork( + c *echo.Context, + id string, + body map[string]any, +) error { + authType, _ := body["authType"].(string) + + sn, err := h.Backend.UpdateServiceNetwork(id, authType) + if err != nil { + return h.handleError(c, err) + } + + return c.JSON(http.StatusOK, serviceNetworkToJSON(sn)) +} + +func (h *Handler) handleDeleteServiceNetwork(c *echo.Context, id string) error { + if err := h.Backend.DeleteServiceNetwork(id); err != nil { + return h.handleError(c, err) + } + + return c.NoContent(http.StatusNoContent) +} + +func (h *Handler) handleListServiceNetworks(c *echo.Context) error { + maxResults := queryInt32(c) + nextToken := c.QueryParam("nextToken") + + items, next, err := h.Backend.ListServiceNetworks(maxResults, nextToken) + if err != nil { + return h.handleError(c, err) + } + + summaries := make([]any, 0, len(items)) + for _, s := range items { + summaries = append(summaries, serviceNetworkSummaryToJSON(s)) + } + + resp := map[string]any{keyItems: summaries} + if next != "" { + resp["nextToken"] = next + } + + return c.JSON(http.StatusOK, resp) +} + +// ------- SNSA handlers ------- + +func (h *Handler) handleCreateSNSA(c *echo.Context, body map[string]any) error { + snID, _ := body["serviceNetworkIdentifier"].(string) + svcID, _ := body["serviceIdentifier"].(string) + + if snID == "" || svcID == "" { + return c.JSON( + http.StatusBadRequest, + map[string]any{ + keyMessage: "serviceNetworkIdentifier and serviceIdentifier are required", + }, + ) + } + + tags := extractTags(body) + + assoc, err := h.Backend.CreateServiceNetworkServiceAssociation(snID, svcID, tags) + if err != nil { + return h.handleError(c, err) + } + + return c.JSON(http.StatusCreated, snsaToJSON(assoc)) +} + +func (h *Handler) handleGetSNSA(c *echo.Context, id string) error { + assoc, err := h.Backend.GetServiceNetworkServiceAssociation(id) + if err != nil { + return h.handleError(c, err) + } + + return c.JSON(http.StatusOK, snsaToJSON(assoc)) +} + +func (h *Handler) handleDeleteSNSA(c *echo.Context, id string) error { + if err := h.Backend.DeleteServiceNetworkServiceAssociation(id); err != nil { + return h.handleError(c, err) + } + + return c.JSON(http.StatusOK, map[string]any{keyStatus: statusDeleteInProgress}) +} + +func (h *Handler) handleListSNSAs(c *echo.Context) error { + maxResults := queryInt32(c) + nextToken := c.QueryParam("nextToken") + snID := c.QueryParam("serviceNetworkIdentifier") + svcID := c.QueryParam("serviceIdentifier") + + items, next, err := h.Backend.ListServiceNetworkServiceAssociations( + snID, + svcID, + maxResults, + nextToken, + ) + if err != nil { + return h.handleError(c, err) + } + + summaries := make([]any, 0, len(items)) + for _, s := range items { + summaries = append(summaries, snsaSummaryToJSON(s)) + } + + resp := map[string]any{keyItems: summaries} + if next != "" { + resp["nextToken"] = next + } + + return c.JSON(http.StatusOK, resp) +} + +// ------- SNVA handlers ------- + +func (h *Handler) handleCreateSNVA(c *echo.Context, body map[string]any) error { + snID, _ := body["serviceNetworkIdentifier"].(string) + vpcID, _ := body["vpcIdentifier"].(string) + + if snID == "" || vpcID == "" { + return c.JSON( + http.StatusBadRequest, + map[string]any{keyMessage: "serviceNetworkIdentifier and vpcIdentifier are required"}, + ) + } + + var sgs []string + if sgRaw, ok := body["securityGroupIds"].([]any); ok { + for _, v := range sgRaw { + if s, ok2 := v.(string); ok2 { + sgs = append(sgs, s) + } + } + } + + tags := extractTags(body) + + assoc, err := h.Backend.CreateServiceNetworkVpcAssociation(snID, vpcID, sgs, tags) + if err != nil { + return h.handleError(c, err) + } + + return c.JSON(http.StatusCreated, snvaToJSON(assoc)) +} + +func (h *Handler) handleGetSNVA(c *echo.Context, id string) error { + assoc, err := h.Backend.GetServiceNetworkVpcAssociation(id) + if err != nil { + return h.handleError(c, err) + } + + return c.JSON(http.StatusOK, snvaToJSON(assoc)) +} + +func (h *Handler) handleUpdateSNVA(c *echo.Context, id string, body map[string]any) error { + var sgs []string + if sgRaw, ok := body["securityGroupIds"].([]any); ok { + for _, v := range sgRaw { + if s, ok2 := v.(string); ok2 { + sgs = append(sgs, s) + } + } + } + + assoc, err := h.Backend.UpdateServiceNetworkVpcAssociation(id, sgs) + if err != nil { + return h.handleError(c, err) + } + + return c.JSON(http.StatusOK, snvaToJSON(assoc)) +} + +func (h *Handler) handleDeleteSNVA(c *echo.Context, id string) error { + if err := h.Backend.DeleteServiceNetworkVpcAssociation(id); err != nil { + return h.handleError(c, err) + } + + return c.JSON(http.StatusOK, map[string]any{keyStatus: statusDeleteInProgress}) +} + +func (h *Handler) handleListSNVAs(c *echo.Context) error { + maxResults := queryInt32(c) + nextToken := c.QueryParam("nextToken") + snID := c.QueryParam("serviceNetworkIdentifier") + vpcID := c.QueryParam("vpcIdentifier") + + items, next, err := h.Backend.ListServiceNetworkVpcAssociations( + snID, + vpcID, + maxResults, + nextToken, + ) + if err != nil { + return h.handleError(c, err) + } + + summaries := make([]any, 0, len(items)) + for _, s := range items { + summaries = append(summaries, snvaSummaryToJSON(s)) + } + + resp := map[string]any{keyItems: summaries} + if next != "" { + resp["nextToken"] = next + } + + return c.JSON(http.StatusOK, resp) +} + +// ------- Listener handlers ------- + +func (h *Handler) handleCreateListener( + c *echo.Context, + serviceID string, + body map[string]any, +) error { + name, _ := body[keyName].(string) + protocol, _ := body[keyProtocol].(string) + + if name == "" || protocol == "" { + return c.JSON( + http.StatusBadRequest, + map[string]any{keyMessage: "name and protocol are required"}, + ) + } + + port := bodyInt32(body, keyPort) + defaultAction := extractRuleAction(body, "defaultAction") + tags := extractTags(body) + + l, err := h.Backend.CreateListener(serviceID, name, protocol, port, defaultAction, tags) + if err != nil { + return h.handleError(c, err) + } + + return c.JSON(http.StatusCreated, listenerToJSON(l)) +} + +func (h *Handler) handleGetListener(c *echo.Context, serviceID, listenerID string) error { + l, err := h.Backend.GetListener(serviceID, listenerID) + if err != nil { + return h.handleError(c, err) + } + + return c.JSON(http.StatusOK, listenerToJSON(l)) +} + +func (h *Handler) handleUpdateListener( + c *echo.Context, + serviceID, listenerID string, + body map[string]any, +) error { + defaultAction := extractRuleAction(body, "defaultAction") + + l, err := h.Backend.UpdateListener(serviceID, listenerID, defaultAction) + if err != nil { + return h.handleError(c, err) + } + + return c.JSON(http.StatusOK, listenerToJSON(l)) +} + +func (h *Handler) handleDeleteListener(c *echo.Context, serviceID, listenerID string) error { + if err := h.Backend.DeleteListener(serviceID, listenerID); err != nil { + return h.handleError(c, err) + } + + return c.NoContent(http.StatusNoContent) +} + +func (h *Handler) handleListListeners(c *echo.Context, serviceID string) error { + maxResults := queryInt32(c) + nextToken := c.QueryParam("nextToken") + + items, next, err := h.Backend.ListListeners(serviceID, maxResults, nextToken) + if err != nil { + return h.handleError(c, err) + } + + summaries := make([]any, 0, len(items)) + for _, l := range items { + summaries = append(summaries, listenerSummaryToJSON(l)) + } + + resp := map[string]any{keyItems: summaries} + if next != "" { + resp["nextToken"] = next + } + + return c.JSON(http.StatusOK, resp) +} + +// ------- Rule handlers ------- + +func (h *Handler) handleCreateRule( + c *echo.Context, + serviceID, listenerID string, + body map[string]any, +) error { + name, _ := body[keyName].(string) + if name == "" { + return c.JSON(http.StatusBadRequest, map[string]any{keyMessage: keyNameRequired}) + } + + priority := bodyInt32(body, keyPriority) + action := extractRuleAction(body, "action") + match := extractRuleMatch(body, "match") + tags := extractTags(body) + + r, err := h.Backend.CreateRule(serviceID, listenerID, name, priority, action, match, tags) + if err != nil { + return h.handleError(c, err) + } + + return c.JSON(http.StatusCreated, ruleToJSON(r)) +} + +func (h *Handler) handleGetRule(c *echo.Context, serviceID, listenerID, ruleID string) error { + r, err := h.Backend.GetRule(serviceID, listenerID, ruleID) + if err != nil { + return h.handleError(c, err) + } + + return c.JSON(http.StatusOK, ruleToJSON(r)) +} + +func (h *Handler) handleUpdateRule( + c *echo.Context, + serviceID, listenerID, ruleID string, + body map[string]any, +) error { + priority := bodyInt32(body, keyPriority) + action := extractRuleAction(body, "action") + match := extractRuleMatch(body, "match") + + r, err := h.Backend.UpdateRule(serviceID, listenerID, ruleID, priority, action, match) + if err != nil { + return h.handleError(c, err) + } + + return c.JSON(http.StatusOK, ruleToJSON(r)) +} + +func (h *Handler) handleDeleteRule(c *echo.Context, serviceID, listenerID, ruleID string) error { + if err := h.Backend.DeleteRule(serviceID, listenerID, ruleID); err != nil { + return h.handleError(c, err) + } + + return c.NoContent(http.StatusNoContent) +} + +func (h *Handler) handleListRules(c *echo.Context, serviceID, listenerID string) error { + maxResults := queryInt32(c) + nextToken := c.QueryParam("nextToken") + + items, next, err := h.Backend.ListRules(serviceID, listenerID, maxResults, nextToken) + if err != nil { + return h.handleError(c, err) + } + + summaries := make([]any, 0, len(items)) + for _, r := range items { + summaries = append(summaries, ruleSummaryToJSON(r)) + } + + resp := map[string]any{keyItems: summaries} + if next != "" { + resp["nextToken"] = next + } + + return c.JSON(http.StatusOK, resp) +} + +func (h *Handler) handleBatchUpdateRule( + c *echo.Context, + serviceID, listenerID string, + body map[string]any, +) error { + var updates []*RuleUpdate + + if rawUpdates, ok := body["rules"].([]any); ok { + for _, raw := range rawUpdates { + if m, ok2 := raw.(map[string]any); ok2 { + u := &RuleUpdate{} + u.RuleIdentifier, _ = m["ruleIdentifier"].(string) + u.Priority = bodyInt32(m, keyPriority) + u.Action = extractRuleAction(m, "action") + u.Match = extractRuleMatch(m, "match") + updates = append(updates, u) + } + } + } + + successes, failures, err := h.Backend.BatchUpdateRule(serviceID, listenerID, updates) + if err != nil { + return h.handleError(c, err) + } + + successList := make([]any, 0, len(successes)) + for _, s := range successes { + successList = append(successList, ruleUpdateSuccessToJSON(s)) + } + + failureList := make([]any, 0, len(failures)) + for _, f := range failures { + failureList = append(failureList, map[string]any{ + "ruleIdentifier": f.RuleIdentifier, + "message": f.Message, + "code": f.Code, + }) + } + + return c.JSON(http.StatusOK, map[string]any{ + "successful": successList, + keyUnsuccessful: failureList, + }) +} + +// ------- TargetGroup handlers ------- + +func (h *Handler) handleCreateTargetGroup(c *echo.Context, body map[string]any) error { + name, _ := body[keyName].(string) + tgType, _ := body["type"].(string) + + if name == "" || tgType == "" { + return c.JSON( + http.StatusBadRequest, + map[string]any{keyMessage: "name and type are required"}, + ) + } + + config := extractTargetGroupConfig(body) + tags := extractTags(body) + + tg, err := h.Backend.CreateTargetGroup(name, tgType, config, tags) + if err != nil { + return h.handleError(c, err) + } + + return c.JSON(http.StatusCreated, targetGroupToJSON(tg)) +} + +func (h *Handler) handleGetTargetGroup(c *echo.Context, id string) error { + tg, err := h.Backend.GetTargetGroup(id) + if err != nil { + return h.handleError(c, err) + } + + return c.JSON(http.StatusOK, targetGroupToJSON(tg)) +} + +func (h *Handler) handleUpdateTargetGroup(c *echo.Context, id string, body map[string]any) error { + var healthCheck *HealthCheckConfig + if hcRaw, ok := body["healthCheck"].(map[string]any); ok { + healthCheck = extractHealthCheckConfig(hcRaw) + } + + tg, err := h.Backend.UpdateTargetGroup(id, healthCheck) + if err != nil { + return h.handleError(c, err) + } + + return c.JSON(http.StatusOK, targetGroupToJSON(tg)) +} + +func (h *Handler) handleDeleteTargetGroup(c *echo.Context, id string) error { + if err := h.Backend.DeleteTargetGroup(id); err != nil { + return h.handleError(c, err) + } + + return c.JSON(http.StatusOK, map[string]any{"id": id, keyStatus: statusDeleteInProgress}) +} + +func (h *Handler) handleListTargetGroups(c *echo.Context) error { + maxResults := queryInt32(c) + nextToken := c.QueryParam("nextToken") + tgType := c.QueryParam("targetGroupType") + svcArn := c.QueryParam("serviceArn") + + items, next, err := h.Backend.ListTargetGroups(tgType, svcArn, maxResults, nextToken) + if err != nil { + return h.handleError(c, err) + } + + summaries := make([]any, 0, len(items)) + for _, tg := range items { + summaries = append(summaries, targetGroupSummaryToJSON(tg)) + } + + resp := map[string]any{keyItems: summaries} + if next != "" { + resp["nextToken"] = next + } + + return c.JSON(http.StatusOK, resp) +} + +func (h *Handler) handleRegisterTargets(c *echo.Context, tgID string, body map[string]any) error { + targets := extractTargets(body) + + failures, err := h.Backend.RegisterTargets(tgID, targets) + if err != nil { + return h.handleError(c, err) + } + + failureList := make([]any, 0, len(failures)) + for _, f := range failures { + failureList = append(failureList, targetFailureToJSON(f)) + } + + return c.JSON(http.StatusOK, map[string]any{keyUnsuccessful: failureList}) +} + +func (h *Handler) handleDeregisterTargets(c *echo.Context, tgID string, body map[string]any) error { + targets := extractTargets(body) + + failures, err := h.Backend.DeregisterTargets(tgID, targets) + if err != nil { + return h.handleError(c, err) + } + + failureList := make([]any, 0, len(failures)) + for _, f := range failures { + failureList = append(failureList, targetFailureToJSON(f)) + } + + return c.JSON(http.StatusOK, map[string]any{keyUnsuccessful: failureList}) +} + +func (h *Handler) handleListTargets(c *echo.Context, tgID string, _ map[string]any) error { + maxResults := queryInt32(c) + nextToken := c.QueryParam("nextToken") + + items, next, err := h.Backend.ListTargets(tgID, maxResults, nextToken) + if err != nil { + return h.handleError(c, err) + } + + summaries := make([]any, 0, len(items)) + for _, t := range items { + summaries = append(summaries, targetSummaryToJSON(t)) + } + + resp := map[string]any{keyItems: summaries} + if next != "" { + resp["nextToken"] = next + } + + return c.JSON(http.StatusOK, resp) +} + +// ------- AccessLogSubscription handlers ------- + +func (h *Handler) handleCreateALS(c *echo.Context, body map[string]any) error { + resourceID, _ := body["resourceIdentifier"].(string) + destArn, _ := body["destinationArn"].(string) + logType, _ := body["serviceNetworkLogType"].(string) + + if resourceID == "" || destArn == "" { + return c.JSON( + http.StatusBadRequest, + map[string]any{keyMessage: "resourceIdentifier and destinationArn are required"}, + ) + } + + tags := extractTags(body) + + als, err := h.Backend.CreateAccessLogSubscription(resourceID, destArn, logType, tags) + if err != nil { + return h.handleError(c, err) + } + + return c.JSON(http.StatusCreated, alsToJSON(als)) +} + +func (h *Handler) handleGetALS(c *echo.Context, id string) error { + als, err := h.Backend.GetAccessLogSubscription(id) + if err != nil { + return h.handleError(c, err) + } + + return c.JSON(http.StatusOK, alsToJSON(als)) +} + +func (h *Handler) handleUpdateALS(c *echo.Context, id string, body map[string]any) error { + destArn, _ := body["destinationArn"].(string) + + als, err := h.Backend.UpdateAccessLogSubscription(id, destArn) + if err != nil { + return h.handleError(c, err) + } + + return c.JSON(http.StatusOK, alsToJSON(als)) +} + +func (h *Handler) handleDeleteALS(c *echo.Context, id string) error { + if err := h.Backend.DeleteAccessLogSubscription(id); err != nil { + return h.handleError(c, err) + } + + return c.NoContent(http.StatusNoContent) +} + +func (h *Handler) handleListALSs(c *echo.Context) error { + maxResults := queryInt32(c) + nextToken := c.QueryParam("nextToken") + resourceID := c.QueryParam("resourceIdentifier") + + items, next, err := h.Backend.ListAccessLogSubscriptions(resourceID, maxResults, nextToken) + if err != nil { + return h.handleError(c, err) + } + + summaries := make([]any, 0, len(items)) + for _, a := range items { + summaries = append(summaries, alsSummaryToJSON(a)) + } + + resp := map[string]any{keyItems: summaries} + if next != "" { + resp["nextToken"] = next + } + + return c.JSON(http.StatusOK, resp) +} + +// ------- Auth/Resource Policy handlers ------- + +func (h *Handler) handlePutAuthPolicy( + c *echo.Context, + resourceID string, + body map[string]any, +) error { + policy, _ := body[keyPolicy].(string) + + ap, err := h.Backend.PutAuthPolicy(resourceID, policy) + if err != nil { + return h.handleError(c, err) + } + + return c.JSON(http.StatusOK, map[string]any{ + keyPolicy: ap.Policy, + "state": ap.State, + }) +} + +func (h *Handler) handleGetAuthPolicy(c *echo.Context, resourceID string) error { + ap, err := h.Backend.GetAuthPolicy(resourceID) + if err != nil { + return h.handleError(c, err) + } + + return c.JSON(http.StatusOK, map[string]any{ + keyPolicy: ap.Policy, + "state": ap.State, + }) +} + +func (h *Handler) handleDeleteAuthPolicy(c *echo.Context, resourceID string) error { + if err := h.Backend.DeleteAuthPolicy(resourceID); err != nil { + return h.handleError(c, err) + } + + return c.NoContent(http.StatusNoContent) +} + +func (h *Handler) handlePutResourcePolicy( + c *echo.Context, + resourceArn string, + body map[string]any, +) error { + policy, _ := body[keyPolicy].(string) + + if err := h.Backend.PutResourcePolicy(resourceArn, policy); err != nil { + return h.handleError(c, err) + } + + return c.JSON(http.StatusOK, map[string]any{}) +} + +func (h *Handler) handleGetResourcePolicy(c *echo.Context, resourceArn string) error { + policy, err := h.Backend.GetResourcePolicy(resourceArn) + if err != nil { + return h.handleError(c, err) + } + + return c.JSON(http.StatusOK, map[string]any{keyPolicy: policy}) +} + +func (h *Handler) handleDeleteResourcePolicy(c *echo.Context, resourceArn string) error { + if err := h.Backend.DeleteResourcePolicy(resourceArn); err != nil { + return h.handleError(c, err) + } + + return c.NoContent(http.StatusNoContent) +} + +// ------- Tagging handlers ------- + +func (h *Handler) handleTagResource( + c *echo.Context, + resourceArn string, + body map[string]any, +) error { + tags := make(map[string]string) + if t, ok := body["tags"].(map[string]any); ok { + for k, v := range t { + if s, ok2 := v.(string); ok2 { + tags[k] = s + } + } + } + + if err := h.Backend.TagResource(resourceArn, tags); err != nil { + return h.handleError(c, err) + } + + return c.JSON(http.StatusOK, map[string]any{}) +} + +func (h *Handler) handleUntagResource(c *echo.Context, resourceArn string) error { + keys := c.Request().URL.Query()["tagKeys"] + + if err := h.Backend.UntagResource(resourceArn, keys); err != nil { + return h.handleError(c, err) + } + + return c.NoContent(http.StatusNoContent) +} + +func (h *Handler) handleListTagsForResource(c *echo.Context, resourceArn string) error { + tags, err := h.Backend.ListTagsForResource(resourceArn) + if err != nil { + return h.handleError(c, err) + } + + return c.JSON(http.StatusOK, map[string]any{"tags": tags}) +} + +// ------- Path classification ------- + +// classifyPath maps (method, path) → (op, id1, id2, id3). +// id1..id3 are path segments in order (service, listener, rule etc.). +// classifyPath maps method+path to (op,id1,id2,id3). +func classifyPath( //nolint:gocyclo,cyclop,funlen // routing dispatch + method, path string, +) (string, string, string, string) { + switch { + case path == pathServices: + if method == http.MethodPost { + return opCreateService, "", "", "" + } + + return opListServices, "", "", "" + case strings.HasPrefix(path, pathServices+"/"): + return classifyServicePath(method, path) + + case path == pathServiceNetworks: + if method == http.MethodPost { + return opCreateSN, "", "", "" + } + + return opListSNs, "", "", "" + case strings.HasPrefix(path, pathServiceNetworks+"/"): + return classifyServiceNetworkPath(method, path) + + case path == pathServiceNetworkServiceAssociations: + if method == http.MethodPost { + return opCreateSNSA, "", "", "" + } + + return opListSNSAs, "", "", "" + case strings.HasPrefix(path, pathServiceNetworkServiceAssociations+"/"): + return classifySNSAPath(method, path) + + case path == pathServiceNetworkVpcAssociations: + if method == http.MethodPost { + return opCreateSNVA, "", "", "" + } + + return opListSNVAs, "", "", "" + case strings.HasPrefix(path, pathServiceNetworkVpcAssociations+"/"): + return classifySNVAPath(method, path) + + case path == pathTargetGroups: + if method == http.MethodPost { + return opCreateTG, "", "", "" + } + + return opListTGs, "", "", "" + case strings.HasPrefix(path, pathTargetGroups+"/"): + return classifyTargetGroupPath(method, path) + + case path == pathAccessLogSubscriptions: + if method == http.MethodPost { + return opCreateALS, "", "", "" + } + + return opListALSs, "", "", "" + case strings.HasPrefix(path, pathAccessLogSubscriptions+"/"): + return classifyALSPath(method, path) + + case strings.HasPrefix(path, pathAuthPolicy+"/"): + resourceID := strings.TrimPrefix(path, pathAuthPolicy+"/") + switch method { + case http.MethodPut: + return opPutAuthPolicy, resourceID, "", "" + case http.MethodGet: + return opGetAuthPolicy, resourceID, "", "" + case http.MethodDelete: + return opDeleteAuthPolicy, resourceID, "", "" + } + + case strings.HasPrefix(path, pathResourcePolicy+"/"): + resourceArn := strings.TrimPrefix(path, pathResourcePolicy+"/") + switch method { + case http.MethodPut: + return opPutResourcePolicy, resourceArn, "", "" + case http.MethodGet: + return opGetResourcePolicy, resourceArn, "", "" + case http.MethodDelete: + return opDeleteResourcePolicy, resourceArn, "", "" + } + + case strings.HasPrefix(path, pathTags+"/"): + resourceArn := strings.TrimPrefix(path, pathTags+"/") + switch method { + case http.MethodPost: + return opTagResource, resourceArn, "", "" + case http.MethodDelete: + return opUntagResource, resourceArn, "", "" + case http.MethodGet: + return opListTagsForResource, resourceArn, "", "" + } + } + + return opUnknown, "", "", "" +} + +// classifyServicePath handles /services/{serviceID}[/listeners[/...]]. +func classifyServicePath( //nolint:gocognit,cyclop // routing dispatch + method, path string, +) (string, string, string, string) { + rest := strings.TrimPrefix(path, pathServices+"/") + serviceID, sub, hasSub := strings.Cut(rest, "/") + + if !hasSub { + switch method { + case http.MethodGet: + return opGetService, serviceID, "", "" + case http.MethodPatch: + return opUpdateService, serviceID, "", "" + case http.MethodDelete: + return opDeleteService, serviceID, "", "" + } + + return opUnknown, serviceID, "", "" + } + + // sub = listeners[/{listenerID}[/rules[/{ruleID}]]] + if sub == "listeners" { + if method == http.MethodPost { + return opCreateListener, serviceID, "", "" + } + + return opListListeners, serviceID, "", "" + } + + if listenerRest, ok := strings.CutPrefix(sub, "listeners/"); ok { //nolint:nestif // nested path hierarchy + listenerID, listenerSub, hasListenerSub := strings.Cut(listenerRest, "/") + + if !hasListenerSub { + switch method { + case http.MethodGet: + return opGetListener, serviceID, listenerID, "" + case http.MethodPatch: + return opUpdateListener, serviceID, listenerID, "" + case http.MethodDelete: + return opDeleteListener, serviceID, listenerID, "" + } + + return opUnknown, serviceID, listenerID, "" + } + + if listenerSub == "rules" { + if method == http.MethodPost { + return opCreateRule, serviceID, listenerID, "" + } + + if method == http.MethodPatch { + return opBatchUpdateRule, serviceID, listenerID, "" + } + + return opListRules, serviceID, listenerID, "" + } + + if ruleID, ok2 := strings.CutPrefix(listenerSub, "rules/"); ok2 { + switch method { + case http.MethodGet: + return opGetRule, serviceID, listenerID, ruleID + case http.MethodPatch: + return opUpdateRule, serviceID, listenerID, ruleID + case http.MethodDelete: + return opDeleteRule, serviceID, listenerID, ruleID + } + } + } + + return opUnknown, serviceID, "", "" +} + +// classifyServiceNetworkPath handles /servicenetworks/{id}. +func classifyServiceNetworkPath( + method, path string, +) (string, string, string, string) { + id := strings.TrimPrefix(path, pathServiceNetworks+"/") + switch method { + case http.MethodGet: + return opGetSN, id, "", "" + case http.MethodPatch: + return opUpdateSN, id, "", "" + case http.MethodDelete: + return opDeleteSN, id, "", "" + } + + return opUnknown, id, "", "" +} + +// classifySNSAPath handles /servicenetworkserviceassociations/{id}. +func classifySNSAPath( + method, path string, +) (string, string, string, string) { + id := strings.TrimPrefix(path, pathServiceNetworkServiceAssociations+"/") + switch method { + case http.MethodGet: + return opGetSNSA, id, "", "" + case http.MethodDelete: + return opDeleteSNSA, id, "", "" + } + + return opUnknown, id, "", "" +} + +// classifySNVAPath handles /servicenetworkvpcassociations/{id}. +func classifySNVAPath( + method, path string, +) (string, string, string, string) { + id := strings.TrimPrefix(path, pathServiceNetworkVpcAssociations+"/") + switch method { + case http.MethodGet: + return opGetSNVA, id, "", "" + case http.MethodPatch: + return opUpdateSNVA, id, "", "" + case http.MethodDelete: + return opDeleteSNVA, id, "", "" + } + + return opUnknown, id, "", "" +} + +// classifyTargetGroupPath handles /targetgroups/{id}[/registertargets|deregistertargets|listtargets]. +func classifyTargetGroupPath( + method, path string, +) (string, string, string, string) { + rest := strings.TrimPrefix(path, pathTargetGroups+"/") + tgID, sub, hasSub := strings.Cut(rest, "/") + + if !hasSub { + switch method { + case http.MethodGet: + return opGetTG, tgID, "", "" + case http.MethodPatch: + return opUpdateTG, tgID, "", "" + case http.MethodDelete: + return opDeleteTG, tgID, "", "" + } + + return opUnknown, tgID, "", "" + } + + switch sub { + case "registertargets": + return opRegisterTargets, tgID, "", "" + case "deregistertargets": + return opDeregisterTargets, tgID, "", "" + case "listtargets": + return opListTargets, tgID, "", "" + } + + return opUnknown, tgID, "", "" +} + +// classifyALSPath handles /accesslogsubscriptions/{id}. +func classifyALSPath( + method, path string, +) (string, string, string, string) { + id := strings.TrimPrefix(path, pathAccessLogSubscriptions+"/") + switch method { + case http.MethodGet: + return opGetALS, id, "", "" + case http.MethodPatch: + return opUpdateALS, id, "", "" + case http.MethodDelete: + return opDeleteALS, id, "", "" + } + + return opUnknown, id, "", "" +} + +// ------- JSON serialization helpers ------- + +func serviceToJSON(s *Service) map[string]any { + m := map[string]any{ + keyARN: s.ARN, + "id": s.ID, + keyName: s.Name, + "authType": s.AuthType, + keyStatus: s.Status, + keyCreatedAt: s.CreatedAt.Format("2006-01-02T15:04:05.000Z"), + keyLastUpdatedAt: s.LastUpdatedAt.Format("2006-01-02T15:04:05.000Z"), + "dnsEntry": map[string]any{"domainName": s.DNSName}, + } + + if s.CertificateArn != "" { + m["certificateArn"] = s.CertificateArn + } + + if s.CustomDomainName != "" { + m["customDomainName"] = s.CustomDomainName + } + + return m +} + +func serviceSummaryToJSON(s *ServiceSummary) map[string]any { + m := map[string]any{ + keyARN: s.ARN, + "id": s.ID, + keyName: s.Name, + keyStatus: s.Status, + keyCreatedAt: s.CreatedAt.Format("2006-01-02T15:04:05.000Z"), + } + + if s.DNSName != "" { + m["dnsEntry"] = map[string]any{"domainName": s.DNSName} + } + + if s.CustomDomainName != "" { + m["customDomainName"] = s.CustomDomainName + } + + return m +} + +func serviceNetworkToJSON(s *ServiceNetwork) map[string]any { + return map[string]any{ + keyARN: s.ARN, + "id": s.ID, + keyName: s.Name, + "authType": s.AuthType, + "numberOfAssociatedServices": s.NumberOfAssociatedServices, + "numberOfAssociatedVPCs": s.NumberOfAssociatedVPCs, + keyCreatedAt: s.CreatedAt.Format("2006-01-02T15:04:05.000Z"), + keyLastUpdatedAt: s.LastUpdatedAt.Format("2006-01-02T15:04:05.000Z"), + } +} + +func serviceNetworkSummaryToJSON(s *ServiceNetworkSummary) map[string]any { + return map[string]any{ + keyARN: s.ARN, + "id": s.ID, + keyName: s.Name, + "numberOfAssociatedServices": s.NumberOfAssociatedServices, + "numberOfAssociatedVPCs": s.NumberOfAssociatedVPCs, + keyCreatedAt: s.CreatedAt.Format("2006-01-02T15:04:05.000Z"), + } +} + +func snsaToJSON(s *ServiceNetworkServiceAssociation) map[string]any { + return map[string]any{ + keyARN: s.ARN, + "id": s.ID, + keyServiceARN: s.ServiceARN, + keyServiceID: s.ServiceID, + "serviceName": s.ServiceName, + keyServiceNetworkARN: s.ServiceNetworkARN, + keyServiceNetworkID: s.ServiceNetworkID, + keyServiceNetworkName: s.ServiceNetworkName, + keyStatus: s.Status, + "createdBy": s.CreatedBy, + keyCreatedAt: s.CreatedAt.Format("2006-01-02T15:04:05.000Z"), + } +} + +func snsaSummaryToJSON(s *ServiceNetworkServiceAssociationSummary) map[string]any { + return map[string]any{ + keyARN: s.ARN, + "id": s.ID, + keyServiceARN: s.ServiceARN, + keyServiceID: s.ServiceID, + "serviceName": s.ServiceName, + keyServiceNetworkARN: s.ServiceNetworkARN, + keyServiceNetworkID: s.ServiceNetworkID, + keyServiceNetworkName: s.ServiceNetworkName, + keyStatus: s.Status, + keyCreatedAt: s.CreatedAt.Format("2006-01-02T15:04:05.000Z"), + } +} + +func snvaToJSON(s *ServiceNetworkVpcAssociation) map[string]any { + sgs := make([]string, len(s.SecurityGroupIDs)) + copy(sgs, s.SecurityGroupIDs) + + return map[string]any{ + keyARN: s.ARN, + "id": s.ID, + keyVPCID: s.VpcID, + keyServiceNetworkARN: s.ServiceNetworkARN, + keyServiceNetworkID: s.ServiceNetworkID, + keyServiceNetworkName: s.ServiceNetworkName, + "securityGroupIds": sgs, + keyStatus: s.Status, + "createdBy": s.CreatedBy, + keyCreatedAt: s.CreatedAt.Format("2006-01-02T15:04:05.000Z"), + keyLastUpdatedAt: s.LastUpdatedAt.Format("2006-01-02T15:04:05.000Z"), + } +} + +func snvaSummaryToJSON(s *ServiceNetworkVpcAssociationSummary) map[string]any { + return map[string]any{ + keyARN: s.ARN, + "id": s.ID, + keyVPCID: s.VpcID, + keyServiceNetworkARN: s.ServiceNetworkARN, + keyServiceNetworkID: s.ServiceNetworkID, + keyServiceNetworkName: s.ServiceNetworkName, + keyStatus: s.Status, + keyCreatedAt: s.CreatedAt.Format("2006-01-02T15:04:05.000Z"), + } +} + +func listenerToJSON(l *Listener) map[string]any { + m := map[string]any{ + keyARN: l.ARN, + "id": l.ID, + keyServiceARN: l.ServiceARN, + keyServiceID: l.ServiceID, + keyName: l.Name, + keyProtocol: l.Protocol, + keyPort: l.Port, + keyCreatedAt: l.CreatedAt.Format("2006-01-02T15:04:05.000Z"), + keyLastUpdatedAt: l.LastUpdatedAt.Format("2006-01-02T15:04:05.000Z"), + } + + if l.DefaultAction != nil { + m["defaultAction"] = ruleActionToJSON(l.DefaultAction) + } + + return m +} + +func listenerSummaryToJSON(l *ListenerSummary) map[string]any { + return map[string]any{ + keyARN: l.ARN, + "id": l.ID, + keyName: l.Name, + keyProtocol: l.Protocol, + keyPort: l.Port, + keyCreatedAt: l.CreatedAt.Format("2006-01-02T15:04:05.000Z"), + keyLastUpdatedAt: l.LastUpdatedAt.Format("2006-01-02T15:04:05.000Z"), + } +} + +func ruleToJSON(r *Rule) map[string]any { + m := map[string]any{ + keyARN: r.ARN, + "id": r.ID, + keyName: r.Name, + keyPriority: r.Priority, + keyIsDefault: r.IsDefault, + keyCreatedAt: r.CreatedAt.Format("2006-01-02T15:04:05.000Z"), + keyLastUpdatedAt: r.LastUpdatedAt.Format("2006-01-02T15:04:05.000Z"), + } + + if r.Action != nil { + m["action"] = ruleActionToJSON(r.Action) + } + + if r.Match != nil { + m["match"] = ruleMatchToJSON(r.Match) + } + + return m +} + +func ruleSummaryToJSON(r *RuleSummary) map[string]any { + return map[string]any{ + keyARN: r.ARN, + "id": r.ID, + keyName: r.Name, + keyPriority: r.Priority, + keyIsDefault: r.IsDefault, + } +} + +func ruleUpdateSuccessToJSON(r *RuleUpdateSuccess) map[string]any { + m := map[string]any{ + keyARN: r.ARN, + "id": r.ID, + keyName: r.Name, + keyPriority: r.Priority, + keyIsDefault: r.IsDefault, + } + + if r.Action != nil { + m["action"] = ruleActionToJSON(r.Action) + } + + if r.Match != nil { + m["match"] = ruleMatchToJSON(r.Match) + } + + return m +} + +func ruleActionToJSON(a *RuleAction) map[string]any { + if a.IsFixedResponse { + return map[string]any{ + "fixedResponse": map[string]any{ + "statusCode": a.FixedResponseStatusCode, + }, + } + } + + wts := make([]any, 0, len(a.ForwardTargetGroups)) + for _, w := range a.ForwardTargetGroups { + wts = append(wts, map[string]any{ + "targetGroupIdentifier": w.TargetGroupID, + "weight": w.Weight, + }) + } + + return map[string]any{ + "forward": map[string]any{ + "targetGroups": wts, + }, + } +} + +func ruleMatchToJSON(m *RuleMatch) map[string]any { + if m == nil { + return nil + } + + httpMatch := map[string]any{} + if m.HTTPMethod != "" { + httpMatch["method"] = m.HTTPMethod + } + + if m.PathMatchType != "" { + pathMatch := map[string]any{ + "match": map[string]any{ + m.PathMatchType: m.PathMatchValue, + }, + } + httpMatch["path"] = pathMatch + } + + if len(m.HeaderMatches) > 0 { + headers := make([]any, 0, len(m.HeaderMatches)) + for _, h := range m.HeaderMatches { + headers = append(headers, map[string]any{ + keyName: h.Name, + "match": map[string]any{h.MatchType: h.MatchValue}, + }) + } + + httpMatch["headerMatches"] = headers + } + + return map[string]any{"httpMatch": httpMatch} +} + +func targetGroupToJSON(tg *TargetGroup) map[string]any { + m := map[string]any{ + keyARN: tg.ARN, + "id": tg.ID, + keyName: tg.Name, + "type": tg.Type, + keyStatus: tg.Status, + "serviceArns": tg.ServiceARNs, + keyCreatedAt: tg.CreatedAt.Format("2006-01-02T15:04:05.000Z"), + keyLastUpdatedAt: tg.LastUpdatedAt.Format("2006-01-02T15:04:05.000Z"), + } + + if tg.Config != nil { + m["config"] = targetGroupConfigToJSON(tg.Config) + } + + return m +} + +func targetGroupSummaryToJSON(tg *TargetGroupSummary) map[string]any { + return map[string]any{ + keyARN: tg.ARN, + "id": tg.ID, + keyName: tg.Name, + "type": tg.Type, + keyStatus: tg.Status, + keyPort: tg.Port, + keyProtocol: tg.Protocol, + keyVPCID: tg.VpcID, + "serviceArns": tg.ServiceARNs, + keyCreatedAt: tg.CreatedAt.Format("2006-01-02T15:04:05.000Z"), + } +} + +func targetGroupConfigToJSON(c *TargetGroupConfig) map[string]any { + m := map[string]any{ + keyPort: c.Port, + keyProtocol: c.Protocol, + "protocolVersion": c.ProtocolVersion, + "vpcIdentifier": c.VpcID, + } + + if c.HealthCheck != nil { + m["healthCheck"] = healthCheckToJSON(c.HealthCheck) + } + + return m +} + +func healthCheckToJSON(hc *HealthCheckConfig) map[string]any { + return map[string]any{ + "enabled": hc.Enabled, + keyProtocol: hc.Protocol, + "path": hc.Path, + keyPort: hc.Port, + "healthyThresholdCount": hc.HealthyThresholdCount, + "unhealthyThresholdCount": hc.UnhealthyThresholdCount, + "healthCheckIntervalSeconds": hc.HealthCheckIntervalSeconds, + "healthCheckTimeoutSeconds": hc.HealthCheckTimeoutSeconds, + } +} + +func targetSummaryToJSON(t *TargetSummary) map[string]any { + return map[string]any{ + "id": t.ID, + keyPort: t.Port, + keyStatus: t.Status, + "reasonCode": t.ReasonCode, + } +} + +func targetFailureToJSON(f *TargetFailure) map[string]any { + return map[string]any{ + "id": f.ID, + keyPort: f.Port, + "code": f.Code, + "message": f.Message, + } +} + +func alsToJSON(a *AccessLogSubscription) map[string]any { + return map[string]any{ + keyARN: a.ARN, + "id": a.ID, + "resourceArn": a.ResourceARN, + "resourceId": a.ResourceID, + "destinationArn": a.DestinationARN, + keyCreatedAt: a.CreatedAt.Format("2006-01-02T15:04:05.000Z"), + keyLastUpdatedAt: a.LastUpdatedAt.Format("2006-01-02T15:04:05.000Z"), + } +} + +func alsSummaryToJSON(a *AccessLogSubscriptionSummary) map[string]any { + return map[string]any{ + keyARN: a.ARN, + "id": a.ID, + "resourceArn": a.ResourceARN, + "resourceId": a.ResourceID, + "destinationArn": a.DestinationARN, + keyCreatedAt: a.CreatedAt.Format("2006-01-02T15:04:05.000Z"), + } +} + +// ------- Body extraction helpers ------- + +func extractTags(body map[string]any) map[string]string { + tags := make(map[string]string) + + if t, ok := body["tags"].(map[string]any); ok { + for k, v := range t { + if s, ok2 := v.(string); ok2 { + tags[k] = s + } + } + } + + return tags +} + +func bodyInt32(body map[string]any, key string) int32 { + switch v := body[key].(type) { + case float64: + return int32(v) + case int: + return int32(v) //nolint:gosec // value bounded by JSON number range + case int32: + return v + case int64: + return int32(v) //nolint:gosec // value bounded by JSON number range + } + + return 0 +} + +func extractRuleAction(body map[string]any, key string) *RuleAction { + raw, ok := body[key].(map[string]any) + if !ok { + return nil + } + + action := &RuleAction{} + + if fr, ok2 := raw["fixedResponse"].(map[string]any); ok2 { + action.IsFixedResponse = true + action.FixedResponseStatusCode = bodyInt32(fr, "statusCode") + + return action + } + + if fwd, ok2 := raw["forward"].(map[string]any); ok2 { + if tgs, ok3 := fwd["targetGroups"].([]any); ok3 { + for _, tgRaw := range tgs { + if tgMap, ok4 := tgRaw.(map[string]any); ok4 { + wt := &WeightedTargetGroup{} + wt.TargetGroupID, _ = tgMap["targetGroupIdentifier"].(string) + wt.Weight = bodyInt32(tgMap, "weight") + action.ForwardTargetGroups = append(action.ForwardTargetGroups, wt) + } + } + } + } + + return action +} + +func extractRuleMatch(body map[string]any, key string) *RuleMatch { //nolint:gocognit // nested extraction + raw, ok := body[key].(map[string]any) + if !ok { + return nil + } + + match := &RuleMatch{} + + if httpMatch, ok2 := raw["httpMatch"].(map[string]any); ok2 { //nolint:nestif // HTTP match needs nested parsing + match.HTTPMethod, _ = httpMatch["method"].(string) + + if pathRaw, ok3 := httpMatch["path"].(map[string]any); ok3 { + if matchRaw, ok4 := pathRaw["match"].(map[string]any); ok4 { + for k, v := range matchRaw { + if s, ok5 := v.(string); ok5 { + match.PathMatchType = k + match.PathMatchValue = s + } + } + } + } + + if headersRaw, ok3 := httpMatch["headerMatches"].([]any); ok3 { + for _, hRaw := range headersRaw { + if hMap, ok4 := hRaw.(map[string]any); ok4 { + hm := &HeaderMatch{} + hm.Name, _ = hMap[keyName].(string) + if matchRaw, ok5 := hMap["match"].(map[string]any); ok5 { + for k, v := range matchRaw { + if s, ok6 := v.(string); ok6 { + hm.MatchType = k + hm.MatchValue = s + } + } + } + + match.HeaderMatches = append(match.HeaderMatches, hm) + } + } + } + } + + return match +} + +func extractTargetGroupConfig(body map[string]any) *TargetGroupConfig { + raw, ok := body["config"].(map[string]any) + if !ok { + return nil + } + + cfg := &TargetGroupConfig{} + cfg.Port = bodyInt32(raw, keyPort) + cfg.Protocol, _ = raw[keyProtocol].(string) + cfg.ProtocolVersion, _ = raw["protocolVersion"].(string) + cfg.VpcID, _ = raw["vpcIdentifier"].(string) + cfg.IPAddressType, _ = raw["ipAddressType"].(string) + cfg.LambdaEventStructureVersion, _ = raw["lambdaEventStructureVersion"].(string) + + if hcRaw, ok2 := raw["healthCheck"].(map[string]any); ok2 { + cfg.HealthCheck = extractHealthCheckConfig(hcRaw) + } + + return cfg +} + +func extractHealthCheckConfig(raw map[string]any) *HealthCheckConfig { + hc := &HealthCheckConfig{} + if v, ok := raw["enabled"].(bool); ok { + hc.Enabled = v + } + + hc.Protocol, _ = raw[keyProtocol].(string) + hc.ProtocolVersion, _ = raw["protocolVersion"].(string) + hc.Path, _ = raw["path"].(string) + hc.Port = bodyInt32(raw, keyPort) + hc.HealthyThresholdCount = bodyInt32(raw, "healthyThresholdCount") + hc.UnhealthyThresholdCount = bodyInt32(raw, "unhealthyThresholdCount") + hc.HealthCheckIntervalSeconds = bodyInt32(raw, "healthCheckIntervalSeconds") + hc.HealthCheckTimeoutSeconds = bodyInt32(raw, "healthCheckTimeoutSeconds") + + return hc +} + +func extractTargets(body map[string]any) []*Target { + var targets []*Target + + if raw, ok := body["targets"].([]any); ok { + for _, tRaw := range raw { + if tMap, ok2 := tRaw.(map[string]any); ok2 { + t := &Target{} + t.ID, _ = tMap["id"].(string) + t.Port = bodyInt32(tMap, keyPort) + targets = append(targets, t) + } + } + } + + return targets +} + +func queryInt32(c *echo.Context) int32 { + v := c.QueryParam("maxResults") + if v == "" { + return 0 + } + + n, err := strconv.ParseInt(v, 10, 32) + if err != nil { + return 0 + } + + return int32(n) +} diff --git a/services/vpclattice/handler_test.go b/services/vpclattice/handler_test.go new file mode 100644 index 000000000..bd512cf8e --- /dev/null +++ b/services/vpclattice/handler_test.go @@ -0,0 +1,890 @@ +package vpclattice_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/services/vpclattice" +) + +func newTestHandler(t *testing.T) *vpclattice.Handler { + t.Helper() + backend := vpclattice.NewInMemoryBackend("000000000000", "us-east-1") + + return vpclattice.NewHandler(backend) +} + +func doRequest( + t *testing.T, + h *vpclattice.Handler, + method, path string, + body any, +) *httptest.ResponseRecorder { + t.Helper() + + var buf *bytes.Reader + if body != nil { + data, err := json.Marshal(body) + require.NoError(t, err) + buf = bytes.NewReader(data) + } else { + buf = bytes.NewReader(nil) + } + + req := httptest.NewRequest(method, path, buf) + if body != nil { + req.Header.Set("Content-Type", "application/json") + req.ContentLength = int64(buf.Len()) + } + + rec := httptest.NewRecorder() + e := echo.New() + c := e.NewContext(req, rec) + require.NoError(t, h.Handler()(c)) + + return rec +} + +func parseBody(t *testing.T, rec *httptest.ResponseRecorder) map[string]any { + t.Helper() + var m map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &m)) + + return m +} + +// TestService_CRUD tests create/get/update/delete/list for services. +func TestService_CRUD(t *testing.T) { + t.Parallel() + + tests := []struct { //nolint:govet // readability: name first + name string + body map[string]any + wantCode int + check func(t *testing.T, resp map[string]any) + }{ + { + name: "create missing name returns 400", + body: map[string]any{}, + wantCode: http.StatusBadRequest, + }, + { + name: "create with name returns 201", + body: map[string]any{"name": "my-svc"}, + wantCode: http.StatusCreated, + check: func(t *testing.T, resp map[string]any) { + t.Helper() + assert.Contains( + t, + resp["arn"], + "arn:aws:vpc-lattice:us-east-1:000000000000:service/svc-", + ) + assert.Equal(t, "my-svc", resp["name"]) + assert.Equal(t, "ACTIVE", resp["status"]) + assert.NotEmpty(t, resp["id"]) + }, + }, + { + name: "create duplicate name returns 409", + body: map[string]any{"name": "dup-svc"}, + wantCode: http.StatusCreated, + }, + } + + h := newTestHandler(t) + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + rec := doRequest(t, h, http.MethodPost, "/services", tc.body) + assert.Equal(t, tc.wantCode, rec.Code) + if tc.check != nil { + tc.check(t, parseBody(t, rec)) + } + }) + } +} + +func TestService_DuplicateName(t *testing.T) { + t.Parallel() + h := newTestHandler(t) + + rec := doRequest(t, h, http.MethodPost, "/services", map[string]any{"name": "dup"}) + require.Equal(t, http.StatusCreated, rec.Code) + + rec2 := doRequest(t, h, http.MethodPost, "/services", map[string]any{"name": "dup"}) + assert.Equal(t, http.StatusConflict, rec2.Code) +} + +func TestService_GetUpdateDelete(t *testing.T) { + t.Parallel() + h := newTestHandler(t) + + // create + rec := doRequest(t, h, http.MethodPost, "/services", map[string]any{"name": "svc1"}) + require.Equal(t, http.StatusCreated, rec.Code) + created := parseBody(t, rec) + id, _ := created["id"].(string) + require.NotEmpty(t, id) + + // get by id + rec = doRequest(t, h, http.MethodGet, "/services/"+id, nil) + assert.Equal(t, http.StatusOK, rec.Code) + got := parseBody(t, rec) + assert.Equal(t, "svc1", got["name"]) + + // get not found + rec = doRequest(t, h, http.MethodGet, "/services/svc-notexist", nil) + assert.Equal(t, http.StatusNotFound, rec.Code) + + // update + rec = doRequest(t, h, http.MethodPatch, "/services/"+id, map[string]any{"authType": "AWS_IAM"}) + assert.Equal(t, http.StatusOK, rec.Code) + updated := parseBody(t, rec) + assert.Equal(t, "AWS_IAM", updated["authType"]) + + // list + rec = doRequest(t, h, http.MethodGet, "/services", nil) + assert.Equal(t, http.StatusOK, rec.Code) + list := parseBody(t, rec) + items, _ := list["items"].([]any) + assert.Len(t, items, 1) + + // delete + rec = doRequest(t, h, http.MethodDelete, "/services/"+id, nil) + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, 0, vpclattice.ServiceCount(h.Backend.(*vpclattice.InMemoryBackend))) + + // get after delete returns 404 + rec = doRequest(t, h, http.MethodGet, "/services/"+id, nil) + assert.Equal(t, http.StatusNotFound, rec.Code) +} + +// TestServiceNetwork_CRUD tests service networks. +func TestServiceNetwork_CRUD(t *testing.T) { + t.Parallel() + h := newTestHandler(t) + + // create + rec := doRequest(t, h, http.MethodPost, "/servicenetworks", map[string]any{"name": "sn1"}) + require.Equal(t, http.StatusCreated, rec.Code) + created := parseBody(t, rec) + id, _ := created["id"].(string) + require.NotEmpty(t, id) + assert.Contains( + t, + created["arn"], + "arn:aws:vpc-lattice:us-east-1:000000000000:servicenetwork/sn-", + ) + + // get + rec = doRequest(t, h, http.MethodGet, "/servicenetworks/"+id, nil) + assert.Equal(t, http.StatusOK, rec.Code) + got := parseBody(t, rec) + assert.Equal(t, "sn1", got["name"]) + + // update + rec = doRequest( + t, + h, + http.MethodPatch, + "/servicenetworks/"+id, + map[string]any{"authType": "AWS_IAM"}, + ) + assert.Equal(t, http.StatusOK, rec.Code) + + // list + rec = doRequest(t, h, http.MethodGet, "/servicenetworks", nil) + assert.Equal(t, http.StatusOK, rec.Code) + list := parseBody(t, rec) + items, _ := list["items"].([]any) + assert.Len(t, items, 1) + assert.Equal(t, 1, vpclattice.ServiceNetworkCount(h.Backend.(*vpclattice.InMemoryBackend))) + + // delete + rec = doRequest(t, h, http.MethodDelete, "/servicenetworks/"+id, nil) + assert.Equal(t, http.StatusNoContent, rec.Code) + assert.Equal(t, 0, vpclattice.ServiceNetworkCount(h.Backend.(*vpclattice.InMemoryBackend))) +} + +// TestSNSA_CRUD tests service network service associations. +func TestSNSA_CRUD(t *testing.T) { + t.Parallel() + h := newTestHandler(t) + + // create prerequisite resources + recSN := doRequest(t, h, http.MethodPost, "/servicenetworks", map[string]any{"name": "net1"}) + require.Equal(t, http.StatusCreated, recSN.Code) + snID, _ := parseBody(t, recSN)["id"].(string) + + recSvc := doRequest(t, h, http.MethodPost, "/services", map[string]any{"name": "svc1"}) + require.Equal(t, http.StatusCreated, recSvc.Code) + svcID, _ := parseBody(t, recSvc)["id"].(string) + + // create association + rec := doRequest(t, h, http.MethodPost, "/servicenetworkserviceassociations", map[string]any{ + "serviceNetworkIdentifier": snID, + "serviceIdentifier": svcID, + }) + require.Equal(t, http.StatusCreated, rec.Code) + assoc := parseBody(t, rec) + assocID, _ := assoc["id"].(string) + require.NotEmpty(t, assocID) + assert.Equal(t, "ACTIVE", assoc["status"]) + + // get + rec = doRequest(t, h, http.MethodGet, "/servicenetworkserviceassociations/"+assocID, nil) + assert.Equal(t, http.StatusOK, rec.Code) + + // list + rec = doRequest(t, h, http.MethodGet, "/servicenetworkserviceassociations", nil) + assert.Equal(t, http.StatusOK, rec.Code) + list := parseBody(t, rec) + items, _ := list["items"].([]any) + assert.Len(t, items, 1) + + // duplicate association returns conflict + rec = doRequest(t, h, http.MethodPost, "/servicenetworkserviceassociations", map[string]any{ + "serviceNetworkIdentifier": snID, + "serviceIdentifier": svcID, + }) + assert.Equal(t, http.StatusConflict, rec.Code) + + // delete + rec = doRequest(t, h, http.MethodDelete, "/servicenetworkserviceassociations/"+assocID, nil) + assert.Equal(t, http.StatusOK, rec.Code) +} + +// TestSNVA_CRUD tests service network VPC associations. +func TestSNVA_CRUD(t *testing.T) { + t.Parallel() + h := newTestHandler(t) + + // create service network + recSN := doRequest(t, h, http.MethodPost, "/servicenetworks", map[string]any{"name": "net2"}) + require.Equal(t, http.StatusCreated, recSN.Code) + snID, _ := parseBody(t, recSN)["id"].(string) + + // create + rec := doRequest(t, h, http.MethodPost, "/servicenetworkvpcassociations", map[string]any{ + "serviceNetworkIdentifier": snID, + "vpcIdentifier": "vpc-1234567890", + "securityGroupIds": []string{"sg-abcdef01"}, + }) + require.Equal(t, http.StatusCreated, rec.Code) + assoc := parseBody(t, rec) + assocID, _ := assoc["id"].(string) + assert.NotEmpty(t, assocID) + assert.Equal(t, "ACTIVE", assoc["status"]) + + // get + rec = doRequest(t, h, http.MethodGet, "/servicenetworkvpcassociations/"+assocID, nil) + assert.Equal(t, http.StatusOK, rec.Code) + + // update security groups + rec = doRequest( + t, + h, + http.MethodPatch, + "/servicenetworkvpcassociations/"+assocID, + map[string]any{ + "securityGroupIds": []string{"sg-new"}, + }, + ) + assert.Equal(t, http.StatusOK, rec.Code) + + // list + rec = doRequest(t, h, http.MethodGet, "/servicenetworkvpcassociations", nil) + assert.Equal(t, http.StatusOK, rec.Code) + list := parseBody(t, rec) + items, _ := list["items"].([]any) + assert.Len(t, items, 1) + + // delete + rec = doRequest(t, h, http.MethodDelete, "/servicenetworkvpcassociations/"+assocID, nil) + assert.Equal(t, http.StatusOK, rec.Code) +} + +// TestListener_CRUD tests listeners. +func TestListener_CRUD(t *testing.T) { + t.Parallel() + h := newTestHandler(t) + + // create service + recSvc := doRequest(t, h, http.MethodPost, "/services", map[string]any{"name": "svc-l"}) + require.Equal(t, http.StatusCreated, recSvc.Code) + svcID, _ := parseBody(t, recSvc)["id"].(string) + + // create listener + rec := doRequest(t, h, http.MethodPost, "/services/"+svcID+"/listeners", map[string]any{ + "name": "my-listener", + "protocol": "HTTP", + "port": 80, + "defaultAction": map[string]any{ + "fixedResponse": map[string]any{"statusCode": 404}, + }, + }) + require.Equal(t, http.StatusCreated, rec.Code) + l := parseBody(t, rec) + listenerID, _ := l["id"].(string) + require.NotEmpty(t, listenerID) + assert.Equal(t, "my-listener", l["name"]) + assert.InDelta(t, float64(80), l["port"], 0) + + // get + rec = doRequest(t, h, http.MethodGet, "/services/"+svcID+"/listeners/"+listenerID, nil) + assert.Equal(t, http.StatusOK, rec.Code) + + // update + rec = doRequest( + t, + h, + http.MethodPatch, + "/services/"+svcID+"/listeners/"+listenerID, + map[string]any{ + "defaultAction": map[string]any{ + "fixedResponse": map[string]any{"statusCode": 200}, + }, + }, + ) + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, 1, vpclattice.ListenerCount(h.Backend.(*vpclattice.InMemoryBackend))) + + // list + rec = doRequest(t, h, http.MethodGet, "/services/"+svcID+"/listeners", nil) + assert.Equal(t, http.StatusOK, rec.Code) + list := parseBody(t, rec) + items, _ := list["items"].([]any) + assert.Len(t, items, 1) + + // delete + rec = doRequest(t, h, http.MethodDelete, "/services/"+svcID+"/listeners/"+listenerID, nil) + assert.Equal(t, http.StatusNoContent, rec.Code) + assert.Equal(t, 0, vpclattice.ListenerCount(h.Backend.(*vpclattice.InMemoryBackend))) +} + +// TestRule_CRUD tests listener rules. +func TestRule_CRUD(t *testing.T) { + t.Parallel() + h := newTestHandler(t) + + // setup + recSvc := doRequest(t, h, http.MethodPost, "/services", map[string]any{"name": "svc-r"}) + require.Equal(t, http.StatusCreated, recSvc.Code) + svcID, _ := parseBody(t, recSvc)["id"].(string) + + recL := doRequest(t, h, http.MethodPost, "/services/"+svcID+"/listeners", map[string]any{ + "name": "l1", + "protocol": "HTTP", + "defaultAction": map[string]any{ + "fixedResponse": map[string]any{"statusCode": 404}, + }, + }) + require.Equal(t, http.StatusCreated, recL.Code) + listenerID, _ := parseBody(t, recL)["id"].(string) + + // create target group for forward rule + recTG := doRequest(t, h, http.MethodPost, "/targetgroups", map[string]any{ + "name": "tg1", + "type": "INSTANCE", + "config": map[string]any{ + "protocol": "HTTP", + "port": 80, + "vpcIdentifier": "vpc-123", + }, + }) + require.Equal(t, http.StatusCreated, recTG.Code) + tgID, _ := parseBody(t, recTG)["id"].(string) + + // create rule with forward action + rec := doRequest( + t, + h, + http.MethodPost, + "/services/"+svcID+"/listeners/"+listenerID+"/rules", + map[string]any{ + "name": "rule1", + "priority": 10, + "action": map[string]any{ + "forward": map[string]any{ + "targetGroups": []any{ + map[string]any{"targetGroupIdentifier": tgID, "weight": 100}, + }, + }, + }, + "match": map[string]any{ + "httpMatch": map[string]any{ + "method": "GET", + "path": map[string]any{ + "match": map[string]any{"exact": "/api"}, + }, + }, + }, + }, + ) + require.Equal(t, http.StatusCreated, rec.Code) + rule := parseBody(t, rec) + ruleID, _ := rule["id"].(string) + require.NotEmpty(t, ruleID) + assert.InDelta(t, float64(10), rule["priority"], 0) + + // get + rec = doRequest( + t, + h, + http.MethodGet, + "/services/"+svcID+"/listeners/"+listenerID+"/rules/"+ruleID, + nil, + ) + assert.Equal(t, http.StatusOK, rec.Code) + + // list (includes default rule) + rec = doRequest(t, h, http.MethodGet, "/services/"+svcID+"/listeners/"+listenerID+"/rules", nil) + assert.Equal(t, http.StatusOK, rec.Code) + list := parseBody(t, rec) + items, _ := list["items"].([]any) + assert.Len(t, items, 2) // default + created + + // update + rec = doRequest( + t, + h, + http.MethodPatch, + "/services/"+svcID+"/listeners/"+listenerID+"/rules/"+ruleID, + map[string]any{ + "priority": 20, + }, + ) + assert.Equal(t, http.StatusOK, rec.Code) + updated := parseBody(t, rec) + assert.InDelta(t, float64(20), updated["priority"], 0) + + // delete + rec = doRequest( + t, + h, + http.MethodDelete, + "/services/"+svcID+"/listeners/"+listenerID+"/rules/"+ruleID, + nil, + ) + assert.Equal(t, http.StatusNoContent, rec.Code) + + // list now has only default rule + rec = doRequest(t, h, http.MethodGet, "/services/"+svcID+"/listeners/"+listenerID+"/rules", nil) + list = parseBody(t, rec) + items, _ = list["items"].([]any) + assert.Len(t, items, 1) +} + +// TestBatchUpdateRule tests batch rule updates. +func TestBatchUpdateRule(t *testing.T) { + t.Parallel() + h := newTestHandler(t) + + // setup + recSvc := doRequest(t, h, http.MethodPost, "/services", map[string]any{"name": "svc-bu"}) + require.Equal(t, http.StatusCreated, recSvc.Code) + svcID, _ := parseBody(t, recSvc)["id"].(string) + + recL := doRequest(t, h, http.MethodPost, "/services/"+svcID+"/listeners", map[string]any{ + "name": "l-bu", + "protocol": "HTTP", + "defaultAction": map[string]any{ + "fixedResponse": map[string]any{"statusCode": 404}, + }, + }) + require.Equal(t, http.StatusCreated, recL.Code) + listenerID, _ := parseBody(t, recL)["id"].(string) + + rec := doRequest( + t, + h, + http.MethodPost, + "/services/"+svcID+"/listeners/"+listenerID+"/rules", + map[string]any{ + "name": "r1", + "priority": 10, + "action": map[string]any{"fixedResponse": map[string]any{"statusCode": 200}}, + }, + ) + require.Equal(t, http.StatusCreated, rec.Code) + ruleID, _ := parseBody(t, rec)["id"].(string) + + // batch update + rec = doRequest( + t, + h, + http.MethodPatch, + "/services/"+svcID+"/listeners/"+listenerID+"/rules", + map[string]any{ + "rules": []any{ + map[string]any{"ruleIdentifier": ruleID, "priority": 50}, + map[string]any{"ruleIdentifier": "rule-notexist", "priority": 99}, + }, + }, + ) + assert.Equal(t, http.StatusOK, rec.Code) + resp := parseBody(t, rec) + successful, _ := resp["successful"].([]any) + unsuccessful, _ := resp["unsuccessful"].([]any) + assert.Len(t, successful, 1) + assert.Len(t, unsuccessful, 1) +} + +// TestTargetGroup_CRUD tests target groups. +func TestTargetGroup_CRUD(t *testing.T) { + t.Parallel() + h := newTestHandler(t) + + tests := []struct { //nolint:govet // readability: name first + name string + body map[string]any + wantCode int + check func(t *testing.T, resp map[string]any) + }{ + { + name: "create missing name returns 400", + body: map[string]any{"type": "INSTANCE"}, + wantCode: http.StatusBadRequest, + }, + { + name: "create instance target group returns 201", + body: map[string]any{ + "name": "tg-inst", + "type": "INSTANCE", + "config": map[string]any{ + "protocol": "HTTP", + "port": 8080, + "vpcIdentifier": "vpc-abc", + }, + }, + wantCode: http.StatusCreated, + check: func(t *testing.T, resp map[string]any) { + t.Helper() + assert.Contains(t, resp["arn"], ":targetgroup/tg-") + assert.Equal(t, "ACTIVE", resp["status"]) + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + h2 := newTestHandler(t) + rec := doRequest(t, h2, http.MethodPost, "/targetgroups", tc.body) + assert.Equal(t, tc.wantCode, rec.Code) + if tc.check != nil { + tc.check(t, parseBody(t, rec)) + } + }) + } + + // full CRUD test + rec := doRequest(t, h, http.MethodPost, "/targetgroups", map[string]any{ + "name": "tg-full", + "type": "IP", + "config": map[string]any{ + "protocol": "HTTPS", + "port": 443, + "vpcIdentifier": "vpc-xyz", + }, + }) + require.Equal(t, http.StatusCreated, rec.Code) + tg := parseBody(t, rec) + tgID, _ := tg["id"].(string) + require.NotEmpty(t, tgID) + assert.Equal(t, 1, vpclattice.TargetGroupCount(h.Backend.(*vpclattice.InMemoryBackend))) + + // get + rec = doRequest(t, h, http.MethodGet, "/targetgroups/"+tgID, nil) + assert.Equal(t, http.StatusOK, rec.Code) + + // list + rec = doRequest(t, h, http.MethodGet, "/targetgroups", nil) + list := parseBody(t, rec) + items, _ := list["items"].([]any) + assert.Len(t, items, 1) + + // update health check + rec = doRequest(t, h, http.MethodPatch, "/targetgroups/"+tgID, map[string]any{ + "healthCheck": map[string]any{ + "enabled": true, + "protocol": "HTTP", + "path": "/health", + "port": 8080, + }, + }) + assert.Equal(t, http.StatusOK, rec.Code) + + // delete + rec = doRequest(t, h, http.MethodDelete, "/targetgroups/"+tgID, nil) + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, 0, vpclattice.TargetGroupCount(h.Backend.(*vpclattice.InMemoryBackend))) +} + +// TestTargets tests register/deregister/list targets. +func TestTargets(t *testing.T) { + t.Parallel() + h := newTestHandler(t) + + rec := doRequest(t, h, http.MethodPost, "/targetgroups", map[string]any{ + "name": "tg-targets", + "type": "IP", + "config": map[string]any{"protocol": "HTTP", "port": 80, "vpcIdentifier": "vpc-1"}, + }) + require.Equal(t, http.StatusCreated, rec.Code) + tgID, _ := parseBody(t, rec)["id"].(string) + + // register + rec = doRequest(t, h, http.MethodPost, "/targetgroups/"+tgID+"/registertargets", map[string]any{ + "targets": []any{ + map[string]any{"id": "10.0.0.1", "port": 80}, + map[string]any{"id": "10.0.0.2", "port": 80}, + }, + }) + assert.Equal(t, http.StatusOK, rec.Code) + resp := parseBody(t, rec) + unsuccessful, _ := resp["unsuccessful"].([]any) + assert.Empty(t, unsuccessful) + + // list + rec = doRequest(t, h, http.MethodPost, "/targetgroups/"+tgID+"/listtargets", map[string]any{}) + assert.Equal(t, http.StatusOK, rec.Code) + list := parseBody(t, rec) + items, _ := list["items"].([]any) + assert.Len(t, items, 2) + + // deregister one + rec = doRequest( + t, + h, + http.MethodPost, + "/targetgroups/"+tgID+"/deregistertargets", + map[string]any{ + "targets": []any{ + map[string]any{"id": "10.0.0.1", "port": 80}, + }, + }, + ) + assert.Equal(t, http.StatusOK, rec.Code) + + // list after deregister + rec = doRequest(t, h, http.MethodPost, "/targetgroups/"+tgID+"/listtargets", map[string]any{}) + list = parseBody(t, rec) + items, _ = list["items"].([]any) + assert.Len(t, items, 1) +} + +// TestAccessLogSubscription_CRUD tests access log subscriptions. +func TestAccessLogSubscription_CRUD(t *testing.T) { + t.Parallel() + h := newTestHandler(t) + + // create service for resource + recSvc := doRequest(t, h, http.MethodPost, "/services", map[string]any{"name": "svc-als"}) + require.Equal(t, http.StatusCreated, recSvc.Code) + svcID, _ := parseBody(t, recSvc)["id"].(string) + + // create ALS + destArn := "arn:aws:logs:us-east-1:000000000000:log-group:/vpc-lattice/test" + rec := doRequest(t, h, http.MethodPost, "/accesslogsubscriptions", map[string]any{ + "resourceIdentifier": svcID, + "destinationArn": destArn, + }) + require.Equal(t, http.StatusCreated, rec.Code) + als := parseBody(t, rec) + alsID, _ := als["id"].(string) + require.NotEmpty(t, alsID) + assert.Equal(t, destArn, als["destinationArn"]) + + // get + rec = doRequest(t, h, http.MethodGet, "/accesslogsubscriptions/"+alsID, nil) + assert.Equal(t, http.StatusOK, rec.Code) + + // update + newDest := "arn:aws:logs:us-east-1:000000000000:log-group:/vpc-lattice/new" + rec = doRequest(t, h, http.MethodPatch, "/accesslogsubscriptions/"+alsID, map[string]any{ + "destinationArn": newDest, + }) + assert.Equal(t, http.StatusOK, rec.Code) + updated := parseBody(t, rec) + assert.Equal(t, newDest, updated["destinationArn"]) + + // list + rec = doRequest(t, h, http.MethodGet, "/accesslogsubscriptions", nil) + list := parseBody(t, rec) + items, _ := list["items"].([]any) + assert.Len(t, items, 1) + + // delete + rec = doRequest(t, h, http.MethodDelete, "/accesslogsubscriptions/"+alsID, nil) + assert.Equal(t, http.StatusNoContent, rec.Code) +} + +// TestAuthPolicy tests put/get/delete auth policy. +func TestAuthPolicy(t *testing.T) { + t.Parallel() + h := newTestHandler(t) + + // create service + recSvc := doRequest(t, h, http.MethodPost, "/services", map[string]any{"name": "svc-auth"}) + require.Equal(t, http.StatusCreated, recSvc.Code) + svcID, _ := parseBody(t, recSvc)["id"].(string) + + policy := `{"Version":"2012-10-17","Statement":[]}` + + // put + rec := doRequest(t, h, http.MethodPut, "/authpolicy/"+svcID, map[string]any{"policy": policy}) + assert.Equal(t, http.StatusOK, rec.Code) + resp := parseBody(t, rec) + assert.Equal(t, policy, resp["policy"]) + + // get + rec = doRequest(t, h, http.MethodGet, "/authpolicy/"+svcID, nil) + assert.Equal(t, http.StatusOK, rec.Code) + resp = parseBody(t, rec) + assert.Equal(t, policy, resp["policy"]) + + // delete + rec = doRequest(t, h, http.MethodDelete, "/authpolicy/"+svcID, nil) + assert.Equal(t, http.StatusNoContent, rec.Code) +} + +// TestResourcePolicy tests put/get/delete resource policy. +func TestResourcePolicy(t *testing.T) { + t.Parallel() + h := newTestHandler(t) + + resArn := "arn:aws:vpc-lattice:us-east-1:000000000000:servicenetwork/sn-abc123" + policy := `{"Version":"2012-10-17","Statement":[]}` + + // put + rec := doRequest( + t, + h, + http.MethodPut, + "/resourcepolicy/"+resArn, + map[string]any{"policy": policy}, + ) + assert.Equal(t, http.StatusOK, rec.Code) + + // get + rec = doRequest(t, h, http.MethodGet, "/resourcepolicy/"+resArn, nil) + assert.Equal(t, http.StatusOK, rec.Code) + resp := parseBody(t, rec) + assert.Equal(t, policy, resp["policy"]) + + // delete + rec = doRequest(t, h, http.MethodDelete, "/resourcepolicy/"+resArn, nil) + assert.Equal(t, http.StatusNoContent, rec.Code) + + // get after delete returns 404 + rec = doRequest(t, h, http.MethodGet, "/resourcepolicy/"+resArn, nil) + assert.Equal(t, http.StatusNotFound, rec.Code) +} + +// TestTagging tests tagging operations. +func TestTagging(t *testing.T) { + t.Parallel() + h := newTestHandler(t) + + // create a service to tag + recSvc := doRequest(t, h, http.MethodPost, "/services", map[string]any{ + "name": "svc-tag", + "tags": map[string]any{"env": "dev"}, + }) + require.Equal(t, http.StatusCreated, recSvc.Code) + svcData := parseBody(t, recSvc) + svcArn, _ := svcData["arn"].(string) + + // list tags + rec := doRequest(t, h, http.MethodGet, "/tags/"+svcArn, nil) + assert.Equal(t, http.StatusOK, rec.Code) + tagsResp := parseBody(t, rec) + tags, _ := tagsResp["tags"].(map[string]any) + assert.Equal(t, "dev", tags["env"]) + + // tag resource + rec = doRequest(t, h, http.MethodPost, "/tags/"+svcArn, map[string]any{ + "tags": map[string]any{"team": "platform"}, + }) + assert.Equal(t, http.StatusOK, rec.Code) + + // verify tag was added + rec = doRequest(t, h, http.MethodGet, "/tags/"+svcArn, nil) + tagsResp = parseBody(t, rec) + tags, _ = tagsResp["tags"].(map[string]any) + assert.Equal(t, "dev", tags["env"]) + assert.Equal(t, "platform", tags["team"]) +} + +// TestARNFormat verifies ARN formats for all resource types. +func TestARNFormat(t *testing.T) { + t.Parallel() + h := newTestHandler(t) + + rec := doRequest(t, h, http.MethodPost, "/services", map[string]any{"name": "arn-svc"}) + require.Equal(t, http.StatusCreated, rec.Code) + svc := parseBody(t, rec) + assert.Regexp( + t, + `^arn:aws:vpc-lattice:us-east-1:000000000000:service/svc-[a-f0-9]+$`, + svc["arn"], + ) + + rec = doRequest(t, h, http.MethodPost, "/servicenetworks", map[string]any{"name": "arn-sn"}) + require.Equal(t, http.StatusCreated, rec.Code) + sn := parseBody(t, rec) + assert.Regexp( + t, + `^arn:aws:vpc-lattice:us-east-1:000000000000:servicenetwork/sn-[a-f0-9]+$`, + sn["arn"], + ) + + rec = doRequest(t, h, http.MethodPost, "/targetgroups", map[string]any{ + "name": "arn-tg", + "type": "IP", + "config": map[string]any{"protocol": "HTTP", "port": 80, "vpcIdentifier": "vpc-1"}, + }) + require.Equal(t, http.StatusCreated, rec.Code) + tg := parseBody(t, rec) + assert.Regexp( + t, + `^arn:aws:vpc-lattice:us-east-1:000000000000:targetgroup/tg-[a-f0-9]+$`, + tg["arn"], + ) +} + +// TestNotFound verifies 404 on get/update/delete of nonexistent resources. +func TestNotFound(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + method string + path string + }{ + {"get service", http.MethodGet, "/services/svc-notexist"}, + {"get servicenetwork", http.MethodGet, "/servicenetworks/sn-notexist"}, + {"get targetgroup", http.MethodGet, "/targetgroups/tg-notexist"}, + {"get snsa", http.MethodGet, "/servicenetworkserviceassociations/snsa-notexist"}, + {"get snva", http.MethodGet, "/servicenetworkvpcassociations/snva-notexist"}, + {"get als", http.MethodGet, "/accesslogsubscriptions/als-notexist"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + h := newTestHandler(t) + rec := doRequest(t, h, tc.method, tc.path, nil) + assert.Equal(t, http.StatusNotFound, rec.Code) + }) + } +} diff --git a/services/vpclattice/interfaces.go b/services/vpclattice/interfaces.go new file mode 100644 index 000000000..8ecba0ea7 --- /dev/null +++ b/services/vpclattice/interfaces.go @@ -0,0 +1,456 @@ +package vpclattice + +import "time" + +// StorageBackend is the interface for VPC Lattice storage operations. +type StorageBackend interface { + CreateService( + name, authType, certificateArn, customDomainName string, + tags map[string]string, + ) (*Service, error) + GetService(serviceID string) (*Service, error) + UpdateService(serviceID, authType, certificateArn string) (*Service, error) + DeleteService(serviceID string) (*Service, error) + ListServices(maxResults int32, nextToken string) ([]*ServiceSummary, string, error) + + CreateServiceNetwork(name, authType string, tags map[string]string) (*ServiceNetwork, error) + GetServiceNetwork(snID string) (*ServiceNetwork, error) + UpdateServiceNetwork(snID, authType string) (*ServiceNetwork, error) + DeleteServiceNetwork(snID string) error + ListServiceNetworks( + maxResults int32, + nextToken string, + ) ([]*ServiceNetworkSummary, string, error) + + CreateServiceNetworkServiceAssociation( + serviceNetworkID, serviceID string, + tags map[string]string, + ) (*ServiceNetworkServiceAssociation, error) + GetServiceNetworkServiceAssociation(snsaID string) (*ServiceNetworkServiceAssociation, error) + DeleteServiceNetworkServiceAssociation(snsaID string) error + ListServiceNetworkServiceAssociations( + serviceNetworkID, serviceID string, + maxResults int32, + nextToken string, + ) ([]*ServiceNetworkServiceAssociationSummary, string, error) + + CreateServiceNetworkVpcAssociation( + serviceNetworkID, vpcID string, + securityGroupIDs []string, + tags map[string]string, + ) (*ServiceNetworkVpcAssociation, error) + GetServiceNetworkVpcAssociation(snvaID string) (*ServiceNetworkVpcAssociation, error) + UpdateServiceNetworkVpcAssociation( + snvaID string, + securityGroupIDs []string, + ) (*ServiceNetworkVpcAssociation, error) + DeleteServiceNetworkVpcAssociation(snvaID string) error + ListServiceNetworkVpcAssociations( + serviceNetworkID, vpcID string, + maxResults int32, + nextToken string, + ) ([]*ServiceNetworkVpcAssociationSummary, string, error) + + CreateListener( + serviceID, name, protocol string, + port int32, + defaultAction *RuleAction, + tags map[string]string, + ) (*Listener, error) + GetListener(serviceID, listenerID string) (*Listener, error) + UpdateListener(serviceID, listenerID string, defaultAction *RuleAction) (*Listener, error) + DeleteListener(serviceID, listenerID string) error + ListListeners( + serviceID string, + maxResults int32, + nextToken string, + ) ([]*ListenerSummary, string, error) + + CreateRule( + serviceID, listenerID, name string, + priority int32, + action *RuleAction, + match *RuleMatch, + tags map[string]string, + ) (*Rule, error) + GetRule(serviceID, listenerID, ruleID string) (*Rule, error) + UpdateRule( + serviceID, listenerID, ruleID string, + priority int32, + action *RuleAction, + match *RuleMatch, + ) (*Rule, error) + DeleteRule(serviceID, listenerID, ruleID string) error + ListRules( + serviceID, listenerID string, + maxResults int32, + nextToken string, + ) ([]*RuleSummary, string, error) + BatchUpdateRule( + serviceID, listenerID string, + updates []*RuleUpdate, + ) ([]*RuleUpdateSuccess, []*RuleUpdateFailure, error) + + CreateTargetGroup( + name, tgType string, + config *TargetGroupConfig, + tags map[string]string, + ) (*TargetGroup, error) + GetTargetGroup(tgID string) (*TargetGroup, error) + UpdateTargetGroup(tgID string, healthCheck *HealthCheckConfig) (*TargetGroup, error) + DeleteTargetGroup(tgID string) error + ListTargetGroups( + tgType, serviceArn string, + maxResults int32, + nextToken string, + ) ([]*TargetGroupSummary, string, error) + RegisterTargets(tgID string, targets []*Target) ([]*TargetFailure, error) + DeregisterTargets(tgID string, targets []*Target) ([]*TargetFailure, error) + ListTargets(tgID string, maxResults int32, nextToken string) ([]*TargetSummary, string, error) + + CreateAccessLogSubscription( + resourceID, destinationArn, logType string, + tags map[string]string, + ) (*AccessLogSubscription, error) + GetAccessLogSubscription(alsID string) (*AccessLogSubscription, error) + UpdateAccessLogSubscription(alsID, destinationArn string) (*AccessLogSubscription, error) + DeleteAccessLogSubscription(alsID string) error + ListAccessLogSubscriptions( + resourceID string, + maxResults int32, + nextToken string, + ) ([]*AccessLogSubscriptionSummary, string, error) + + PutAuthPolicy(resourceID, policy string) (*AuthPolicy, error) + GetAuthPolicy(resourceID string) (*AuthPolicy, error) + DeleteAuthPolicy(resourceID string) error + + PutResourcePolicy(resourceArn, policy string) error + GetResourcePolicy(resourceArn string) (string, error) + DeleteResourcePolicy(resourceArn string) error + + TagResource(resourceArn string, tags map[string]string) error + UntagResource(resourceArn string, keys []string) error + ListTagsForResource(resourceArn string) (map[string]string, error) + + AccountID() string + Region() string + Reset() + Snapshot() []byte + Restore(data []byte) error +} + +// Service represents a VPC Lattice service. +type Service struct { + CreatedAt time.Time + LastUpdatedAt time.Time + ARN string + ID string + Name string + AuthType string + CertificateArn string + CustomDomainName string + DNSName string + Status string +} + +// ServiceSummary is a service entry for list responses. +type ServiceSummary struct { + CreatedAt time.Time + LastUpdatedAt time.Time + ARN string + ID string + Name string + CustomDomainName string + DNSName string + Status string +} + +// ServiceNetwork represents a VPC Lattice service network. +type ServiceNetwork struct { + CreatedAt time.Time + LastUpdatedAt time.Time + ARN string + ID string + Name string + AuthType string + NumberOfAssociatedServices int64 + NumberOfAssociatedVPCs int64 +} + +// ServiceNetworkSummary is a service network entry for list responses. +type ServiceNetworkSummary struct { + CreatedAt time.Time + ARN string + ID string + Name string + NumberOfAssociatedServices int64 + NumberOfAssociatedVPCs int64 +} + +// ServiceNetworkServiceAssociation is a service-to-service-network association. +type ServiceNetworkServiceAssociation struct { + CreatedAt time.Time + ARN string + ID string + ServiceARN string + ServiceID string + ServiceName string + ServiceNetworkARN string + ServiceNetworkID string + ServiceNetworkName string + Status string + CreatedBy string + CustomDomainName string + DNSName string +} + +// ServiceNetworkServiceAssociationSummary is a summary for list responses. +type ServiceNetworkServiceAssociationSummary struct { + CreatedAt time.Time + ARN string + ID string + ServiceARN string + ServiceID string + ServiceName string + ServiceNetworkARN string + ServiceNetworkID string + ServiceNetworkName string + Status string + CustomDomainName string + DNSName string +} + +// ServiceNetworkVpcAssociation is a VPC-to-service-network association. +type ServiceNetworkVpcAssociation struct { + CreatedAt time.Time + LastUpdatedAt time.Time + ARN string + ID string + VpcID string + ServiceNetworkARN string + ServiceNetworkID string + ServiceNetworkName string + Status string + CreatedBy string + SecurityGroupIDs []string +} + +// ServiceNetworkVpcAssociationSummary is a summary for list responses. +type ServiceNetworkVpcAssociationSummary struct { + CreatedAt time.Time + ARN string + ID string + VpcID string + ServiceNetworkARN string + ServiceNetworkID string + ServiceNetworkName string + Status string +} + +// Listener represents a VPC Lattice listener. +type Listener struct { + DefaultAction *RuleAction + CreatedAt time.Time + LastUpdatedAt time.Time + ARN string + ID string + ServiceARN string + ServiceID string + Name string + Protocol string + Port int32 +} + +// ListenerSummary is a listener entry for list responses. +type ListenerSummary struct { + CreatedAt time.Time + LastUpdatedAt time.Time + ARN string + ID string + Name string + Protocol string + Port int32 +} + +// Rule represents a VPC Lattice listener rule. +type Rule struct { + Action *RuleAction + Match *RuleMatch + CreatedAt time.Time + LastUpdatedAt time.Time + ARN string + ID string + Name string + Priority int32 + IsDefault bool +} + +// RuleSummary is a rule entry for list responses. +type RuleSummary struct { + ARN string + ID string + Name string + Priority int32 + IsDefault bool +} + +// RuleUpdate is an update spec for BatchUpdateRule. +type RuleUpdate struct { + Action *RuleAction + Match *RuleMatch + RuleIdentifier string + Priority int32 +} + +// RuleUpdateSuccess is a successful rule update result. +type RuleUpdateSuccess struct { + Action *RuleAction + Match *RuleMatch + ARN string + ID string + Name string + Priority int32 + IsDefault bool +} + +// RuleUpdateFailure is a failed rule update result. +type RuleUpdateFailure struct { + RuleIdentifier string + Message string + Code string +} + +// RuleAction is the action for a listener rule. +type RuleAction struct { + ForwardTargetGroups []*WeightedTargetGroup `json:"forwardTargetGroups,omitempty"` + FixedResponseStatusCode int32 `json:"fixedResponseStatusCode,omitempty"` + IsFixedResponse bool `json:"isFixedResponse,omitempty"` +} + +// WeightedTargetGroup is a weighted target group for forward actions. +type WeightedTargetGroup struct { + TargetGroupID string `json:"targetGroupId"` + Weight int32 `json:"weight"` +} + +// RuleMatch is the match conditions for a listener rule. +type RuleMatch struct { + HTTPMethod string `json:"httpMethod,omitempty"` + PathMatchType string `json:"pathMatchType,omitempty"` + PathMatchValue string `json:"pathMatchValue,omitempty"` + HeaderMatches []*HeaderMatch `json:"headerMatches,omitempty"` +} + +// HeaderMatch is an HTTP header match condition. +type HeaderMatch struct { + Name string `json:"name"` + MatchType string `json:"matchType"` + MatchValue string `json:"matchValue"` + CaseSensitive bool `json:"caseSensitive"` +} + +// TargetGroup represents a VPC Lattice target group. +type TargetGroup struct { + CreatedAt time.Time + LastUpdatedAt time.Time + ARN string + ID string + Name string + Type string + Status string + Config *TargetGroupConfig + ServiceARNs []string +} + +// TargetGroupSummary is a target group entry for list responses. +type TargetGroupSummary struct { + CreatedAt time.Time + ARN string + ID string + Name string + Type string + Status string + Protocol string + VpcID string + ServiceARNs []string + Port int32 +} + +// TargetGroupConfig is the configuration for a target group. +type TargetGroupConfig struct { + HealthCheck *HealthCheckConfig `json:"healthCheck,omitempty"` + Protocol string `json:"protocol,omitempty"` + ProtocolVersion string `json:"protocolVersion,omitempty"` + VpcID string `json:"vpcId,omitempty"` + IPAddressType string `json:"ipAddressType,omitempty"` + LambdaEventStructureVersion string `json:"lambdaEventStructureVersion,omitempty"` + Port int32 `json:"port,omitempty"` +} + +// HealthCheckConfig is the health check configuration for a target group. +type HealthCheckConfig struct { + Protocol string `json:"protocol,omitempty"` + ProtocolVersion string `json:"protocolVersion,omitempty"` + Path string `json:"path,omitempty"` + MatcherHTTPCode string `json:"matcherHttpCode,omitempty"` + Port int32 `json:"port,omitempty"` + HealthyThresholdCount int32 `json:"healthyThresholdCount,omitempty"` + UnhealthyThresholdCount int32 `json:"unhealthyThresholdCount,omitempty"` + HealthCheckIntervalSeconds int32 `json:"healthCheckIntervalSeconds,omitempty"` + HealthCheckTimeoutSeconds int32 `json:"healthCheckTimeoutSeconds,omitempty"` + Enabled bool `json:"enabled,omitempty"` +} + +// Target is a target registered to a target group. +type Target struct { + ID string + Port int32 +} + +// TargetSummary is a target entry for list responses. +type TargetSummary struct { + ID string + Status string + ReasonCode string + Port int32 +} + +// TargetFailure is a target registration/deregistration failure. +type TargetFailure struct { + ID string + Code string + Message string + Port int32 +} + +// AccessLogSubscription represents a VPC Lattice access log subscription. +type AccessLogSubscription struct { + CreatedAt time.Time + LastUpdatedAt time.Time + ARN string + ID string + ResourceARN string + ResourceID string + DestinationARN string + ServiceNetworkLogType string +} + +// AccessLogSubscriptionSummary is a summary for list responses. +type AccessLogSubscriptionSummary struct { + CreatedAt time.Time + LastUpdatedAt time.Time + ARN string + ID string + ResourceARN string + ResourceID string + DestinationARN string +} + +// AuthPolicy represents an auth policy on a VPC Lattice resource. +type AuthPolicy struct { + Policy string + State string +} + +var _ StorageBackend = (*InMemoryBackend)(nil) diff --git a/services/vpclattice/provider.go b/services/vpclattice/provider.go new file mode 100644 index 000000000..436f99370 --- /dev/null +++ b/services/vpclattice/provider.go @@ -0,0 +1,40 @@ +package vpclattice + +import ( + "errors" + + "github.com/blackbirdworks/gopherstack/pkgs/config" + "github.com/blackbirdworks/gopherstack/pkgs/service" +) + +// ErrNilAppContext is returned when Provider.Init is called with a nil AppContext. +var ErrNilAppContext = errors.New("AppContext is required") + +// Provider implements service.Provider for the VPC Lattice service. +type Provider struct{} + +// Name returns the logical name of the provider. +func (p *Provider) Name() string { return "VPCLattice" } + +// Init initializes the VPC Lattice backend and handler. +// +//nolint:ireturn,nolintlint // architecturally required to return interface +func (p *Provider) Init(ctx *service.AppContext) (service.Registerable, error) { + if ctx == nil { + return nil, ErrNilAppContext + } + + accountID := config.DefaultAccountID + region := config.DefaultRegion + + if cp, ok := ctx.Config.(config.Provider); ok { + cfg := cp.GetGlobalConfig() + accountID = cfg.GetAccountID() + region = cfg.GetRegion() + } + + backend := NewInMemoryBackend(accountID, region) + handler := NewHandler(backend) + + return handler, nil +} diff --git a/services/vpclattice/sdk_completeness_test.go b/services/vpclattice/sdk_completeness_test.go new file mode 100644 index 000000000..4ca37543a --- /dev/null +++ b/services/vpclattice/sdk_completeness_test.go @@ -0,0 +1,55 @@ +package vpclattice_test + +import ( + "testing" + + vpclatticesdk "github.com/aws/aws-sdk-go-v2/service/vpclattice" + + "github.com/blackbirdworks/gopherstack/pkgs/sdkcheck" + "github.com/blackbirdworks/gopherstack/services/vpclattice" +) + +// TestSDKCompleteness verifies that every operation exposed by the AWS SDK v2 +// vpclattice client is either listed in GetSupportedOperations() or explicitly +// acknowledged in the notImplemented slice. +func TestSDKCompleteness(t *testing.T) { + t.Parallel() + + backend := vpclattice.NewInMemoryBackend("000000000000", "us-east-1") + h := vpclattice.NewHandler(backend) + + notImplemented := []string{ + // Resource Gateway and Configuration (newer feature set) + "CreateResourceConfiguration", + "CreateResourceGateway", + "DeleteResourceConfiguration", + "DeleteResourceEndpointAssociation", + "DeleteResourceGateway", + "GetResourceConfiguration", + "GetResourceGateway", + "ListResourceConfigurations", + "ListResourceEndpointAssociations", + "ListResourceGateways", + "UpdateResourceConfiguration", + "UpdateResourceGateway", + // ServiceNetworkResourceAssociation (resource gateway feature) + "CreateServiceNetworkResourceAssociation", + "DeleteServiceNetworkResourceAssociation", + "GetServiceNetworkResourceAssociation", + "ListServiceNetworkResourceAssociations", + // VPC endpoint associations + "ListServiceNetworkVpcEndpointAssociations", + // Domain verification + "DeleteDomainVerification", + "GetDomainVerification", + "ListDomainVerifications", + "StartDomainVerification", + } + + sdkcheck.CheckCompleteness( + t, + &vpclatticesdk.Client{}, + h.GetSupportedOperations(), + notImplemented, + ) +} diff --git a/test/terraform/fixtures/vpclattice/success.tf b/test/terraform/fixtures/vpclattice/success.tf new file mode 100644 index 000000000..67bab32a4 --- /dev/null +++ b/test/terraform/fixtures/vpclattice/success.tf @@ -0,0 +1,29 @@ +resource "terraform_data" "vpclattice_service_network" { + triggers_replace = { + endpoint = "{{.Endpoint}}" + } + + provisioner "local-exec" { + environment = { + AWS_ACCESS_KEY_ID = "test" + AWS_SECRET_ACCESS_KEY = "test" + AWS_DEFAULT_REGION = "us-east-1" + } + command = "aws --endpoint-url '{{.Endpoint}}' vpc-lattice create-service-network --name tf-sn-test" + } +} + +resource "terraform_data" "vpclattice_service" { + triggers_replace = { + endpoint = "{{.Endpoint}}" + } + + provisioner "local-exec" { + environment = { + AWS_ACCESS_KEY_ID = "test" + AWS_SECRET_ACCESS_KEY = "test" + AWS_DEFAULT_REGION = "us-east-1" + } + command = "aws --endpoint-url '{{.Endpoint}}' vpc-lattice create-service --name tf-svc-test" + } +} diff --git a/test/terraform/main_test.go b/test/terraform/main_test.go index 2da832cc9..13c1afe7c 100644 --- a/test/terraform/main_test.go +++ b/test/terraform/main_test.go @@ -120,6 +120,7 @@ import ( timestreamquerysvc "github.com/aws/aws-sdk-go-v2/service/timestreamquery" transfersvc "github.com/aws/aws-sdk-go-v2/service/transfer" verifiedpermissionssvc "github.com/aws/aws-sdk-go-v2/service/verifiedpermissions" + vpclatticesvc "github.com/aws/aws-sdk-go-v2/service/vpclattice" xraysvc "github.com/aws/aws-sdk-go-v2/service/xray" "github.com/moby/moby/client" @@ -2334,6 +2335,13 @@ func createXrayClient(t *testing.T) *xraysvc.Client { return createClientWithEndpoint(t, xraysvc.NewFromConfig, endpoint) } +// createVPCLatticeClient returns a VPC Lattice client pointed at the shared test container. +func createVPCLatticeClient(t *testing.T) *vpclatticesvc.Client { + t.Helper() + + return createClientWithEndpoint(t, vpclatticesvc.NewFromConfig, endpoint) +} + // createClientWithEndpoint is a helper to create an AWS client with a base endpoint. func createClientWithEndpoint[T any, O any](t *testing.T, newFn func(aws.Config, ...func(*O)) *T, endpoint string) *T { t.Helper() diff --git a/test/terraform/terraform_test.go b/test/terraform/terraform_test.go index 2e51dfb37..f8c87e597 100644 --- a/test/terraform/terraform_test.go +++ b/test/terraform/terraform_test.go @@ -139,6 +139,7 @@ import ( timestreamquerytypes "github.com/aws/aws-sdk-go-v2/service/timestreamquery/types" transfersvc "github.com/aws/aws-sdk-go-v2/service/transfer" verifiedpermissionssvc "github.com/aws/aws-sdk-go-v2/service/verifiedpermissions" + vpclatticesvc "github.com/aws/aws-sdk-go-v2/service/vpclattice" xraysvc "github.com/aws/aws-sdk-go-v2/service/xray" "github.com/google/uuid" "github.com/stretchr/testify/assert" @@ -311,6 +312,7 @@ provider "aws" { ssoadmin = %[1]q sts = %[1]q swf = %[1]q + vpclattice = %[1]q wafv2 = %[1]q } } @@ -6394,6 +6396,28 @@ func TestTerraform_Xray(t *testing.T) { }) } +// TestTerraform_VPCLattice provisions a VPC Lattice service and service network +// via Terraform and verifies they were created. +func TestTerraform_VPCLattice(t *testing.T) { + t.Parallel() + + runTfTestWithEndpoint(t, "vpclattice/success", func(t *testing.T, ctx context.Context) { + t.Helper() + + client := createVPCLatticeClient(t) + + // ListServices - should have at least one from Terraform provisioning. + svcsOut, err := client.ListServices(ctx, &vpclatticesvc.ListServicesInput{}) + require.NoError(t, err, "ListServices should succeed") + assert.NotEmpty(t, svcsOut.Items, "expected at least one service") + + // ListServiceNetworks - should have at least one from Terraform provisioning. + snsOut, err := client.ListServiceNetworks(ctx, &vpclatticesvc.ListServiceNetworksInput{}) + require.NoError(t, err, "ListServiceNetworks should succeed") + assert.NotEmpty(t, snsOut.Items, "expected at least one service network") + }) +} + // TestTerraform_Wafv2 provisions a WAFv2 Web ACL via Terraform and verifies it was created. func TestTerraform_Wafv2(t *testing.T) { t.Parallel()