mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-12 06:28:52 +00:00
edf6bee7f8
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.
228 lines
9.0 KiB
Go
228 lines
9.0 KiB
Go
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())
|
|
}
|
|
}
|