mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 17:11:30 +00:00
373 lines
13 KiB
Go
373 lines
13 KiB
Go
package awsmodel
|
|
|
|
import (
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"math/big"
|
|
"regexp"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// RequestOptions places generated ARNs in the account and region the
|
|
// requests are sent as. AccessKeyID is the key the requests are signed with.
|
|
// Fixtures maps a member to an existing resource it can name.
|
|
type RequestOptions struct {
|
|
AccountID string
|
|
Region string
|
|
AccessKeyID string
|
|
Fixtures map[string]string
|
|
}
|
|
|
|
// Models constrain most identifiers only by length, so "spxa" is a model-valid
|
|
// AMI ID that AWS rejects. Hints give resource-naming members a value of the
|
|
// right form, used only if the member's own constraints accept it.
|
|
|
|
// ec2IDPrefixes maps the resource an EC2 "<Resource>Id" member names to its
|
|
// ID prefix.
|
|
var ec2IDPrefixes = map[string]string{
|
|
"Allocation": "eipalloc",
|
|
"CapacityReservation": "cr",
|
|
"CarrierGateway": "cagw",
|
|
"DhcpOptions": "dopt",
|
|
"EgressOnlyInternetGateway": "eigw",
|
|
"Fleet": "fleet",
|
|
"Group": "sg",
|
|
"Host": "h",
|
|
"Image": "ami",
|
|
"Instance": "i",
|
|
"InternetGateway": "igw",
|
|
"KeyPair": "key",
|
|
"LaunchTemplate": "lt",
|
|
"NatGateway": "nat",
|
|
"NetworkAcl": "acl",
|
|
"NetworkInterface": "eni",
|
|
"PlacementGroup": "pg",
|
|
"PrefixList": "pl",
|
|
"RouteTable": "rtb",
|
|
"SecurityGroup": "sg",
|
|
"SecurityGroupRule": "sgr",
|
|
"Snapshot": "snap",
|
|
"SourceImage": "ami",
|
|
"SourceSnapshot": "snap",
|
|
"SpotInstanceRequest": "sir",
|
|
"Subnet": "subnet",
|
|
"TransitGateway": "tgw",
|
|
"Volume": "vol",
|
|
"Vpc": "vpc",
|
|
"VpcEndpoint": "vpce",
|
|
"VpcPeeringConnection": "pcx",
|
|
}
|
|
|
|
var ec2IDMember = regexp.MustCompile(`^(.*?)Ids?$`)
|
|
|
|
// conditionallyRequired names members the model marks optional that the
|
|
// operation still needs in its base request. The services refuse their absence
|
|
// with a code the judge cannot tell from a real finding.
|
|
var conditionallyRequired = map[Service]map[string][]string{
|
|
EC2: {"CreateCapacityReservation": {"AvailabilityZone"}},
|
|
ECS: {
|
|
"CreateCapacityProvider": {"autoScalingGroupProvider"},
|
|
"CreateService": {"taskDefinition"},
|
|
},
|
|
RDS: {
|
|
"CreateDBInstance": {"AllocatedStorage", "MasterUsername", "MasterUserPassword"},
|
|
"RestoreDBInstanceFromDBSnapshot": {"DBSnapshotIdentifier"},
|
|
},
|
|
}
|
|
|
|
const (
|
|
// imageManifest is well formed but names blobs no repository holds.
|
|
imageManifest = `{"schemaVersion":2,"mediaType":"application/vnd.docker.distribution.manifest.v2+json","config":{"mediaType":"application/vnd.docker.container.image.v1+json","size":2,"digest":"sha256:0000000000000000000000000000000000000000000000000000000000000000"},"layers":[]}`
|
|
lifecyclePolicy = `{"rules":[{"rulePriority":1,"description":"expire untagged","selection":{"tagStatus":"untagged","countType":"sinceImagePushed","countUnit":"days","countNumber":14},"action":{"type":"expire"}}]}`
|
|
repositoryPolicy = `{"Version":"2012-10-17","Statement":[{"Sid":"pull","Effect":"Allow","Principal":{"AWS":"arn:aws:iam::%s:root"},"Action":"ecr:BatchGetImage"}]}`
|
|
|
|
policyDocument = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`
|
|
trustPolicyDocument = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"ec2.amazonaws.com"},"Action":"sts:AssumeRole"}]}`
|
|
)
|
|
|
|
// ec2TaggedTypes is the resource type each EC2 create tags through its
|
|
// TagSpecifications. AWS refuses any other type, so the enum's first value
|
|
// would make an otherwise valid request invalid.
|
|
var ec2TaggedTypes = map[string]string{
|
|
"AllocateAddress": "elastic-ip",
|
|
"AuthorizeSecurityGroupEgress": "security-group-rule",
|
|
"AuthorizeSecurityGroupIngress": "security-group-rule",
|
|
"CopyImage": "image",
|
|
"CopySnapshot": "snapshot",
|
|
"CreateCapacityReservation": "capacity-reservation",
|
|
"CreateEgressOnlyInternetGateway": "egress-only-internet-gateway",
|
|
"CreateImage": "image",
|
|
"CreateInternetGateway": "internet-gateway",
|
|
"CreateKeyPair": "key-pair",
|
|
"CreateLaunchTemplate": "launch-template",
|
|
"CreateNatGateway": "natgateway",
|
|
"CreateNetworkInterface": "network-interface",
|
|
"CreatePlacementGroup": "placement-group",
|
|
"CreateRouteTable": "route-table",
|
|
"CreateSecurityGroup": "security-group",
|
|
"CreateSnapshot": "snapshot",
|
|
"CreateSubnet": "subnet",
|
|
"CreateVolume": "volume",
|
|
"CreateVpc": "vpc",
|
|
"ImportKeyPair": "key-pair",
|
|
"RegisterImage": "image",
|
|
"RequestSpotInstances": "spot-instances-request",
|
|
"RunInstances": "instance",
|
|
}
|
|
|
|
// enumHint returns the enum value a valid request needs at path, where the
|
|
// first value would not do.
|
|
func (g *requestGenerator) enumHint(path []pathStep) (string, bool) {
|
|
if g.model.service != EC2 || len(path) == 0 || path[0] != "TagSpecifications" || memberName(path) != "ResourceType" {
|
|
return "", false
|
|
}
|
|
value, ok := ec2TaggedTypes[g.operation]
|
|
return value, ok
|
|
}
|
|
|
|
// hint returns a value of the right form for the member at path, if one is
|
|
// known. salt keeps names distinct between request cases.
|
|
func (g *requestGenerator) hint(path []pathStep) (string, bool) {
|
|
member := memberName(path)
|
|
if member == "" {
|
|
return "", false
|
|
}
|
|
options := g.options
|
|
if fixture, ok := options.Fixtures[member]; ok {
|
|
return fixture, true
|
|
}
|
|
suffix := fmt.Sprintf("%017x", g.saltNumber)
|
|
name := "spx" + g.salt
|
|
|
|
switch g.model.service {
|
|
case EC2:
|
|
switch member {
|
|
case "AvailabilityZone":
|
|
return options.Region + "a", true
|
|
case "SourceRegion":
|
|
return options.Region, true
|
|
}
|
|
if match := ec2IDMember.FindStringSubmatch(capitalize(member)); match != nil {
|
|
if prefix, ok := ec2IDPrefixes[match[1]]; ok {
|
|
return prefix + "-" + suffix, true
|
|
}
|
|
}
|
|
case ElasticLoadBalancingV2:
|
|
base := fmt.Sprintf("arn:aws:elasticloadbalancing:%s:%s:", options.Region, options.AccountID)
|
|
switch member {
|
|
case "LoadBalancerArn", "LoadBalancerArns", "ResourceArns":
|
|
return base + "loadbalancer/app/" + name + "/" + suffix[1:], true
|
|
case "TargetGroupArn", "TargetGroupArns":
|
|
return base + "targetgroup/" + name + "/" + suffix[1:], true
|
|
case "ListenerArn", "ListenerArns":
|
|
return base + "listener/app/" + name + "/" + suffix[1:] + "/" + suffix[1:], true
|
|
case "RuleArn", "RuleArns":
|
|
return base + "listener-rule/app/" + name + "/" + suffix[1:] + "/" + suffix[1:] + "/" + suffix[1:], true
|
|
case "ProtocolVersion":
|
|
return "HTTP1", true
|
|
case "HttpCode":
|
|
return "200", true
|
|
}
|
|
case RDS:
|
|
switch member {
|
|
case "ResourceName":
|
|
return fmt.Sprintf("arn:aws:rds:%s:%s:db:%s", options.Region, options.AccountID, name), true
|
|
case "Engine":
|
|
return "postgres", true
|
|
case "DBInstanceClass":
|
|
return "db.t3.medium", true
|
|
case "DBParameterGroupFamily":
|
|
return "postgres18", true
|
|
case "MasterUserPassword":
|
|
return "spx-conformance-" + g.salt, true
|
|
case "SnapshotType":
|
|
return "manual", true
|
|
case "Source":
|
|
return "user", true
|
|
}
|
|
case ECS:
|
|
switch member {
|
|
case "resourceArn":
|
|
return fmt.Sprintf("arn:aws:ecs:%s:%s:cluster/%s", options.Region, options.AccountID, name), true
|
|
case "taskDefinition":
|
|
return name + ":1", true
|
|
}
|
|
case ACM:
|
|
// The integration tenant CA permits example.com.
|
|
switch member {
|
|
case "DomainName", "SubjectAlternativeNames", "ValidationDomain":
|
|
return name + ".example.com", true
|
|
}
|
|
case ECR:
|
|
switch member {
|
|
case "resourceArn":
|
|
return fmt.Sprintf("arn:aws:ecr:%s:%s:repository/%s", options.Region, options.AccountID, name), true
|
|
case "imageManifest":
|
|
return imageManifest, true
|
|
case "lifecyclePolicyText":
|
|
return lifecyclePolicy, true
|
|
case "policyText":
|
|
return fmt.Sprintf(repositoryPolicy, options.AccountID), true
|
|
}
|
|
case EKS:
|
|
switch member {
|
|
case "resourceArn":
|
|
return fmt.Sprintf("arn:aws:eks:%s:%s:cluster/%s", options.Region, options.AccountID, name), true
|
|
case "policyArn":
|
|
return "arn:aws:eks::aws:cluster-access-policy/AmazonEKSViewPolicy", true
|
|
case "addonName":
|
|
return "aws-ebs-csi-driver", true
|
|
}
|
|
// An access entry's type; a nested type names an identity provider's.
|
|
if member == "type" && len(path) == 1 {
|
|
return "STANDARD", true
|
|
}
|
|
case STS:
|
|
// GetAccessKeyInfo answers only for a key that exists.
|
|
if member == "AccessKeyId" && options.AccessKeyID != "" {
|
|
return options.AccessKeyID, true
|
|
}
|
|
}
|
|
|
|
switch member {
|
|
case "PolicyArn", "PermissionsBoundary":
|
|
return fmt.Sprintf("arn:aws:iam::%s:policy/%s", options.AccountID, name), true
|
|
case "RoleArn":
|
|
return fmt.Sprintf("arn:aws:iam::%s:role/%s", options.AccountID, name), true
|
|
case "CertificateArn":
|
|
return fmt.Sprintf("arn:aws:acm:%s:%s:certificate/%s-0000-0000-0000-000000000000", options.Region, options.AccountID, suffix[9:]), true
|
|
case "PolicyDocument", "Policy":
|
|
return policyDocument, true
|
|
case "AssumeRolePolicyDocument":
|
|
return trustPolicyDocument, true
|
|
case "Url":
|
|
return "https://" + name + ".example.com", true
|
|
case "AccessKeyId":
|
|
return "AKIA" + strings.ToUpper(fmt.Sprintf("%016x", g.saltNumber)), true
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// integerHint returns a value AWS accepts for an integer member whose range
|
|
// the model leaves out, if one is known.
|
|
func (g *requestGenerator) integerHint(path []pathStep) (int64, bool) {
|
|
if g.model.service == RDS && memberName(path) == "AllocatedStorage" {
|
|
// The smallest gp2/gp3 allocation PostgreSQL, the Engine hint, accepts.
|
|
return 20, true
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
// blobHint returns a value AWS accepts for a blob member, if one is known.
|
|
func (g *requestGenerator) blobHint(path []pathStep) ([]byte, bool) {
|
|
if g.model.service != ACM {
|
|
return nil, false
|
|
}
|
|
// GenerateRequests has already surfaced a generation error.
|
|
material, err := importMaterial()
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
switch memberName(path) {
|
|
case "Certificate":
|
|
return material.certificate, true
|
|
case "PrivateKey":
|
|
return material.privateKey, true
|
|
case "CertificateChain":
|
|
return material.chain, true
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
// certificateMaterial is a PEM leaf certificate, its key and the CA that
|
|
// signed it, as ImportCertificate takes them.
|
|
type certificateMaterial struct {
|
|
certificate, privateKey, chain []byte
|
|
}
|
|
|
|
// importMaterial is generated once per run; minting a key per request would
|
|
// dominate the ACM cases.
|
|
var importMaterial = sync.OnceValues(func() (certificateMaterial, error) {
|
|
now := time.Now().UTC()
|
|
caKey, caDER, err := newCertificate(&x509.Certificate{
|
|
SerialNumber: big.NewInt(1),
|
|
Subject: pkix.Name{CommonName: "Spinifex Conformance CA"},
|
|
NotBefore: now.Add(-time.Hour),
|
|
NotAfter: now.AddDate(1, 0, 0),
|
|
KeyUsage: x509.KeyUsageCertSign,
|
|
BasicConstraintsValid: true,
|
|
IsCA: true,
|
|
}, nil, nil)
|
|
if err != nil {
|
|
return certificateMaterial{}, err
|
|
}
|
|
ca, err := x509.ParseCertificate(caDER)
|
|
if err != nil {
|
|
return certificateMaterial{}, fmt.Errorf("awsmodel: parse conformance CA: %w", err)
|
|
}
|
|
leafKey, leafDER, err := newCertificate(&x509.Certificate{
|
|
SerialNumber: big.NewInt(2),
|
|
Subject: pkix.Name{CommonName: "spx.example.com"},
|
|
DNSNames: []string{"spx.example.com"},
|
|
NotBefore: now.Add(-time.Hour),
|
|
NotAfter: now.AddDate(1, 0, 0),
|
|
KeyUsage: x509.KeyUsageDigitalSignature,
|
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
|
}, ca, caKey)
|
|
if err != nil {
|
|
return certificateMaterial{}, err
|
|
}
|
|
keyDER, err := x509.MarshalPKCS8PrivateKey(leafKey)
|
|
if err != nil {
|
|
return certificateMaterial{}, fmt.Errorf("awsmodel: marshal conformance key: %w", err)
|
|
}
|
|
return certificateMaterial{
|
|
certificate: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: leafDER}),
|
|
privateKey: pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}),
|
|
chain: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDER}),
|
|
}, nil
|
|
})
|
|
|
|
// newCertificate creates a P-256 key and a certificate for it from template,
|
|
// signed by parent's key, or self-signed when parent is nil.
|
|
func newCertificate(template, parent *x509.Certificate, parentKey *ecdsa.PrivateKey) (*ecdsa.PrivateKey, []byte, error) {
|
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("awsmodel: generate conformance key: %w", err)
|
|
}
|
|
if parent == nil {
|
|
parent, parentKey = template, key
|
|
}
|
|
der, err := x509.CreateCertificate(rand.Reader, template, parent, &key.PublicKey, parentKey)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("awsmodel: create conformance certificate: %w", err)
|
|
}
|
|
return key, der, nil
|
|
}
|
|
|
|
// memberName is the structure member a value at path belongs to, looking
|
|
// through list indices.
|
|
func memberName(path []pathStep) string {
|
|
for _, step := range slices.Backward(path) {
|
|
if name, ok := step.(string); ok {
|
|
return name
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func capitalize(name string) string {
|
|
if name == "" {
|
|
return name
|
|
}
|
|
return strings.ToUpper(name[:1]) + name[1:]
|
|
}
|