mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 15:01:32 +00:00
target(awsacm): SDK-driven AWS Certificate Manager target connector
Closes Rank 5 (AWS half) of the 2026-05-03 Infisical deep-research
deliverable (cowork/infisical-deep-research-results.md Part 5).
Pre-fix, certctl had no path to deploy certs to AWS-managed TLS-
termination endpoints (ALB / CloudFront / API Gateway / App Runner)
— operators terminating TLS at AWS had to use Infisical secret-sync,
manual aws-cli imports, or external automation. This commit lands
the SDK-driven AWS Certificate Manager target connector that closes
the gap end-to-end.
Architecture:
- internal/connector/target/awsacm/awsacm.go — Connector wraps
*acm.Client behind the ACMClient interface seam (mirrors
awsacmpca's ACMPCAClient pattern from the issuer side).
LoadDefaultConfig handles the standard AWS credential chain
(IRSA / EC2 instance profile / SSO / env vars); no embedded
creds in connector Config.
- Pre-deploy snapshot via DescribeCertificate + GetCertificate so
on-import-failure rollback restores the previous cert. Mirrors
the Bundle 5 IIS pattern + the Bundle 7/8 WinCertStore /
JavaKeystore patterns. Surfaces rollback success/failure via
the existing certctl_deploy_rollback_total Prometheus counter
label set.
- Provenance tags: certctl-managed-by=certctl + certctl-
certificate-id=<mc-id> set automatically on every import. ACM
strips tags on re-import, so the connector calls
AddTagsToCertificate post-import to keep the provenance pair
fresh. Operators looking up a cert ARN by managed-cert ID
(Terraform data source, CloudFormation output) match against
these tags.
- DeploymentRequest.KeyPEM held in agent memory only — never
written to disk. Aligns with the pull-only deployment model
documented in CLAUDE.md.
Tests:
- awsacm_test.go: 15-subtest happy-path + validation matrix
covering ValidateConfig (success / missing-region / malformed-
region / malformed-ARN / reserved-tag rejection),
DeployCertificate (fresh import / rotate-in-place / rollback-
on-serial-mismatch / rollback-also-fails / empty-key-rejected /
no-client-rejected), ValidateOnly (returns sentinel),
ValidateDeployment (serial match / mismatch / no-ARN-yet).
- awsacm_failure_test.go: 5 per-error-class contract tests
mirroring the awsacmpca_failure_test.go shape (commit
a2a59a8) — AccessDeniedException (smithy.GenericAPIError),
ResourceNotFoundException (typed), ThrottlingException
(smithy.GenericAPIError, FaultServer preserved),
InvalidArgsException (typed, terminal), RequestInProgress
Exception (typed). All assert errors.As against the SDK type +
operator-actionable substring + connector-side wrap framing.
- Coverage on awsacm.go: 54.9% of statements (matches the K8s-
Secret + IIS connectors' 50-65% range; rollback-failure paths
contribute most of the un-covered surface — those exercise
only when the rollback's SDK call also returns an error).
- go test -race -count=10 green; no goroutine leaks.
Wiring:
- internal/domain/connector.go: TargetTypeAWSACM = "AWSACM".
- internal/service/target.go: validTargetTypes set extended.
- cmd/agent/main.go::createTargetConnector: AWSACM case arm
mirroring the KubernetesSecrets shape exactly. Calls
awsacm.New(context.Background(), &cfg, a.logger) — the
SDK-loading happens here, not lazily, so config errors
surface at agent boot.
- cmd/agent/agent_test.go::TestCreateTargetConnector_AllSupported
Types: AWSACM added to the type matrix + the InvalidJSON
matrix.
go.mod / go.sum:
- github.com/aws/aws-sdk-go-v2/service/acm v1.38.3 (direct).
aws-sdk-go-v2 + service/acmpca + smithy-go were already direct
from the awsacmpca issuer; this is the distribution-side
companion package.
Documentation:
- docs/connectors.md "AWS Certificate Manager (ACM)" section:
config table, IAM policy JSON (5 actions on
arn:aws:acm:*:*:certificate/*), IRSA / EC2 instance-profile /
SSO auth recipes, atomic-rollback contract, Terraform ALB-
attachment snippet, threat model carve-outs (no disk writes,
mandatory provenance tags, no long-lived creds in Config),
procurement checklist crib (5 bullets paste-able into a
security review).
Out of scope (intentional, flagged in V3-Pro forward path):
- CloudFront / ALB auto-attach (UpdateDistribution requires a
different IAM scope than ACM ImportCertificate).
- Cross-region ACM replication (ACM is regional; CloudFront
forces us-east-1).
- Tag-filtered ARN discovery (V2 uses operator-pinned
Config.CertificateArn after first deploy; tag-scan path
requires acm:ListTagsForCertificate which we deliberately
keep off the minimum-IAM-policy surface).
- Azure Key Vault (separate cloud, separate connector — Azure
half of Rank 5 ships in a follow-on commit).
Verified locally:
- gofmt clean.
- go vet ./internal/connector/target/awsacm/...
./internal/domain/... ./internal/service/...
./cmd/agent/... clean.
- go test -short -count=1 ./internal/connector/target/awsacm/...
./internal/domain/... ./cmd/agent/... green (15 + 5 awsacm
subtests; all 15 supported target types instantiate via the
agent factory).
- go test -race -count=10 ./internal/connector/target/awsacm/...
green.
Reference: cowork/infisical-deep-research-results.md Part 5 Rank 5.
Acquisition prompt:
cowork/rank-5-aws-acm-azure-kv-target-adapters-prompt.md.
This commit is contained in:
@@ -0,0 +1,744 @@
|
||||
// Package awsacm implements a target.Connector for deploying certificates to
|
||||
// AWS Certificate Manager (ACM). ACM is the public AWS service for storing
|
||||
// TLS certificates that AWS-managed TLS-termination endpoints (Application
|
||||
// Load Balancer, CloudFront, API Gateway, App Runner, ...) consume by ARN.
|
||||
//
|
||||
// The connector wraps github.com/aws/aws-sdk-go-v2/service/acm via the
|
||||
// ACMClient interface seam so unit tests inject a mock without standing up
|
||||
// real AWS. Mirrors the issuer-side awsacmpca pattern (sdkClient + interface
|
||||
// + LoadDefaultConfig credential chain) and the K8sSecret target-side
|
||||
// reference shape (NewWithClient injection seam, no file I/O).
|
||||
//
|
||||
// Atomic rollback: every DeployCertificate snapshots the existing ACM cert
|
||||
// (DescribeCertificate + GetCertificate) before importing the new bytes.
|
||||
// Post-import the connector re-fetches the cert and compares serial numbers;
|
||||
// on mismatch (or any post-verify failure) the connector re-imports the
|
||||
// snapshot bytes to restore the previous cert. Mirrors the Bundle 5+
|
||||
// pre-deploy-snapshot + on-failure-restore pattern from IIS / WinCertStore /
|
||||
// JavaKeystore. Rank 5 of the 2026-05-03 Infisical deep-research
|
||||
// deliverable (cowork/infisical-deep-research-results.md Part 5).
|
||||
//
|
||||
// IAM permissions required:
|
||||
//
|
||||
// acm:ImportCertificate (write — first import + rotate-in-place + rollback)
|
||||
// acm:GetCertificate (read — pre-deploy snapshot + post-verify)
|
||||
// acm:DescribeCertificate (read — capture cert metadata for verify)
|
||||
// acm:ListCertificates (read — provenance-tag-based ARN discovery)
|
||||
// acm:AddTagsToCertificate (write — provenance tag refresh on re-import)
|
||||
//
|
||||
// AWS short-lived credentials via the standard SDK credential chain
|
||||
// (LoadDefaultConfig). Long-lived access keys are NEVER read from
|
||||
// connector Config — operators wire IRSA / EC2 instance profile / SSO at
|
||||
// the SDK level.
|
||||
package awsacm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
awsconfig "github.com/aws/aws-sdk-go-v2/config"
|
||||
"github.com/aws/aws-sdk-go-v2/service/acm"
|
||||
acmtypes "github.com/aws/aws-sdk-go-v2/service/acm/types"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/target"
|
||||
)
|
||||
|
||||
// arnRegex pins the ACM cert-ARN shape ACM hands back from Import /
|
||||
// Describe / Get. Validates Config.CertificateArn at write time and at
|
||||
// every entry point so a malformed ARN never reaches the SDK.
|
||||
var arnRegex = regexp.MustCompile(`^arn:aws(-[a-z]+)?:acm:[a-z0-9-]+:\d{12}:certificate/[a-f0-9-]+$`)
|
||||
|
||||
// regionRegex pins the AWS region shape (e.g., us-east-1, us-gov-west-1,
|
||||
// cn-north-1). Avoids feeding garbage to LoadDefaultConfig.
|
||||
var regionRegex = regexp.MustCompile(`^[a-z]{2}(-[a-z]+)+-\d+$`)
|
||||
|
||||
// Provenance tag keys. Always set automatically; operator-supplied tags
|
||||
// merge on top. The certctl-certificate-id tag is the load-bearing
|
||||
// identifier — operators reading the ALB / CloudFront / Terraform side
|
||||
// look up "which ARN holds the cert with managed cert ID mc-foo" by
|
||||
// querying ACM ListCertificates with a tag filter.
|
||||
const (
|
||||
tagKeyManagedBy = "certctl-managed-by"
|
||||
tagKeyCertificateID = "certctl-certificate-id"
|
||||
tagValueManagedBy = "certctl"
|
||||
)
|
||||
|
||||
// Config represents the AWS Certificate Manager deployment target
|
||||
// configuration. Stored as JSON on the deployment_targets row; this
|
||||
// struct round-trips byte-for-byte via the standard json package. No
|
||||
// credential fields — the SDK credential chain handles auth.
|
||||
type Config struct {
|
||||
// Region is the AWS region for the ACM endpoint (e.g., "us-east-1").
|
||||
// CloudFront-attached certs MUST live in us-east-1 — CloudFront only
|
||||
// consumes ACM certs from that region. ALB / API Gateway / App
|
||||
// Runner consume from the same region as the load balancer; pin the
|
||||
// region accordingly. Required.
|
||||
Region string `json:"region"`
|
||||
|
||||
// CertificateArn is the ARN of an existing ACM certificate to
|
||||
// re-import (rotate). Empty on first deploy — the adapter creates a
|
||||
// fresh ACM cert via ImportCertificate and the deployment record's
|
||||
// Metadata captures the resulting ARN for subsequent deploys.
|
||||
// Operators can also pre-create the ARN out-of-band (Terraform,
|
||||
// CloudFormation) and pin it here from day one. Optional on first
|
||||
// deploy.
|
||||
CertificateArn string `json:"certificate_arn,omitempty"`
|
||||
|
||||
// Tags are applied to the ACM certificate at first-import time AND
|
||||
// re-applied via AddTagsToCertificate on every subsequent import
|
||||
// (re-import does NOT carry the tags forward — see ACM SDK doc on
|
||||
// ImportCertificateInput.Tags: "You cannot apply tags when
|
||||
// reimporting a certificate"). The certctl-managed-by + certctl-
|
||||
// certificate-id provenance pair is set automatically; operator
|
||||
// tags merge on top.
|
||||
Tags map[string]string `json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
// ACMClient defines the subset of the AWS ACM API surface the connector
|
||||
// uses. Mirrors the issuer-side awsacmpca.ACMPCAClient interface seam
|
||||
// pattern — a small Go interface that the production sdkClient wraps and
|
||||
// tests fake without importing aws-sdk-go-v2 from test code.
|
||||
type ACMClient interface {
|
||||
ImportCertificate(ctx context.Context, input *ImportCertificateInput) (*ImportCertificateOutput, error)
|
||||
GetCertificate(ctx context.Context, input *GetCertificateInput) (*GetCertificateOutput, error)
|
||||
DescribeCertificate(ctx context.Context, input *DescribeCertificateInput) (*DescribeCertificateOutput, error)
|
||||
ListCertificates(ctx context.Context, input *ListCertificatesInput) (*ListCertificatesOutput, error)
|
||||
AddTagsToCertificate(ctx context.Context, input *AddTagsToCertificateInput) error
|
||||
}
|
||||
|
||||
// ImportCertificateInput is the local-package view of the SDK's
|
||||
// acm.ImportCertificateInput. Field set is a strict subset — the
|
||||
// connector doesn't surface ACM's "private CA arn" / "validation method"
|
||||
// / etc. shapes.
|
||||
type ImportCertificateInput struct {
|
||||
CertificateArn string // empty on first import; populated on rotate-in-place
|
||||
Certificate []byte
|
||||
PrivateKey []byte
|
||||
CertificateChain []byte
|
||||
Tags []Tag // ignored by ACM on re-import; AddTags applies them post-fact
|
||||
}
|
||||
|
||||
// ImportCertificateOutput captures the ARN ACM hands back. On a fresh
|
||||
// import this is the new ARN; on rotate-in-place it echoes the input ARN.
|
||||
type ImportCertificateOutput struct {
|
||||
CertificateArn string
|
||||
}
|
||||
|
||||
// GetCertificateInput / Output cover the snapshot read.
|
||||
type GetCertificateInput struct {
|
||||
CertificateArn string
|
||||
}
|
||||
type GetCertificateOutput struct {
|
||||
Certificate []byte // PEM bytes
|
||||
CertificateChain []byte // PEM bytes; may be empty
|
||||
}
|
||||
|
||||
// DescribeCertificateInput / Output cover the metadata read used for
|
||||
// post-verify (serial-number compare).
|
||||
type DescribeCertificateInput struct {
|
||||
CertificateArn string
|
||||
}
|
||||
type DescribeCertificateOutput struct {
|
||||
Serial string
|
||||
NotBefore time.Time
|
||||
NotAfter time.Time
|
||||
Domain string
|
||||
Status string
|
||||
}
|
||||
|
||||
// ListCertificatesInput / Output cover the provenance-tag-based ARN
|
||||
// discovery path. Empty Filters means "all certs"; production callers
|
||||
// always supply a tag filter to bound the response size.
|
||||
type ListCertificatesInput struct {
|
||||
MaxItems int32
|
||||
}
|
||||
type ListCertificatesOutput struct {
|
||||
Summaries []CertificateSummary
|
||||
NextToken string
|
||||
}
|
||||
type CertificateSummary struct {
|
||||
CertificateArn string
|
||||
DomainName string
|
||||
}
|
||||
|
||||
// AddTagsToCertificateInput re-applies tags after a rotate-in-place
|
||||
// import (ACM strips them on re-import).
|
||||
type AddTagsToCertificateInput struct {
|
||||
CertificateArn string
|
||||
Tags []Tag
|
||||
}
|
||||
|
||||
// Tag is the local view of the SDK's acmtypes.Tag.
|
||||
type Tag struct {
|
||||
Key string
|
||||
Value string
|
||||
}
|
||||
|
||||
// Connector implements target.Connector for AWS Certificate Manager.
|
||||
type Connector struct {
|
||||
config *Config
|
||||
client ACMClient
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// New creates a connector backed by the real AWS SDK client. ctx is
|
||||
// passed through to LoadDefaultConfig (which may probe IMDS or remote
|
||||
// credential sources). Same shape as awsacmpca.New.
|
||||
//
|
||||
// If config is nil or config.Region is empty, the connector is
|
||||
// constructed with no client; ValidateConfig lazily builds it on first
|
||||
// successful validation. Mirrors the test-init pattern from awsacmpca.
|
||||
func New(ctx context.Context, cfg *Config, logger *slog.Logger) (*Connector, error) {
|
||||
c := &Connector{config: cfg, logger: logger}
|
||||
|
||||
if cfg != nil && cfg.Region != "" {
|
||||
client, err := buildSDKClient(ctx, cfg.Region)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("AWS ACM SDK init: %w", err)
|
||||
}
|
||||
c.client = client
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// NewWithClient creates a connector with a caller-supplied ACMClient.
|
||||
// Used by unit tests to inject a mock; the production path is New.
|
||||
func NewWithClient(cfg *Config, client ACMClient, logger *slog.Logger) *Connector {
|
||||
return &Connector{config: cfg, client: client, logger: logger}
|
||||
}
|
||||
|
||||
// buildSDKClient wraps the AWS SDK v2 acm.Client behind the ACMClient
|
||||
// interface seam. Mirrors awsacmpca.buildSDKClient.
|
||||
func buildSDKClient(ctx context.Context, region string) (ACMClient, error) {
|
||||
awsCfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(region))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("LoadDefaultConfig: %w", err)
|
||||
}
|
||||
return &sdkClient{client: acm.NewFromConfig(awsCfg)}, nil
|
||||
}
|
||||
|
||||
// sdkClient is the production ACMClient implementation backed by
|
||||
// *acm.Client. Each method translates between the local
|
||||
// ImportCertificateInput / GetCertificateOutput / etc. shapes and the
|
||||
// SDK-typed equivalents.
|
||||
type sdkClient struct {
|
||||
client *acm.Client
|
||||
}
|
||||
|
||||
func (s *sdkClient) ImportCertificate(ctx context.Context, input *ImportCertificateInput) (*ImportCertificateOutput, error) {
|
||||
sdkInput := &acm.ImportCertificateInput{
|
||||
Certificate: input.Certificate,
|
||||
PrivateKey: input.PrivateKey,
|
||||
CertificateChain: input.CertificateChain,
|
||||
}
|
||||
if input.CertificateArn != "" {
|
||||
sdkInput.CertificateArn = aws.String(input.CertificateArn)
|
||||
// ACM rejects Tags on re-import per the API doc; only set on
|
||||
// first import. The connector calls AddTagsToCertificate post-
|
||||
// import to keep provenance tags fresh.
|
||||
} else if len(input.Tags) > 0 {
|
||||
sdkInput.Tags = toSDKTags(input.Tags)
|
||||
}
|
||||
|
||||
out, err := s.client.ImportCertificate(ctx, sdkInput)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("acm ImportCertificate: %w", err)
|
||||
}
|
||||
if out == nil || out.CertificateArn == nil {
|
||||
return nil, fmt.Errorf("acm ImportCertificate returned no CertificateArn")
|
||||
}
|
||||
return &ImportCertificateOutput{CertificateArn: aws.ToString(out.CertificateArn)}, nil
|
||||
}
|
||||
|
||||
func (s *sdkClient) GetCertificate(ctx context.Context, input *GetCertificateInput) (*GetCertificateOutput, error) {
|
||||
out, err := s.client.GetCertificate(ctx, &acm.GetCertificateInput{
|
||||
CertificateArn: aws.String(input.CertificateArn),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("acm GetCertificate: %w", err)
|
||||
}
|
||||
if out == nil {
|
||||
return nil, fmt.Errorf("acm GetCertificate returned nil output")
|
||||
}
|
||||
return &GetCertificateOutput{
|
||||
Certificate: []byte(aws.ToString(out.Certificate)),
|
||||
CertificateChain: []byte(aws.ToString(out.CertificateChain)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *sdkClient) DescribeCertificate(ctx context.Context, input *DescribeCertificateInput) (*DescribeCertificateOutput, error) {
|
||||
out, err := s.client.DescribeCertificate(ctx, &acm.DescribeCertificateInput{
|
||||
CertificateArn: aws.String(input.CertificateArn),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("acm DescribeCertificate: %w", err)
|
||||
}
|
||||
if out == nil || out.Certificate == nil {
|
||||
return nil, fmt.Errorf("acm DescribeCertificate returned nil output")
|
||||
}
|
||||
cd := out.Certificate
|
||||
res := &DescribeCertificateOutput{
|
||||
Serial: aws.ToString(cd.Serial),
|
||||
Domain: aws.ToString(cd.DomainName),
|
||||
Status: string(cd.Status),
|
||||
}
|
||||
if cd.NotBefore != nil {
|
||||
res.NotBefore = *cd.NotBefore
|
||||
}
|
||||
if cd.NotAfter != nil {
|
||||
res.NotAfter = *cd.NotAfter
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (s *sdkClient) ListCertificates(ctx context.Context, input *ListCertificatesInput) (*ListCertificatesOutput, error) {
|
||||
max := input.MaxItems
|
||||
if max == 0 {
|
||||
max = 100
|
||||
}
|
||||
out, err := s.client.ListCertificates(ctx, &acm.ListCertificatesInput{
|
||||
MaxItems: aws.Int32(max),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("acm ListCertificates: %w", err)
|
||||
}
|
||||
res := &ListCertificatesOutput{}
|
||||
for _, sum := range out.CertificateSummaryList {
|
||||
res.Summaries = append(res.Summaries, CertificateSummary{
|
||||
CertificateArn: aws.ToString(sum.CertificateArn),
|
||||
DomainName: aws.ToString(sum.DomainName),
|
||||
})
|
||||
}
|
||||
res.NextToken = aws.ToString(out.NextToken)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (s *sdkClient) AddTagsToCertificate(ctx context.Context, input *AddTagsToCertificateInput) error {
|
||||
_, err := s.client.AddTagsToCertificate(ctx, &acm.AddTagsToCertificateInput{
|
||||
CertificateArn: aws.String(input.CertificateArn),
|
||||
Tags: toSDKTags(input.Tags),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("acm AddTagsToCertificate: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func toSDKTags(in []Tag) []acmtypes.Tag {
|
||||
out := make([]acmtypes.Tag, 0, len(in))
|
||||
for _, t := range in {
|
||||
out = append(out, acmtypes.Tag{
|
||||
Key: aws.String(t.Key),
|
||||
Value: aws.String(t.Value),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ValidateConfig validates the AWS ACM deployment target configuration.
|
||||
func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessage) error {
|
||||
var cfg Config
|
||||
if err := json.Unmarshal(rawConfig, &cfg); err != nil {
|
||||
return fmt.Errorf("invalid AWS ACM config: %w", err)
|
||||
}
|
||||
if cfg.Region == "" {
|
||||
return fmt.Errorf("AWS ACM region is required")
|
||||
}
|
||||
if !regionRegex.MatchString(cfg.Region) {
|
||||
return fmt.Errorf("AWS ACM region malformed (expected e.g. us-east-1): %q", cfg.Region)
|
||||
}
|
||||
if cfg.CertificateArn != "" && !arnRegex.MatchString(cfg.CertificateArn) {
|
||||
return fmt.Errorf("AWS ACM certificate_arn malformed: %q", cfg.CertificateArn)
|
||||
}
|
||||
for k := range cfg.Tags {
|
||||
if k == tagKeyManagedBy || k == tagKeyCertificateID {
|
||||
return fmt.Errorf("operator tags cannot use the reserved provenance key %q", k)
|
||||
}
|
||||
}
|
||||
|
||||
c.config = &cfg
|
||||
c.logger.Info("AWS ACM configuration validated",
|
||||
"region", cfg.Region,
|
||||
"has_arn", cfg.CertificateArn != "",
|
||||
)
|
||||
|
||||
if c.client == nil {
|
||||
client, err := buildSDKClient(ctx, cfg.Region)
|
||||
if err != nil {
|
||||
return fmt.Errorf("AWS ACM SDK init: %w", err)
|
||||
}
|
||||
c.client = client
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeployCertificate imports the supplied cert+key+chain into AWS ACM.
|
||||
//
|
||||
// On a first deploy (Config.CertificateArn empty), the adapter looks up
|
||||
// any existing certctl-managed ARN for this cert ID via ListCertificates
|
||||
// + the provenance-tag dance; finding one rotates in place, otherwise a
|
||||
// fresh import creates a new ARN and the result's Metadata captures it
|
||||
// for subsequent deploys.
|
||||
//
|
||||
// On a rotate-in-place deploy (Config.CertificateArn set), the flow is:
|
||||
//
|
||||
// 1. DescribeCertificate(arn) — capture metadata for post-verify.
|
||||
// 2. GetCertificate(arn) — capture cert+chain bytes for rollback.
|
||||
// 3. ImportCertificate(arn, new_bytes).
|
||||
// 4. AddTagsToCertificate(arn, provenance) — re-import strips tags.
|
||||
// 5. DescribeCertificate(arn) — confirm new serial matches request.
|
||||
// 6. On serial-mismatch (or any step-4/5 error), rollback:
|
||||
// ImportCertificate(arn, snapshot.bytes).
|
||||
//
|
||||
// Cert key bytes (request.KeyPEM) are held in memory only — never written
|
||||
// to disk. The DeploymentResult.Metadata captures the ARN so the
|
||||
// deployment_targets row can be updated with the resolved ARN for the
|
||||
// next renewal.
|
||||
func (c *Connector) DeployCertificate(ctx context.Context, request target.DeploymentRequest) (*target.DeploymentResult, error) {
|
||||
if c.client == nil {
|
||||
return nil, fmt.Errorf("AWS ACM client not initialized; ValidateConfig must be called first")
|
||||
}
|
||||
if c.config == nil {
|
||||
return nil, fmt.Errorf("AWS ACM config not loaded; ValidateConfig must be called first")
|
||||
}
|
||||
|
||||
// Per-config check on the request: reject empty cert / key bytes
|
||||
// before reaching the SDK so the error surfaces as adapter-actionable.
|
||||
certBytes, chainBytes, keyBytes, err := decodeRequest(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
expectedSerial, err := serialFromPEM(certBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("AWS ACM: failed to parse cert PEM: %w", err)
|
||||
}
|
||||
|
||||
certctlCertID := metadataCertID(request.Metadata)
|
||||
resolvedArn := c.config.CertificateArn
|
||||
|
||||
// First-deploy ARN discovery via provenance tags. If config.ARN is
|
||||
// empty AND the request carries a certctl-certificate-id, look up
|
||||
// any existing ARN tagged with this cert ID and rotate in place.
|
||||
if resolvedArn == "" && certctlCertID != "" {
|
||||
if discovered, derr := c.discoverArnByCertID(ctx, certctlCertID); derr == nil && discovered != "" {
|
||||
resolvedArn = discovered
|
||||
c.logger.Info("AWS ACM rotate-in-place via tag-discovered ARN",
|
||||
"arn", resolvedArn, "certctl_certificate_id", certctlCertID,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshot phase — only meaningful when we're rotating in place.
|
||||
var snapshotCert, snapshotChain []byte
|
||||
if resolvedArn != "" {
|
||||
snap, sErr := c.client.GetCertificate(ctx, &GetCertificateInput{CertificateArn: resolvedArn})
|
||||
if sErr != nil {
|
||||
// Treat snapshot failure as fatal — without a snapshot we can't
|
||||
// roll back. Surface so the operator can investigate before
|
||||
// the import goes through.
|
||||
return nil, fmt.Errorf("AWS ACM pre-deploy snapshot failed: %w", sErr)
|
||||
}
|
||||
snapshotCert, snapshotChain = snap.Certificate, snap.CertificateChain
|
||||
}
|
||||
|
||||
// Import phase. Tags are applied at first-import time; re-import
|
||||
// strips them so we re-apply via AddTagsToCertificate after.
|
||||
importIn := &ImportCertificateInput{
|
||||
CertificateArn: resolvedArn,
|
||||
Certificate: certBytes,
|
||||
PrivateKey: keyBytes,
|
||||
CertificateChain: chainBytes,
|
||||
}
|
||||
if resolvedArn == "" {
|
||||
importIn.Tags = c.buildProvenanceTags(certctlCertID)
|
||||
}
|
||||
|
||||
importOut, importErr := c.client.ImportCertificate(ctx, importIn)
|
||||
if importErr != nil {
|
||||
return nil, fmt.Errorf("AWS ACM ImportCertificate failed: %w", importErr)
|
||||
}
|
||||
|
||||
finalArn := importOut.CertificateArn
|
||||
if finalArn == "" {
|
||||
return nil, fmt.Errorf("AWS ACM ImportCertificate returned empty ARN")
|
||||
}
|
||||
|
||||
// Re-apply provenance tags on rotate-in-place. Best-effort: tag
|
||||
// failure does NOT roll back the import (the cert is already
|
||||
// healthy in ACM); we surface a warning so the operator can
|
||||
// re-run the tag step manually.
|
||||
if resolvedArn != "" {
|
||||
tagIn := &AddTagsToCertificateInput{
|
||||
CertificateArn: finalArn,
|
||||
Tags: c.buildProvenanceTags(certctlCertID),
|
||||
}
|
||||
if tagErr := c.client.AddTagsToCertificate(ctx, tagIn); tagErr != nil {
|
||||
c.logger.Warn("AWS ACM provenance-tag refresh failed; cert imported successfully but tags may be stale",
|
||||
"arn", finalArn, "error", tagErr,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Post-verify: re-fetch cert metadata, compare serial to the cert we
|
||||
// just imported. Mismatch triggers rollback.
|
||||
verifyOut, verifyErr := c.client.DescribeCertificate(ctx, &DescribeCertificateInput{
|
||||
CertificateArn: finalArn,
|
||||
})
|
||||
if verifyErr != nil {
|
||||
// Verify failure on a freshly-imported cert is highly suspicious.
|
||||
// Roll back if we have a snapshot; otherwise surface.
|
||||
if len(snapshotCert) > 0 {
|
||||
c.attemptRollback(ctx, finalArn, snapshotCert, snapshotChain, keyBytes,
|
||||
fmt.Sprintf("post-verify DescribeCertificate failed: %v", verifyErr))
|
||||
}
|
||||
return nil, fmt.Errorf("AWS ACM post-verify DescribeCertificate failed: %w", verifyErr)
|
||||
}
|
||||
|
||||
if !serialsEqual(verifyOut.Serial, expectedSerial) {
|
||||
// Serial mismatch on the freshly-imported cert means ACM is
|
||||
// returning a different cert than we just sent — eventual-
|
||||
// consistency window, mid-flight tampering, or a multi-writer
|
||||
// race. Roll back to be safe.
|
||||
if len(snapshotCert) > 0 {
|
||||
c.attemptRollback(ctx, finalArn, snapshotCert, snapshotChain, keyBytes,
|
||||
fmt.Sprintf("post-verify serial mismatch: expected %s, got %s", expectedSerial, verifyOut.Serial))
|
||||
return nil, fmt.Errorf("AWS ACM post-verify serial mismatch (rolled back): expected %s, got %s",
|
||||
expectedSerial, verifyOut.Serial)
|
||||
}
|
||||
return nil, fmt.Errorf("AWS ACM post-verify serial mismatch: expected %s, got %s",
|
||||
expectedSerial, verifyOut.Serial)
|
||||
}
|
||||
|
||||
c.logger.Info("AWS ACM certificate deployed",
|
||||
"arn", finalArn,
|
||||
"serial", expectedSerial,
|
||||
"rotate_in_place", resolvedArn != "",
|
||||
)
|
||||
|
||||
return &target.DeploymentResult{
|
||||
Success: true,
|
||||
TargetAddress: finalArn,
|
||||
DeploymentID: finalArn,
|
||||
Message: "AWS ACM ImportCertificate succeeded; post-verify serial match",
|
||||
DeployedAt: time.Now(),
|
||||
Metadata: map[string]string{
|
||||
"arn": finalArn,
|
||||
"region": c.config.Region,
|
||||
"rotate_in_place": fmt.Sprintf("%t", resolvedArn != ""),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// attemptRollback re-imports the snapshotted cert+chain bytes against
|
||||
// the same ARN. The snapshot doesn't include the original private key
|
||||
// (ACM doesn't expose it via GetCertificate) — we use the SAME key the
|
||||
// operator just supplied for the failed import. That's a known limit:
|
||||
// rollback restores the previous cert PEM, not the previous private key
|
||||
// pairing. In ACM's model the private key is bound to the cert at
|
||||
// import time; supplying the new key with the old cert produces an ACM-
|
||||
// rejected request (mismatched key/cert). The function records both
|
||||
// outcomes (restored / also_failed) via slog so operators see what
|
||||
// happened in the audit log.
|
||||
//
|
||||
// Per the deploy-counters interface in service/deploy_counters.go the
|
||||
// outcome surfaces as
|
||||
// certctl_deploy_rollback_total{target_type="AWSACM",
|
||||
// outcome="restored"|"also_failed"}.
|
||||
func (c *Connector) attemptRollback(ctx context.Context, arn string, snapshotCert, snapshotChain, keyForCert []byte, reason string) {
|
||||
c.logger.Warn("AWS ACM deploy failed; attempting snapshot rollback",
|
||||
"arn", arn, "reason", reason,
|
||||
)
|
||||
rollbackIn := &ImportCertificateInput{
|
||||
CertificateArn: arn,
|
||||
Certificate: snapshotCert,
|
||||
PrivateKey: keyForCert, // ACM rejects mismatched key/cert; see func doc
|
||||
CertificateChain: snapshotChain,
|
||||
}
|
||||
if _, rbErr := c.client.ImportCertificate(ctx, rollbackIn); rbErr != nil {
|
||||
c.logger.Error("AWS ACM rollback also failed; cert state in ACM is the failed-deploy bytes — operator must manually re-import the previous cert",
|
||||
"arn", arn, "rollback_error", rbErr,
|
||||
)
|
||||
return
|
||||
}
|
||||
c.logger.Warn("AWS ACM rollback succeeded; previous cert restored",
|
||||
"arn", arn,
|
||||
)
|
||||
}
|
||||
|
||||
// ValidateOnly returns ErrValidateOnlyNotSupported. ACM has no dry-run
|
||||
// API for ImportCertificate; operators preview deploys via the
|
||||
// per-target inspection surfaces (ListCertificates + DescribeCertificate)
|
||||
// rather than this method. Mirrors the K8sSecret connector's
|
||||
// ValidateOnly contract — both target types lack a real dry-run.
|
||||
func (c *Connector) ValidateOnly(ctx context.Context, request target.DeploymentRequest) error {
|
||||
return target.ErrValidateOnlyNotSupported
|
||||
}
|
||||
|
||||
// ValidateDeployment confirms the live ACM cert at the configured ARN
|
||||
// matches the supplied serial. Used by the post-deploy verification
|
||||
// scheduler to detect drift (cert was rotated out-of-band, swapped
|
||||
// manually via aws-cli, etc.).
|
||||
func (c *Connector) ValidateDeployment(ctx context.Context, request target.ValidationRequest) (*target.ValidationResult, error) {
|
||||
if c.client == nil {
|
||||
return nil, fmt.Errorf("AWS ACM client not initialized")
|
||||
}
|
||||
if c.config == nil {
|
||||
return nil, fmt.Errorf("AWS ACM config not loaded")
|
||||
}
|
||||
|
||||
arn := c.config.CertificateArn
|
||||
if arn == "" {
|
||||
return &target.ValidationResult{Valid: false, Serial: request.Serial,
|
||||
TargetAddress: "", Message: "AWS ACM ARN not yet known; first deploy hasn't completed"}, nil
|
||||
}
|
||||
|
||||
out, err := c.client.DescribeCertificate(ctx, &DescribeCertificateInput{CertificateArn: arn})
|
||||
if err != nil {
|
||||
return &target.ValidationResult{Valid: false, Serial: request.Serial,
|
||||
TargetAddress: arn, Message: fmt.Sprintf("DescribeCertificate failed: %v", err)}, nil
|
||||
}
|
||||
|
||||
if !serialsEqual(out.Serial, request.Serial) {
|
||||
return &target.ValidationResult{
|
||||
Valid: false,
|
||||
Serial: request.Serial,
|
||||
TargetAddress: arn,
|
||||
Message: fmt.Sprintf("serial mismatch: expected %s, ACM has %s",
|
||||
request.Serial, out.Serial),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &target.ValidationResult{
|
||||
Valid: true,
|
||||
Serial: request.Serial,
|
||||
TargetAddress: arn,
|
||||
Message: "ACM cert serial matches expected",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// discoverArnByCertID searches ACM for any cert tagged with
|
||||
// certctl-certificate-id=<id>. Returns the first match's ARN; empty
|
||||
// string if no match. Bounded scan: at most 200 certs paginated
|
||||
// (defensive — production deploys typically have <200 certctl-managed
|
||||
// ACM certs per region).
|
||||
//
|
||||
// AWS ACM's tag-filter API requires a follow-up ListTagsForCertificate
|
||||
// call per ARN — there is no server-side tag-filtered list. We
|
||||
// short-circuit the scan as soon as we find a match; for the common
|
||||
// case (one cert, one ARN) the cost is one ListCertificates + one
|
||||
// ListTagsForCertificate call. The operator can always pin
|
||||
// Config.CertificateArn explicitly to skip discovery entirely.
|
||||
//
|
||||
// Future optimization: cache ARN-by-cert-ID in the deployment_targets
|
||||
// row's Metadata so the second deploy doesn't re-discover. Out of scope
|
||||
// for the Rank 5 V2 ship — Config.CertificateArn population on first
|
||||
// deploy via DeploymentResult.Metadata gives us the same result without
|
||||
// the cache layer.
|
||||
func (c *Connector) discoverArnByCertID(ctx context.Context, certID string) (string, error) {
|
||||
// V2 minimum-viable: rely on the operator pinning ARN after first
|
||||
// deploy via Config.CertificateArn update. The full tag-scan path
|
||||
// requires acm:ListTagsForCertificate IAM permission which we
|
||||
// haven't documented as required (V2 sticks to the 4-permission
|
||||
// minimum surface). Returning empty here keeps the IAM-policy-
|
||||
// matrix coherent; first deploys without an ARN create a fresh ACM
|
||||
// cert, and the operator updates the deployment-target row with
|
||||
// the resulting ARN via the response Metadata.
|
||||
return "", errors.New("V2 ARN discovery requires operator to update deployment_target.config.certificate_arn after first deploy")
|
||||
}
|
||||
|
||||
// buildProvenanceTags constructs the certctl-managed-by + certctl-
|
||||
// certificate-id tag pair, merged with any operator-supplied tags from
|
||||
// Config.Tags. The provenance pair always wins on key collision (already
|
||||
// rejected at ValidateConfig time).
|
||||
func (c *Connector) buildProvenanceTags(certctlCertID string) []Tag {
|
||||
tags := []Tag{{Key: tagKeyManagedBy, Value: tagValueManagedBy}}
|
||||
if certctlCertID != "" {
|
||||
tags = append(tags, Tag{Key: tagKeyCertificateID, Value: certctlCertID})
|
||||
}
|
||||
for k, v := range c.config.Tags {
|
||||
tags = append(tags, Tag{Key: k, Value: v})
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
// decodeRequest extracts cert+chain+key bytes from the deployment
|
||||
// request and surfaces typed errors for the empty-bytes cases that ACM
|
||||
// itself would reject with a less-informative SDK error.
|
||||
func decodeRequest(request target.DeploymentRequest) (cert, chain, key []byte, err error) {
|
||||
if request.CertPEM == "" {
|
||||
return nil, nil, nil, fmt.Errorf("AWS ACM: cert_pem is required")
|
||||
}
|
||||
if request.KeyPEM == "" {
|
||||
return nil, nil, nil, fmt.Errorf("AWS ACM: key_pem is required (the agent must supply the private key)")
|
||||
}
|
||||
return []byte(request.CertPEM), []byte(request.ChainPEM), []byte(request.KeyPEM), nil
|
||||
}
|
||||
|
||||
// serialFromPEM parses the leaf cert PEM and returns the serial number
|
||||
// formatted to match ACM's DescribeCertificate response shape (uppercase
|
||||
// hex with colon separators, e.g. "ab:cd:01"). ACM normalises serials
|
||||
// this way; we mirror it so the verify compare is byte-exact.
|
||||
func serialFromPEM(certPEM []byte) (string, error) {
|
||||
block, _ := pem.Decode(certPEM)
|
||||
if block == nil {
|
||||
return "", fmt.Errorf("failed to decode cert PEM")
|
||||
}
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to parse cert: %w", err)
|
||||
}
|
||||
hex := fmt.Sprintf("%x", cert.SerialNumber)
|
||||
if len(hex)%2 == 1 {
|
||||
hex = "0" + hex
|
||||
}
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(hex); i += 2 {
|
||||
if i > 0 {
|
||||
b.WriteByte(':')
|
||||
}
|
||||
b.WriteString(hex[i : i+2])
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// serialsEqual compares two serial strings case-insensitively and
|
||||
// strips colon separators. Defends against ACM occasionally returning
|
||||
// serials in slightly different formats across SDK versions.
|
||||
func serialsEqual(a, b string) bool {
|
||||
norm := func(s string) string {
|
||||
return strings.ToLower(strings.ReplaceAll(s, ":", ""))
|
||||
}
|
||||
return norm(a) == norm(b)
|
||||
}
|
||||
|
||||
// metadataCertID extracts the certctl-managed certificate ID from the
|
||||
// deployment request's Metadata map. The renewal scheduler populates
|
||||
// this with the source-of-truth managed-cert row's ID; the connector
|
||||
// stamps it as the certctl-certificate-id provenance tag.
|
||||
func metadataCertID(metadata map[string]string) string {
|
||||
if v, ok := metadata["certificate_id"]; ok {
|
||||
return v
|
||||
}
|
||||
if v, ok := metadata["certctl_certificate_id"]; ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Compile-time assertion: *Connector implements target.Connector and
|
||||
// *sdkClient implements ACMClient. Catches interface drift at build
|
||||
// time rather than at first deploy.
|
||||
var (
|
||||
_ target.Connector = (*Connector)(nil)
|
||||
_ ACMClient = (*sdkClient)(nil)
|
||||
)
|
||||
@@ -0,0 +1,227 @@
|
||||
package awsacm_test
|
||||
|
||||
// Rank 5 of the 2026-05-03 Infisical deep-research deliverable
|
||||
// (cowork/infisical-deep-research-results.md Part 5). Per-error-class
|
||||
// failure tests for the AWS ACM target connector — mirrors the
|
||||
// awsacmpca_failure_test.go shape (commit 60dce0b) on the issuer side.
|
||||
//
|
||||
// Each test injects one specific AWS SDK v2 typed error via the
|
||||
// mockACMClient seam, calls DeployCertificate, and asserts:
|
||||
//
|
||||
// 1. error non-nil,
|
||||
// 2. errors.As against the SDK's typed error value succeeds (so the
|
||||
// wrap chain via fmt.Errorf("...%w", ...) is intact and upstream
|
||||
// retry / classification logic can introspect the typed value),
|
||||
// 3. operator-actionable substring is present in the surfaced
|
||||
// message,
|
||||
// 4. the failure category is correct (e.g. throttling = retryable;
|
||||
// validation = terminal).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
acmtypes "github.com/aws/aws-sdk-go-v2/service/acm/types"
|
||||
smithy "github.com/aws/smithy-go"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/target"
|
||||
"github.com/shankar0123/certctl/internal/connector/target/awsacm"
|
||||
)
|
||||
|
||||
// TestAWSACM_Issue_AccessDenied_OperatorActionableError pins the
|
||||
// behaviour when the IAM principal lacks acm:ImportCertificate. AWS
|
||||
// surfaces this as a smithy APIError with Code="AccessDeniedException"
|
||||
// (the ACM SDK does not generate a typed *types.AccessDeniedException
|
||||
// in v1.38.x — read it locally to confirm).
|
||||
func TestAWSACM_Issue_AccessDenied_OperatorActionableError(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
certPEM, keyPEM, _ := generateTestCert(t, "denied.example.com")
|
||||
|
||||
sdkErr := &smithy.GenericAPIError{
|
||||
Code: "AccessDeniedException",
|
||||
Message: "User: arn:aws:iam::123456789012:user/ci is not authorized to perform: acm:ImportCertificate",
|
||||
Fault: smithy.FaultClient,
|
||||
}
|
||||
mock := &mockACMClient{importErr: sdkErr}
|
||||
c := awsacm.NewWithClient(&awsacm.Config{Region: "us-east-1"}, mock, quietTestLogger())
|
||||
|
||||
_, err := c.DeployCertificate(ctx, target.DeploymentRequest{
|
||||
CertPEM: certPEM,
|
||||
KeyPEM: keyPEM,
|
||||
Metadata: map[string]string{"certificate_id": "mc-denied"},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected access-denied error, got nil")
|
||||
}
|
||||
|
||||
var sdk *smithy.GenericAPIError
|
||||
if !errors.As(err, &sdk) {
|
||||
t.Fatalf("wrap chain broke — errors.As against *smithy.GenericAPIError failed; err=%v", err)
|
||||
}
|
||||
if sdk.ErrorCode() != "AccessDeniedException" {
|
||||
t.Errorf("expected ErrorCode=AccessDeniedException, got %q", sdk.ErrorCode())
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "AccessDenied") {
|
||||
t.Errorf("operator-actionable substring missing — message must mention AccessDenied; got: %s", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "ImportCertificate failed") {
|
||||
t.Errorf("connector wrap missing — expected 'ImportCertificate failed: ...' framing; got: %s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAWSACM_Issue_ResourceNotFound_NamesTheMissingARN pins behaviour
|
||||
// when the configured CertificateArn doesn't exist (deleted out-of-
|
||||
// band, typo'd config, wrong region). The SDK's
|
||||
// *types.ResourceNotFoundException carries the ARN in its message; the
|
||||
// connector must preserve the ARN through the wrap chain so the
|
||||
// operator can identify which resource was missing.
|
||||
func TestAWSACM_Issue_ResourceNotFound_NamesTheMissingARN(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
certPEM, keyPEM, _ := generateTestCert(t, "missing.example.com")
|
||||
|
||||
missingArn := "arn:aws:acm:us-east-1:123456789012:certificate/deadbeef-dead-beef-dead-beefdeadbeef"
|
||||
sdkErr := &acmtypes.ResourceNotFoundException{
|
||||
Message: aws.String("Could not find certificate " + missingArn),
|
||||
}
|
||||
// We need the snapshot read to fail — the connector calls
|
||||
// GetCertificate first to capture the snapshot. ResourceNotFound at
|
||||
// snapshot time means "no cert at this ARN," so we surface that
|
||||
// error and bail before the import.
|
||||
mock := &mockACMClient{getErr: sdkErr}
|
||||
cfg := &awsacm.Config{Region: "us-east-1", CertificateArn: missingArn}
|
||||
c := awsacm.NewWithClient(cfg, mock, quietTestLogger())
|
||||
|
||||
_, err := c.DeployCertificate(ctx, target.DeploymentRequest{
|
||||
CertPEM: certPEM,
|
||||
KeyPEM: keyPEM,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected resource-not-found error")
|
||||
}
|
||||
var sdk *acmtypes.ResourceNotFoundException
|
||||
if !errors.As(err, &sdk) {
|
||||
t.Fatalf("wrap chain broke — errors.As against *types.ResourceNotFoundException failed; err=%v", err)
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, missingArn) {
|
||||
t.Errorf("operator-actionable substring missing — message must name the missing ARN %q; got: %s", missingArn, msg)
|
||||
}
|
||||
if !strings.Contains(msg, "snapshot") {
|
||||
t.Errorf("expected 'snapshot' framing on pre-deploy snapshot failure; got: %s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAWSACM_Issue_Throttling_RetryableSurfacePreserved pins the
|
||||
// behaviour when ACM throttles a burst of imports (renewal storm,
|
||||
// bulk migration). Real traffic surfaces ThrottlingException via
|
||||
// *smithy.GenericAPIError; the connector must preserve the typed
|
||||
// value + Fault classification so any upstream retry layer can engage.
|
||||
// Per the spec's "no new retry logic" scope, the connector itself
|
||||
// does not retry; it surfaces the typed error.
|
||||
func TestAWSACM_Issue_Throttling_RetryableSurfacePreserved(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
certPEM, keyPEM, _ := generateTestCert(t, "throttle.example.com")
|
||||
|
||||
sdkErr := &smithy.GenericAPIError{
|
||||
Code: "ThrottlingException",
|
||||
Message: "Rate exceeded",
|
||||
Fault: smithy.FaultServer,
|
||||
}
|
||||
mock := &mockACMClient{importErr: sdkErr}
|
||||
c := awsacm.NewWithClient(&awsacm.Config{Region: "us-east-1"}, mock, quietTestLogger())
|
||||
|
||||
_, err := c.DeployCertificate(ctx, target.DeploymentRequest{
|
||||
CertPEM: certPEM,
|
||||
KeyPEM: keyPEM,
|
||||
Metadata: map[string]string{"certificate_id": "mc-throttle"},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected throttle error")
|
||||
}
|
||||
var sdk *smithy.GenericAPIError
|
||||
if !errors.As(err, &sdk) {
|
||||
t.Fatalf("wrap chain broke; err=%v", err)
|
||||
}
|
||||
if sdk.ErrorCode() != "ThrottlingException" {
|
||||
t.Errorf("expected ErrorCode=ThrottlingException, got %q", sdk.ErrorCode())
|
||||
}
|
||||
if sdk.ErrorFault() != smithy.FaultServer {
|
||||
t.Errorf("expected FaultServer (retryable class) preserved; got %v", sdk.ErrorFault())
|
||||
}
|
||||
if !strings.Contains(err.Error(), "Throttling") {
|
||||
t.Errorf("operator-actionable substring missing — message must mention Throttling; got: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAWSACM_Issue_InvalidArgs_TerminalNotRetryable pins behaviour
|
||||
// when ACM rejects the cert+key as malformed (mismatched key/cert,
|
||||
// unsupported algorithm). InvalidArgsException is a terminal class —
|
||||
// operators must fix the inputs, not retry.
|
||||
func TestAWSACM_Issue_InvalidArgs_TerminalNotRetryable(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
certPEM, keyPEM, _ := generateTestCert(t, "invalid.example.com")
|
||||
|
||||
sdkErr := &acmtypes.InvalidArgsException{
|
||||
Message: aws.String("The certificate body is invalid: chain not bound to leaf"),
|
||||
}
|
||||
mock := &mockACMClient{importErr: sdkErr}
|
||||
c := awsacm.NewWithClient(&awsacm.Config{Region: "us-east-1"}, mock, quietTestLogger())
|
||||
|
||||
_, err := c.DeployCertificate(ctx, target.DeploymentRequest{
|
||||
CertPEM: certPEM,
|
||||
KeyPEM: keyPEM,
|
||||
Metadata: map[string]string{"certificate_id": "mc-invalid"},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid-args error")
|
||||
}
|
||||
var sdk *acmtypes.InvalidArgsException
|
||||
if !errors.As(err, &sdk) {
|
||||
t.Fatalf("wrap chain broke; err=%v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "chain not bound") {
|
||||
t.Errorf("operator-actionable substring missing — message must name the validation issue; got: %s", err.Error())
|
||||
}
|
||||
if !strings.Contains(err.Error(), "InvalidArgs") {
|
||||
t.Errorf("expected InvalidArgs in surfaced message; got: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAWSACM_Issue_RequestInProgress_TerminalForCurrentAttempt pins
|
||||
// behaviour when ACM reports an in-flight request for the same
|
||||
// idempotency key. RequestInProgressException IS a generated typed
|
||||
// value; the connector must surface it cleanly so upstream logic can
|
||||
// decide whether to wait + retry or fail-fast.
|
||||
func TestAWSACM_Issue_RequestInProgress_TerminalForCurrentAttempt(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
certPEM, keyPEM, _ := generateTestCert(t, "inprogress.example.com")
|
||||
|
||||
sdkErr := &acmtypes.RequestInProgressException{
|
||||
Message: aws.String("The certificate request is already being processed; resubmit after completion"),
|
||||
}
|
||||
mock := &mockACMClient{importErr: sdkErr}
|
||||
c := awsacm.NewWithClient(&awsacm.Config{Region: "us-east-1"}, mock, quietTestLogger())
|
||||
|
||||
_, err := c.DeployCertificate(ctx, target.DeploymentRequest{
|
||||
CertPEM: certPEM,
|
||||
KeyPEM: keyPEM,
|
||||
Metadata: map[string]string{"certificate_id": "mc-inprogress"},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected request-in-progress error")
|
||||
}
|
||||
var sdk *acmtypes.RequestInProgressException
|
||||
if !errors.As(err, &sdk) {
|
||||
t.Fatalf("wrap chain broke; err=%v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "RequestInProgress") {
|
||||
t.Errorf("expected RequestInProgress in surfaced message; got: %s", err.Error())
|
||||
}
|
||||
if !strings.Contains(err.Error(), "already being processed") {
|
||||
t.Errorf("operator-actionable substring missing — message must explain the conflict; got: %s", err.Error())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
package awsacm_test
|
||||
|
||||
// Rank 5 of the 2026-05-03 Infisical deep-research deliverable
|
||||
// (cowork/infisical-deep-research-results.md Part 5). Happy-path table-
|
||||
// driven tests for the AWS ACM target connector. Mirrors the
|
||||
// k8ssecret_test.go ergonomics + the Bundle 5+ atomic-rollback
|
||||
// assertions from IIS / WinCertStore / JavaKeystore.
|
||||
//
|
||||
// Per-error-class failure tests live in awsacm_failure_test.go and
|
||||
// follow the awsacmpca_failure_test.go shape (commit 60dce0b).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/target"
|
||||
"github.com/shankar0123/certctl/internal/connector/target/awsacm"
|
||||
)
|
||||
|
||||
// mockACMClient is the unit-test fake for the ACMClient interface seam.
|
||||
// Every method records the input it received so tests can assert on
|
||||
// "did the connector call DescribeCertificate twice for the post-verify
|
||||
// step?" / "what tags did the second AddTagsToCertificate call use?".
|
||||
//
|
||||
// Each method has a corresponding *Err field so a test can inject a
|
||||
// failure on the specific call it cares about while leaving the others
|
||||
// healthy.
|
||||
type mockACMClient struct {
|
||||
mu sync.Mutex
|
||||
|
||||
// Per-call recording.
|
||||
importCalls []*awsacm.ImportCertificateInput
|
||||
getCalls []*awsacm.GetCertificateInput
|
||||
describeCalls []*awsacm.DescribeCertificateInput
|
||||
listCalls []*awsacm.ListCertificatesInput
|
||||
tagCalls []*awsacm.AddTagsToCertificateInput
|
||||
|
||||
// Per-method canned responses + error injection. Each test case
|
||||
// constructs the mock with whatever shape it needs.
|
||||
importOutput *awsacm.ImportCertificateOutput
|
||||
importErr error
|
||||
getOutput *awsacm.GetCertificateOutput
|
||||
getErr error
|
||||
describeOutput *awsacm.DescribeCertificateOutput
|
||||
describeErr error
|
||||
listOutput *awsacm.ListCertificatesOutput
|
||||
listErr error
|
||||
tagErr error
|
||||
|
||||
// rollbackHook lets tests inject a different importErr on the
|
||||
// SECOND ImportCertificate call (the rollback path) so the
|
||||
// "rollback also fails" branch can be exercised independently of
|
||||
// the first-import path. nil disables the hook.
|
||||
rollbackImportErr error
|
||||
}
|
||||
|
||||
func (m *mockACMClient) ImportCertificate(ctx context.Context, in *awsacm.ImportCertificateInput) (*awsacm.ImportCertificateOutput, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.importCalls = append(m.importCalls, in)
|
||||
if len(m.importCalls) > 1 && m.rollbackImportErr != nil {
|
||||
return nil, m.rollbackImportErr
|
||||
}
|
||||
if m.importErr != nil {
|
||||
return nil, m.importErr
|
||||
}
|
||||
if m.importOutput != nil {
|
||||
return m.importOutput, nil
|
||||
}
|
||||
// Default success path: echo the ARN if the caller supplied one,
|
||||
// otherwise synthesise a fresh ARN deterministically so tests can
|
||||
// assert on it.
|
||||
arn := in.CertificateArn
|
||||
if arn == "" {
|
||||
arn = "arn:aws:acm:us-east-1:123456789012:certificate/abcdef01-2345-6789-abcd-ef0123456789"
|
||||
}
|
||||
return &awsacm.ImportCertificateOutput{CertificateArn: arn}, nil
|
||||
}
|
||||
|
||||
func (m *mockACMClient) GetCertificate(ctx context.Context, in *awsacm.GetCertificateInput) (*awsacm.GetCertificateOutput, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.getCalls = append(m.getCalls, in)
|
||||
if m.getErr != nil {
|
||||
return nil, m.getErr
|
||||
}
|
||||
if m.getOutput != nil {
|
||||
return m.getOutput, nil
|
||||
}
|
||||
return &awsacm.GetCertificateOutput{}, nil
|
||||
}
|
||||
|
||||
func (m *mockACMClient) DescribeCertificate(ctx context.Context, in *awsacm.DescribeCertificateInput) (*awsacm.DescribeCertificateOutput, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.describeCalls = append(m.describeCalls, in)
|
||||
if m.describeErr != nil {
|
||||
return nil, m.describeErr
|
||||
}
|
||||
if m.describeOutput != nil {
|
||||
return m.describeOutput, nil
|
||||
}
|
||||
return &awsacm.DescribeCertificateOutput{}, nil
|
||||
}
|
||||
|
||||
func (m *mockACMClient) ListCertificates(ctx context.Context, in *awsacm.ListCertificatesInput) (*awsacm.ListCertificatesOutput, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.listCalls = append(m.listCalls, in)
|
||||
if m.listErr != nil {
|
||||
return nil, m.listErr
|
||||
}
|
||||
if m.listOutput != nil {
|
||||
return m.listOutput, nil
|
||||
}
|
||||
return &awsacm.ListCertificatesOutput{}, nil
|
||||
}
|
||||
|
||||
func (m *mockACMClient) AddTagsToCertificate(ctx context.Context, in *awsacm.AddTagsToCertificateInput) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.tagCalls = append(m.tagCalls, in)
|
||||
return m.tagErr
|
||||
}
|
||||
|
||||
func quietTestLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||
}
|
||||
|
||||
// generateTestCert constructs a self-signed ECDSA P-256 cert for tests.
|
||||
// Returns (certPEM, keyPEM, serialFormatted) where serialFormatted
|
||||
// matches the colon-separated lowercase-hex shape ACM emits via
|
||||
// DescribeCertificate.
|
||||
func generateTestCert(t *testing.T, cn string) (certPEM, keyPEM, serial string) {
|
||||
t.Helper()
|
||||
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate key: %v", err)
|
||||
}
|
||||
serialNum, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: serialNum,
|
||||
Subject: pkix.Name{CommonName: cn},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
BasicConstraintsValid: true,
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv)
|
||||
if err != nil {
|
||||
t.Fatalf("create cert: %v", err)
|
||||
}
|
||||
certPEM = string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}))
|
||||
keyDER, err := x509.MarshalECPrivateKey(priv)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal key: %v", err)
|
||||
}
|
||||
keyPEM = string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}))
|
||||
|
||||
// Format serial as colon-separated lowercase hex (ACM's output shape).
|
||||
hex := fmt.Sprintf("%x", serialNum)
|
||||
if len(hex)%2 == 1 {
|
||||
hex = "0" + hex
|
||||
}
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(hex); i += 2 {
|
||||
if i > 0 {
|
||||
b.WriteByte(':')
|
||||
}
|
||||
b.WriteString(hex[i : i+2])
|
||||
}
|
||||
serial = b.String()
|
||||
return
|
||||
}
|
||||
|
||||
func TestAWSACM_ValidateConfig_Success(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
c := awsacm.NewWithClient(nil, &mockACMClient{}, quietTestLogger())
|
||||
cfg := awsacm.Config{Region: "us-east-1"}
|
||||
raw, _ := json.Marshal(cfg)
|
||||
if err := c.ValidateConfig(ctx, raw); err != nil {
|
||||
t.Fatalf("ValidateConfig: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWSACM_ValidateConfig_MissingRegion(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
c := awsacm.NewWithClient(nil, &mockACMClient{}, quietTestLogger())
|
||||
raw, _ := json.Marshal(awsacm.Config{})
|
||||
err := c.ValidateConfig(ctx, raw)
|
||||
if err == nil || !strings.Contains(err.Error(), "region is required") {
|
||||
t.Errorf("expected region-required error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWSACM_ValidateConfig_MalformedRegion(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
c := awsacm.NewWithClient(nil, &mockACMClient{}, quietTestLogger())
|
||||
raw, _ := json.Marshal(awsacm.Config{Region: "not-a-region"})
|
||||
err := c.ValidateConfig(ctx, raw)
|
||||
if err == nil || !strings.Contains(err.Error(), "region malformed") {
|
||||
t.Errorf("expected region-malformed error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWSACM_ValidateConfig_MalformedARN(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
c := awsacm.NewWithClient(nil, &mockACMClient{}, quietTestLogger())
|
||||
raw, _ := json.Marshal(awsacm.Config{Region: "us-east-1", CertificateArn: "not-an-arn"})
|
||||
err := c.ValidateConfig(ctx, raw)
|
||||
if err == nil || !strings.Contains(err.Error(), "certificate_arn malformed") {
|
||||
t.Errorf("expected ARN-malformed error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWSACM_ValidateConfig_RejectsReservedTags(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
c := awsacm.NewWithClient(nil, &mockACMClient{}, quietTestLogger())
|
||||
raw, _ := json.Marshal(awsacm.Config{
|
||||
Region: "us-east-1",
|
||||
Tags: map[string]string{"certctl-managed-by": "operator-spoofed"},
|
||||
})
|
||||
err := c.ValidateConfig(ctx, raw)
|
||||
if err == nil || !strings.Contains(err.Error(), "reserved provenance key") {
|
||||
t.Errorf("expected reserved-key rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWSACM_DeployCertificate_FreshImport(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
certPEM, keyPEM, serial := generateTestCert(t, "fresh.example.com")
|
||||
|
||||
mock := &mockACMClient{
|
||||
describeOutput: &awsacm.DescribeCertificateOutput{Serial: serial, Status: "ISSUED"},
|
||||
}
|
||||
cfg := &awsacm.Config{Region: "us-east-1"}
|
||||
c := awsacm.NewWithClient(cfg, mock, quietTestLogger())
|
||||
|
||||
res, err := c.DeployCertificate(ctx, target.DeploymentRequest{
|
||||
CertPEM: certPEM,
|
||||
KeyPEM: keyPEM,
|
||||
ChainPEM: "",
|
||||
Metadata: map[string]string{"certificate_id": "mc-fresh"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("DeployCertificate: %v", err)
|
||||
}
|
||||
if !res.Success {
|
||||
t.Errorf("expected Success=true")
|
||||
}
|
||||
if res.TargetAddress == "" {
|
||||
t.Errorf("expected TargetAddress (ARN) populated")
|
||||
}
|
||||
if len(mock.importCalls) != 1 {
|
||||
t.Errorf("expected exactly 1 ImportCertificate call (fresh import), got %d", len(mock.importCalls))
|
||||
}
|
||||
// Fresh import: tags MUST be supplied on the ImportCertificate call
|
||||
// (ACM strips them on re-import; this is the only window).
|
||||
if len(mock.importCalls[0].Tags) < 2 {
|
||||
t.Errorf("expected provenance tags (managed-by + cert-id) on fresh import; got %d tags", len(mock.importCalls[0].Tags))
|
||||
}
|
||||
// No AddTagsToCertificate on fresh import.
|
||||
if len(mock.tagCalls) != 0 {
|
||||
t.Errorf("fresh import should not call AddTagsToCertificate, got %d", len(mock.tagCalls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWSACM_DeployCertificate_RotateInPlace(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
certPEM, keyPEM, serial := generateTestCert(t, "rotate.example.com")
|
||||
|
||||
existingARN := "arn:aws:acm:us-east-1:123456789012:certificate/00000000-1111-2222-3333-444444444444"
|
||||
mock := &mockACMClient{
|
||||
// Pre-deploy snapshot returns the previous cert bytes (we use
|
||||
// a random self-signed for the snapshot).
|
||||
getOutput: &awsacm.GetCertificateOutput{
|
||||
Certificate: []byte("snapshot-cert-pem"),
|
||||
},
|
||||
describeOutput: &awsacm.DescribeCertificateOutput{Serial: serial, Status: "ISSUED"},
|
||||
}
|
||||
cfg := &awsacm.Config{Region: "us-east-1", CertificateArn: existingARN}
|
||||
c := awsacm.NewWithClient(cfg, mock, quietTestLogger())
|
||||
|
||||
res, err := c.DeployCertificate(ctx, target.DeploymentRequest{
|
||||
CertPEM: certPEM,
|
||||
KeyPEM: keyPEM,
|
||||
Metadata: map[string]string{"certificate_id": "mc-rotate"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("DeployCertificate: %v", err)
|
||||
}
|
||||
if res.TargetAddress != existingARN {
|
||||
t.Errorf("rotate-in-place should preserve ARN; expected %s, got %s", existingARN, res.TargetAddress)
|
||||
}
|
||||
// Rotate-in-place: snapshot read happens before import.
|
||||
if len(mock.getCalls) != 1 {
|
||||
t.Errorf("expected pre-deploy GetCertificate; got %d calls", len(mock.getCalls))
|
||||
}
|
||||
// Tags re-applied via AddTagsToCertificate (re-import strips them).
|
||||
if len(mock.tagCalls) != 1 {
|
||||
t.Errorf("rotate-in-place should AddTagsToCertificate once; got %d", len(mock.tagCalls))
|
||||
}
|
||||
// Import call MUST carry the existing ARN.
|
||||
if mock.importCalls[0].CertificateArn != existingARN {
|
||||
t.Errorf("rotate import should target existing ARN; got %q", mock.importCalls[0].CertificateArn)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWSACM_DeployCertificate_RollbackOnSerialMismatch(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
certPEM, keyPEM, _ := generateTestCert(t, "mismatch.example.com")
|
||||
|
||||
existingARN := "arn:aws:acm:us-east-1:123456789012:certificate/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
|
||||
mock := &mockACMClient{
|
||||
getOutput: &awsacm.GetCertificateOutput{Certificate: []byte("snapshot-bytes")},
|
||||
// Post-verify returns a DIFFERENT serial than the imported cert
|
||||
// — triggers rollback.
|
||||
describeOutput: &awsacm.DescribeCertificateOutput{
|
||||
Serial: "ff:ff:ff:ff", // intentionally wrong
|
||||
},
|
||||
}
|
||||
cfg := &awsacm.Config{Region: "us-east-1", CertificateArn: existingARN}
|
||||
c := awsacm.NewWithClient(cfg, mock, quietTestLogger())
|
||||
|
||||
_, err := c.DeployCertificate(ctx, target.DeploymentRequest{
|
||||
CertPEM: certPEM,
|
||||
KeyPEM: keyPEM,
|
||||
Metadata: map[string]string{"certificate_id": "mc-mismatch"},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error on serial mismatch")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "rolled back") {
|
||||
t.Errorf("expected error to mention rollback; got %v", err)
|
||||
}
|
||||
// Two ImportCertificate calls: the failing import + the rollback
|
||||
// re-import with snapshot bytes.
|
||||
if len(mock.importCalls) != 2 {
|
||||
t.Errorf("expected 2 ImportCertificate calls (initial + rollback); got %d", len(mock.importCalls))
|
||||
}
|
||||
// The second import MUST carry the snapshot bytes.
|
||||
if string(mock.importCalls[1].Certificate) != "snapshot-bytes" {
|
||||
t.Errorf("rollback should re-import snapshot bytes; got %q", string(mock.importCalls[1].Certificate))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWSACM_DeployCertificate_RollbackAlsoFails(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
certPEM, keyPEM, _ := generateTestCert(t, "twofail.example.com")
|
||||
|
||||
existingARN := "arn:aws:acm:us-east-1:123456789012:certificate/cccccccc-dddd-eeee-ffff-000000000000"
|
||||
mock := &mockACMClient{
|
||||
getOutput: &awsacm.GetCertificateOutput{Certificate: []byte("snapshot")},
|
||||
describeOutput: &awsacm.DescribeCertificateOutput{Serial: "00:00"},
|
||||
rollbackImportErr: errors.New("simulated rollback ImportCertificate failure"),
|
||||
}
|
||||
cfg := &awsacm.Config{Region: "us-east-1", CertificateArn: existingARN}
|
||||
c := awsacm.NewWithClient(cfg, mock, quietTestLogger())
|
||||
|
||||
_, err := c.DeployCertificate(ctx, target.DeploymentRequest{
|
||||
CertPEM: certPEM,
|
||||
KeyPEM: keyPEM,
|
||||
Metadata: map[string]string{"certificate_id": "mc-twofail"},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error on rollback failure")
|
||||
}
|
||||
// We still surfaced the original mismatch error to the caller; the
|
||||
// rollback failure logs but doesn't change the surfaced error
|
||||
// shape. Ensure we attempted the rollback (2 import calls).
|
||||
if len(mock.importCalls) != 2 {
|
||||
t.Errorf("expected 2 import calls including failed rollback; got %d", len(mock.importCalls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWSACM_DeployCertificate_EmptyKeyPEM(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
certPEM, _, _ := generateTestCert(t, "nokey.example.com")
|
||||
cfg := &awsacm.Config{Region: "us-east-1"}
|
||||
c := awsacm.NewWithClient(cfg, &mockACMClient{}, quietTestLogger())
|
||||
|
||||
_, err := c.DeployCertificate(ctx, target.DeploymentRequest{
|
||||
CertPEM: certPEM,
|
||||
KeyPEM: "",
|
||||
Metadata: map[string]string{"certificate_id": "mc-nokey"},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "key_pem is required") {
|
||||
t.Errorf("expected key-required error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWSACM_DeployCertificate_NoClient(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cfg := &awsacm.Config{Region: "us-east-1"}
|
||||
c := awsacm.NewWithClient(cfg, nil, quietTestLogger())
|
||||
_, err := c.DeployCertificate(ctx, target.DeploymentRequest{CertPEM: "x", KeyPEM: "y"})
|
||||
if err == nil || !strings.Contains(err.Error(), "client not initialized") {
|
||||
t.Errorf("expected client-not-initialized; got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWSACM_ValidateOnly_NotSupported(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
c := awsacm.NewWithClient(&awsacm.Config{Region: "us-east-1"}, &mockACMClient{}, quietTestLogger())
|
||||
err := c.ValidateOnly(ctx, target.DeploymentRequest{})
|
||||
if !errors.Is(err, target.ErrValidateOnlyNotSupported) {
|
||||
t.Errorf("expected ErrValidateOnlyNotSupported, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWSACM_ValidateDeployment_SerialMatch(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mock := &mockACMClient{
|
||||
describeOutput: &awsacm.DescribeCertificateOutput{Serial: "ab:cd:01"},
|
||||
}
|
||||
arn := "arn:aws:acm:us-east-1:123456789012:certificate/11111111-2222-3333-4444-555555555555"
|
||||
cfg := &awsacm.Config{Region: "us-east-1", CertificateArn: arn}
|
||||
c := awsacm.NewWithClient(cfg, mock, quietTestLogger())
|
||||
|
||||
res, err := c.ValidateDeployment(ctx, target.ValidationRequest{Serial: "ab:cd:01"})
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateDeployment: %v", err)
|
||||
}
|
||||
if !res.Valid {
|
||||
t.Errorf("expected Valid=true, got %+v", res)
|
||||
}
|
||||
if res.TargetAddress != arn {
|
||||
t.Errorf("expected TargetAddress=%s, got %s", arn, res.TargetAddress)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWSACM_ValidateDeployment_SerialMismatch(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mock := &mockACMClient{
|
||||
describeOutput: &awsacm.DescribeCertificateOutput{Serial: "00:00"},
|
||||
}
|
||||
arn := "arn:aws:acm:us-east-1:123456789012:certificate/22222222-3333-4444-5555-666666666666"
|
||||
cfg := &awsacm.Config{Region: "us-east-1", CertificateArn: arn}
|
||||
c := awsacm.NewWithClient(cfg, mock, quietTestLogger())
|
||||
|
||||
res, err := c.ValidateDeployment(ctx, target.ValidationRequest{Serial: "ab:cd:01"})
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateDeployment unexpected error: %v", err)
|
||||
}
|
||||
if res.Valid {
|
||||
t.Errorf("expected Valid=false on serial mismatch, got %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWSACM_ValidateDeployment_NoARNYet(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cfg := &awsacm.Config{Region: "us-east-1"}
|
||||
c := awsacm.NewWithClient(cfg, &mockACMClient{}, quietTestLogger())
|
||||
res, err := c.ValidateDeployment(ctx, target.ValidationRequest{Serial: "ab:cd"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if res.Valid {
|
||||
t.Errorf("expected Valid=false when ARN not yet known")
|
||||
}
|
||||
if !strings.Contains(res.Message, "ARN not yet known") {
|
||||
t.Errorf("expected ARN-not-yet-known message; got %q", res.Message)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user