package hostenv import ( "context" "errors" "strings" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/ssm" ssmtypes "github.com/aws/aws-sdk-go-v2/service/ssm/types" ) // The AWS adapter for Params (region HA spec 2 §25): SSM Parameter Store // SecureStrings, encrypted with the deployment's configuration key (§4). // A box reads its two paths with the role's ssm:GetParametersByPath and // kms:Decrypt; an operator seeding them needs ssm:PutParameter and // kms:Encrypt on that key. // ssmParams holds method values bound to the two SSM calls it makes, not // the *ssm.Client. Handing the client to the paginator (whose argument is // an interface) would make the linker keep every exported SSM method once // html/template is linked in: about 5 MB of linux/arm64 binary (discussion // #275, .amadan/ci.d/20-cross@5). A method value keeps the one call alone. type ssmParams struct { getByPath getParametersByPathFunc put func(context.Context, *ssm.PutParameterInput, ...func(*ssm.Options)) (*ssm.PutParameterOutput, error) kmsKey string } // getParametersByPathFunc is ssm.GetParametersByPathAPIClient over one // bound method value, for the paginator. type getParametersByPathFunc func(context.Context, *ssm.GetParametersByPathInput, ...func(*ssm.Options)) (*ssm.GetParametersByPathOutput, error) func (f getParametersByPathFunc) GetParametersByPath(ctx context.Context, in *ssm.GetParametersByPathInput, opts ...func(*ssm.Options)) (*ssm.GetParametersByPathOutput, error) { return f(ctx, in, opts...) } // openSSMParams opens SSM in awsRegion ("" is the SDK's default region). // kmsKey is the key Set encrypts with. func openSSMParams(ctx context.Context, awsRegion, kmsKey string) (Params, error) { var opts []func(*config.LoadOptions) error if awsRegion != "" { opts = append(opts, config.WithRegion(awsRegion)) } cfg, err := config.LoadDefaultConfig(ctx, opts...) if err != nil { return nil, err } client := ssm.NewFromConfig(cfg) return &ssmParams{ getByPath: getParametersByPathFunc(client.GetParametersByPath), put: client.PutParameter, kmsKey: kmsKey, }, nil } // ByPath reads every parameter one level under prefix, decrypted, page by // page. Any failed page fails the whole read. func (p *ssmParams) ByPath(ctx context.Context, prefix string) (map[string]string, error) { out := map[string]string{} pages := ssm.NewGetParametersByPathPaginator(p.getByPath, &ssm.GetParametersByPathInput{ Path: aws.String(strings.TrimSuffix(prefix, "/")), Recursive: aws.Bool(false), WithDecryption: aws.Bool(true), }) for pages.HasMorePages() { page, err := pages.NextPage(ctx) if err != nil { return nil, err } for _, param := range page.Parameters { name, ok := strings.CutPrefix(aws.ToString(param.Name), prefix) if !ok || name == "" || strings.Contains(name, "/") { continue } out[name] = aws.ToString(param.Value) } } return out, nil } // Set writes one SecureString with the deployment's key, replacing it: // a region's own values. func (p *ssmParams) Set(ctx context.Context, name, value string) error { in := &ssm.PutParameterInput{ Name: aws.String(name), Value: aws.String(value), Type: ssmtypes.ParameterTypeSecureString, Overwrite: aws.Bool(true), } if p.kmsKey != "" { in.KeyId = aws.String(p.kmsKey) } _, err := p.put(ctx, in) return err } // SetNew writes one SecureString with the deployment's key only if it does // not exist (Overwrite false). SSM's ParameterAlreadyExists is ErrExists. func (p *ssmParams) SetNew(ctx context.Context, name, value string) error { in := &ssm.PutParameterInput{ Name: aws.String(name), Value: aws.String(value), Type: ssmtypes.ParameterTypeSecureString, Overwrite: aws.Bool(false), } if p.kmsKey != "" { in.KeyId = aws.String(p.kmsKey) } _, err := p.put(ctx, in) var exists *ssmtypes.ParameterAlreadyExists if errors.As(err, &exists) { return ErrExists } return err }