Created
June 5, 2026 13:09
-
-
Save syed/828463b8ebf3073020d098c79065e0b0 to your computer and use it in GitHub Desktop.
ACM certificate issuance perf test tool
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| package main | |
| import ( | |
| "context" | |
| "crypto/x509" | |
| "encoding/pem" | |
| "flag" | |
| "fmt" | |
| "log" | |
| "os" | |
| "strings" | |
| "sync" | |
| "time" | |
| "github.com/aws/aws-sdk-go-v2/aws" | |
| "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/aws/aws-sdk-go-v2/service/route53" | |
| r53types "github.com/aws/aws-sdk-go-v2/service/route53/types" | |
| ) | |
| type stepTimings struct { | |
| requestCert time.Duration | |
| waitValidation time.Duration | |
| createDNS time.Duration | |
| waitIssuance time.Duration | |
| retrieveCert time.Duration | |
| total time.Duration | |
| } | |
| type runResult struct { | |
| id int | |
| domain string | |
| timings stepTimings | |
| err error | |
| } | |
| func main() { | |
| domain := flag.String("domain", "", "Domain name for the certificate (required)") | |
| hostedZoneID := flag.String("hosted-zone-id", "", "Route 53 hosted zone ID (required)") | |
| region := flag.String("region", "us-east-1", "AWS region") | |
| pollInterval := flag.Duration("poll-interval", 10*time.Second, "Polling interval for certificate status") | |
| timeout := flag.Duration("timeout", 30*time.Minute, "Timeout waiting for certificate issuance") | |
| cleanup := flag.Bool("cleanup", false, "Delete certificate and DNS record after completion") | |
| parallel := flag.Int("parallel", 1, "Number of parallel certificate requests") | |
| flag.Parse() | |
| if *domain == "" || *hostedZoneID == "" { | |
| flag.Usage() | |
| os.Exit(1) | |
| } | |
| ctx := context.Background() | |
| cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion(*region)) | |
| if err != nil { | |
| log.Fatalf("unable to load AWS config: %v", err) | |
| } | |
| acmClient := acm.NewFromConfig(cfg) | |
| r53Client := route53.NewFromConfig(cfg) | |
| if *parallel == 1 { | |
| result := runSingle(ctx, acmClient, r53Client, 0, *domain, *hostedZoneID, *pollInterval, *timeout, *cleanup, true) | |
| if result.err != nil { | |
| log.Fatalf("certificate run failed: %v", result.err) | |
| } | |
| printSingleSummary(result) | |
| return | |
| } | |
| // Parallel mode | |
| fmt.Printf("=== Starting %d parallel certificate requests ===\n\n", *parallel) | |
| overallStart := time.Now() | |
| var wg sync.WaitGroup | |
| results := make([]runResult, *parallel) | |
| for i := 0; i < *parallel; i++ { | |
| wg.Add(1) | |
| go func(id int) { | |
| defer wg.Done() | |
| d := fmt.Sprintf("cert-test-%d.%s", id, strings.TrimPrefix(*domain, "cert-test.")) | |
| if !strings.HasPrefix(*domain, "cert-test.") { | |
| d = fmt.Sprintf("cert-test-%d.%s", id, *domain) | |
| } | |
| results[id] = runSingle(ctx, acmClient, r53Client, id, d, *hostedZoneID, *pollInterval, *timeout, *cleanup, false) | |
| }(i) | |
| } | |
| wg.Wait() | |
| overallDuration := time.Since(overallStart) | |
| printParallelSummary(results, overallDuration) | |
| } | |
| func runSingle(ctx context.Context, acmClient *acm.Client, r53Client *route53.Client, id int, domain, hostedZoneID string, pollInterval, timeout time.Duration, cleanup, verbose bool) runResult { | |
| prefix := fmt.Sprintf("[worker-%d]", id) | |
| logf := func(format string, args ...interface{}) { | |
| if verbose { | |
| fmt.Printf(format, args...) | |
| } else { | |
| fmt.Printf(prefix+" "+format, args...) | |
| } | |
| } | |
| var t stepTimings | |
| overallStart := time.Now() | |
| // Step 1: Request certificate | |
| logf("Requesting certificate for %s\n", domain) | |
| reqStart := time.Now() | |
| reqOutput, err := acmClient.RequestCertificate(ctx, &acm.RequestCertificateInput{ | |
| DomainName: &domain, | |
| ValidationMethod: acmtypes.ValidationMethodDns, | |
| Options: &acmtypes.CertificateOptions{ | |
| CertificateTransparencyLoggingPreference: acmtypes.CertificateTransparencyLoggingPreferenceEnabled, | |
| }, | |
| }) | |
| if err != nil { | |
| return runResult{id: id, domain: domain, err: fmt.Errorf("request certificate: %w", err)} | |
| } | |
| certArn := *reqOutput.CertificateArn | |
| t.requestCert = time.Since(reqStart) | |
| logf(" ARN: %s (%s)\n", certArn, t.requestCert.Round(time.Millisecond)) | |
| if cleanup { | |
| defer cleanupResources(ctx, acmClient, r53Client, certArn, hostedZoneID, prefix, verbose) | |
| } | |
| // Step 2: Wait for DNS validation details | |
| logf(" Waiting for validation details...\n") | |
| dnsWaitStart := time.Now() | |
| var validationRecord acmtypes.ResourceRecord | |
| for { | |
| descOutput, err := acmClient.DescribeCertificate(ctx, &acm.DescribeCertificateInput{ | |
| CertificateArn: &certArn, | |
| }) | |
| if err != nil { | |
| return runResult{id: id, domain: domain, err: fmt.Errorf("describe certificate: %w", err)} | |
| } | |
| if len(descOutput.Certificate.DomainValidationOptions) > 0 && | |
| descOutput.Certificate.DomainValidationOptions[0].ResourceRecord != nil { | |
| validationRecord = *descOutput.Certificate.DomainValidationOptions[0].ResourceRecord | |
| break | |
| } | |
| time.Sleep(2 * time.Second) | |
| } | |
| t.waitValidation = time.Since(dnsWaitStart) | |
| logf(" Validation details ready (%s)\n", t.waitValidation.Round(time.Millisecond)) | |
| // Step 3: Create Route 53 DNS record | |
| r53Start := time.Now() | |
| _, err = r53Client.ChangeResourceRecordSets(ctx, &route53.ChangeResourceRecordSetsInput{ | |
| HostedZoneId: &hostedZoneID, | |
| ChangeBatch: &r53types.ChangeBatch{ | |
| Changes: []r53types.Change{ | |
| { | |
| Action: r53types.ChangeActionUpsert, | |
| ResourceRecordSet: &r53types.ResourceRecordSet{ | |
| Name: validationRecord.Name, | |
| Type: r53types.RRTypeCname, | |
| TTL: aws.Int64(300), | |
| ResourceRecords: []r53types.ResourceRecord{ | |
| {Value: validationRecord.Value}, | |
| }, | |
| }, | |
| }, | |
| }, | |
| }, | |
| }) | |
| if err != nil { | |
| return runResult{id: id, domain: domain, err: fmt.Errorf("create route53 record: %w", err)} | |
| } | |
| t.createDNS = time.Since(r53Start) | |
| logf(" DNS record created (%s)\n", t.createDNS.Round(time.Millisecond)) | |
| // Step 4: Wait for certificate issuance | |
| logf(" Waiting for issuance...\n") | |
| issueStart := time.Now() | |
| deadline := time.After(timeout) | |
| var certStatus acmtypes.CertificateStatus | |
| for { | |
| select { | |
| case <-deadline: | |
| return runResult{id: id, domain: domain, err: fmt.Errorf("timeout after %s (last status: %s)", timeout, certStatus)} | |
| default: | |
| } | |
| descOutput, err := acmClient.DescribeCertificate(ctx, &acm.DescribeCertificateInput{ | |
| CertificateArn: &certArn, | |
| }) | |
| if err != nil { | |
| return runResult{id: id, domain: domain, err: fmt.Errorf("describe certificate: %w", err)} | |
| } | |
| certStatus = descOutput.Certificate.Status | |
| if certStatus == acmtypes.CertificateStatusIssued { | |
| break | |
| } | |
| if certStatus == acmtypes.CertificateStatusFailed { | |
| return runResult{id: id, domain: domain, err: fmt.Errorf("certificate failed: %s", descOutput.Certificate.FailureReason)} | |
| } | |
| time.Sleep(pollInterval) | |
| } | |
| t.waitIssuance = time.Since(issueStart) | |
| logf(" Certificate ISSUED (%s)\n", t.waitIssuance.Round(time.Millisecond)) | |
| // Step 5: Retrieve certificate | |
| getStart := time.Now() | |
| getCertOutput, err := acmClient.GetCertificate(ctx, &acm.GetCertificateInput{ | |
| CertificateArn: &certArn, | |
| }) | |
| if err != nil { | |
| return runResult{id: id, domain: domain, err: fmt.Errorf("get certificate: %w", err)} | |
| } | |
| t.retrieveCert = time.Since(getStart) | |
| t.total = time.Since(overallStart) | |
| if verbose { | |
| fmt.Println("--- Certificate ---") | |
| fmt.Println(*getCertOutput.Certificate) | |
| if getCertOutput.CertificateChain != nil { | |
| fmt.Println("--- Certificate Chain ---") | |
| fmt.Println(*getCertOutput.CertificateChain) | |
| } | |
| printCertDetails(*getCertOutput.Certificate) | |
| } | |
| logf(" Retrieved certificate (%s) | Total: %s\n", t.retrieveCert.Round(time.Millisecond), t.total.Round(time.Millisecond)) | |
| return runResult{id: id, domain: domain, timings: t} | |
| } | |
| func printSingleSummary(r runResult) { | |
| t := r.timings | |
| fmt.Println("\n========================================") | |
| fmt.Println(" TIMING SUMMARY") | |
| fmt.Println("========================================") | |
| fmt.Printf(" %-30s %s\n", "Request certificate", t.requestCert.Round(time.Millisecond)) | |
| fmt.Printf(" %-30s %s\n", "Wait for validation details", t.waitValidation.Round(time.Millisecond)) | |
| fmt.Printf(" %-30s %s\n", "Create Route 53 record", t.createDNS.Round(time.Millisecond)) | |
| fmt.Printf(" %-30s %s\n", "Wait for issuance", t.waitIssuance.Round(time.Millisecond)) | |
| fmt.Printf(" %-30s %s\n", "Retrieve certificate", t.retrieveCert.Round(time.Millisecond)) | |
| fmt.Println(" ----------------------------------------") | |
| fmt.Printf(" %-30s %s\n", "TOTAL (request to issuance)", t.total.Round(time.Millisecond)) | |
| fmt.Println("========================================") | |
| } | |
| func printParallelSummary(results []runResult, overallDuration time.Duration) { | |
| fmt.Println("\n========================================") | |
| fmt.Println(" PER-WORKER RESULTS") | |
| fmt.Println("========================================") | |
| var succeeded []runResult | |
| for _, r := range results { | |
| if r.err != nil { | |
| fmt.Printf(" worker-%d %-50s FAILED: %v\n", r.id, r.domain, r.err) | |
| } else { | |
| fmt.Printf(" worker-%d %-50s %s\n", r.id, r.domain, r.timings.total.Round(time.Millisecond)) | |
| succeeded = append(succeeded, r) | |
| } | |
| } | |
| if len(succeeded) == 0 { | |
| fmt.Println("\n No successful runs to average.") | |
| return | |
| } | |
| var avg stepTimings | |
| for _, r := range succeeded { | |
| avg.requestCert += r.timings.requestCert | |
| avg.waitValidation += r.timings.waitValidation | |
| avg.createDNS += r.timings.createDNS | |
| avg.waitIssuance += r.timings.waitIssuance | |
| avg.retrieveCert += r.timings.retrieveCert | |
| avg.total += r.timings.total | |
| } | |
| n := time.Duration(len(succeeded)) | |
| avg.requestCert /= n | |
| avg.waitValidation /= n | |
| avg.createDNS /= n | |
| avg.waitIssuance /= n | |
| avg.retrieveCert /= n | |
| avg.total /= n | |
| var minTotal, maxTotal time.Duration | |
| minTotal = succeeded[0].timings.total | |
| maxTotal = succeeded[0].timings.total | |
| for _, r := range succeeded[1:] { | |
| if r.timings.total < minTotal { | |
| minTotal = r.timings.total | |
| } | |
| if r.timings.total > maxTotal { | |
| maxTotal = r.timings.total | |
| } | |
| } | |
| fmt.Println("\n========================================") | |
| fmt.Printf(" AVERAGE TIMINGS (%d/%d succeeded)\n", len(succeeded), len(results)) | |
| fmt.Println("========================================") | |
| fmt.Printf(" %-30s %s\n", "Request certificate", avg.requestCert.Round(time.Millisecond)) | |
| fmt.Printf(" %-30s %s\n", "Wait for validation details", avg.waitValidation.Round(time.Millisecond)) | |
| fmt.Printf(" %-30s %s\n", "Create Route 53 record", avg.createDNS.Round(time.Millisecond)) | |
| fmt.Printf(" %-30s %s\n", "Wait for issuance", avg.waitIssuance.Round(time.Millisecond)) | |
| fmt.Printf(" %-30s %s\n", "Retrieve certificate", avg.retrieveCert.Round(time.Millisecond)) | |
| fmt.Println(" ----------------------------------------") | |
| fmt.Printf(" %-30s %s\n", "AVG total per cert", avg.total.Round(time.Millisecond)) | |
| fmt.Printf(" %-30s %s\n", "MIN total", minTotal.Round(time.Millisecond)) | |
| fmt.Printf(" %-30s %s\n", "MAX total", maxTotal.Round(time.Millisecond)) | |
| fmt.Printf(" %-30s %s\n", "Wall clock (all parallel)", overallDuration.Round(time.Millisecond)) | |
| fmt.Println("========================================") | |
| } | |
| func printCertDetails(certPEM string) { | |
| block, _ := pem.Decode([]byte(certPEM)) | |
| if block == nil { | |
| return | |
| } | |
| cert, err := x509.ParseCertificate(block.Bytes) | |
| if err != nil { | |
| return | |
| } | |
| fmt.Println("\n--- Certificate Details ---") | |
| fmt.Printf(" Subject: %s\n", cert.Subject.CommonName) | |
| fmt.Printf(" Issuer: %s\n", cert.Issuer.CommonName) | |
| fmt.Printf(" Serial: %s\n", cert.SerialNumber) | |
| fmt.Printf(" Not Before: %s\n", cert.NotBefore) | |
| fmt.Printf(" Not After: %s\n", cert.NotAfter) | |
| if len(cert.DNSNames) > 0 { | |
| fmt.Printf(" SANs: %s\n", strings.Join(cert.DNSNames, ", ")) | |
| } | |
| } | |
| func cleanupResources(ctx context.Context, acmClient *acm.Client, r53Client *route53.Client, certArn, hostedZoneID, prefix string, verbose bool) { | |
| logf := func(format string, args ...interface{}) { | |
| if verbose { | |
| fmt.Printf(format, args...) | |
| } else { | |
| fmt.Printf(prefix+" "+format, args...) | |
| } | |
| } | |
| logf("Cleanup: deleting cert %s\n", certArn) | |
| descOutput, err := acmClient.DescribeCertificate(ctx, &acm.DescribeCertificateInput{ | |
| CertificateArn: &certArn, | |
| }) | |
| if err == nil && len(descOutput.Certificate.DomainValidationOptions) > 0 { | |
| rr := descOutput.Certificate.DomainValidationOptions[0].ResourceRecord | |
| if rr != nil { | |
| _, err = r53Client.ChangeResourceRecordSets(ctx, &route53.ChangeResourceRecordSetsInput{ | |
| HostedZoneId: &hostedZoneID, | |
| ChangeBatch: &r53types.ChangeBatch{ | |
| Changes: []r53types.Change{ | |
| { | |
| Action: r53types.ChangeActionDelete, | |
| ResourceRecordSet: &r53types.ResourceRecordSet{ | |
| Name: rr.Name, | |
| Type: r53types.RRTypeCname, | |
| TTL: aws.Int64(300), | |
| ResourceRecords: []r53types.ResourceRecord{ | |
| {Value: rr.Value}, | |
| }, | |
| }, | |
| }, | |
| }, | |
| }, | |
| }) | |
| if err != nil { | |
| logf(" Warning: failed to delete DNS record: %v\n", err) | |
| } else { | |
| logf(" Deleted DNS validation record\n") | |
| } | |
| } | |
| } | |
| _, err = acmClient.DeleteCertificate(ctx, &acm.DeleteCertificateInput{ | |
| CertificateArn: &certArn, | |
| }) | |
| if err != nil { | |
| logf(" Warning: failed to delete certificate: %v\n", err) | |
| } else { | |
| logf(" Deleted certificate\n") | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment