AWS SDK Go v2 PrivateLink inventory — paginated DescribeVpcEndpoints
LoadDefaultConfig. Filter Interface. Paginate. WARN on single-AZ and Private DNS off.
<!-- hal:authoritative:yaml -->
*LoadDefaultConfig. Paginate DescribeVpcEndpoints. Keep Interface rows. Print service, Private DNS, subnet count. Do not mutate endpoints.*
§I — Frame
Saturday's Go lesson listed S3 keys on a Terraform state bucket with SDK v2. Sunday's Go lesson inventoried IRSA annotations with client-go. Today the quatro theme is PrivateLink. DevOps-Go returns to AWS SDK for Go v2 on the EC2 API the Ops census already uses in Python.
This is inventory, not terratest. No terraform.Apply. No apply-wait-destroy loop. Read-only ec2:DescribeVpcEndpoints (and optional DescribeSubnets) under the same config chain as the AWS CLI.
Bootcamp PrivateLink notes define why the fields matter. This lesson shows how to page them in Go without loading the entire account into one slice blindly.
§II — Foundations: five SDK facts
Fact one. Config chain matches the CLI.
config.LoadDefaultConfig(ctx) resolves env vars, shared config, and IAM roles the same way the CLI does. Pin the region explicitly when the binary runs in automation.
Fact two. Paginators beat hand-rolled NextToken loops.
ec2.NewDescribeVpcEndpointsPaginator yields pages until HasMorePages is false. Prefer it for fleet accounts with many endpoints.
Fact three. Filter server-side when you can.
Pass Filters for vpc-endpoint-type = Interface so Gateway endpoints never enter the binary's hot path.
Fact four. Pointer fields are normal in AWS SDK v2.
Generated API shapes use *string and helpers like aws.ToString. Defensive nil checks keep inventory from panicking on sparse rows.
Fact five. Inventory prints; it does not remediate.
WARN lines for single-AZ or Private DNS off are signals for humans or a separate change pipeline. This binary exits nonzero only on API errors, not on WARN counts, unless you deliberately add a --strict flag later.
§III — Worked inventory
package main
import (
"context"
"fmt"
"log"
"os"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/ec2"
"github.com/aws/aws-sdk-go-v2/service/ec2/types"
)
func main() {
ctx := context.Background()
region := envOr("AWS_REGION", "us-east-1")
cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion(region))
if err != nil {
log.Fatalf("config: %v", err)
}
client := ec2.NewFromConfig(cfg)
paginator := ec2.NewDescribeVpcEndpointsPaginator(client, &ec2.DescribeVpcEndpointsInput{
Filters: []types.Filter{{
Name: aws.String("vpc-endpoint-type"),
Values: []string{"Interface"},
}},
})
var n, warn int
for paginator.HasMorePages() {
page, err := paginator.NextPage(ctx)
if err != nil {
log.Fatalf("describe: %v", err)
}
for _, ep := range page.VpcEndpoints {
n++
id := aws.ToString(ep.VpcEndpointId)
svc := aws.ToString(ep.ServiceName)
vpc := aws.ToString(ep.VpcId)
dns := ep.PrivateDnsEnabled != nil && *ep.PrivateDnsEnabled
subnets := len(ep.SubnetIds)
sgs := len(ep.Groups)
fmt.Printf("%s\tservice=%s\tvpc=%s\tprivateDNS=%t\tsubnets=%d\tsgs=%d\tstate=%s\n",
id, svc, vpc, dns, subnets, sgs, string(ep.State))
if subnets < 2 {
fmt.Fprintf(os.Stderr, "WARN single-AZ-ish endpoint %s service=%s subnets=%d\n", id, svc, subnets)
warn++
}
if !dns {
fmt.Fprintf(os.Stderr, "WARN PrivateDNS off %s service=%s\n", id, svc)
warn++
}
}
}
fmt.Fprintf(os.Stderr, "endpoints=%d warns=%d region=%s\n", n, warn, region)
}
func envOr(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
Build with module deps on aws-sdk-go-v2, config, and service/ec2. Run under a read-only role.
§III.B — Optional AZ resolution
Subnet count is a proxy. For true AZ sets, batch DescribeSubnets with the union of subnet IDs (chunks of 100), map ID → AZ, then recompute unique AZs per endpoint. Keep that in a second function so the happy path stays one paginator loop.
func azSet(ctx context.Context, client *ec2.Client, subnetIDs []string) (map[string]string, error) {
out := map[string]string{}
for i := 0; i < len(subnetIDs); i += 100 {
j := i + 100
if j > len(subnetIDs) {
j = len(subnetIDs)
}
resp, err := client.DescribeSubnets(ctx, &ec2.DescribeSubnetsInput{
SubnetIds: subnetIDs[i:j],
})
if err != nil {
return nil, err
}
for _, sn := range resp.Subnets {
out[aws.ToString(sn.SubnetId)] = aws.ToString(sn.AvailabilityZone)
}
}
return out, nil
}
§III.C — What this binary refuses
Do not call CreateVpcEndpoint or ModifyVpcEndpoint from the inventory tool. Do not parse and rewrite endpoint policies in place. Do not mix Gateway endpoints into the Interface report without a separate mode flag. Do not embed static access keys; use the config chain.
Parity with the Python Ops census matters: same filters, same WARN classes, different runtime for fleets that already ship Go ops binaries.
§IV — Tie to the quatro
Ops owns the Python reference census. Dev owns TypedDict shapes for the JSON you might dump beside this Go TSV. Cert owns the SAP decision tree that explains why Interface rows exist. Maghrib will not create a Go quiz; Go assessment items fold into Dev if needed.
§V — Operator checklist
LoadDefaultConfigwith explicit region in CI.- Server-side filter
vpc-endpoint-type=Interface. - Paginate; do not assume one page.
- Print Private DNS and subnet counts on every row.
- WARN on subnet count under 2 and Private DNS off.
- Optional second pass for AZ uniqueness.
- Exit nonzero on API errors only (default).
§VI — Close
SDK v2 paginators turn DescribeVpcEndpoints into a boring inventory. Boring is the point. Filter Interface endpoints, print the fields Cert taught you to care about, and leave remediation to a change ticket.
Examine one WARN line against the live console before you silence it.
Related
- Python ops census
- SDK v2 S3 list (prior)
- AWS SAP PrivateLink
- Cross-References/Archmagus-Stack — Polyglot-Dev Go hub
- Grounding: Bootcamp privatelink.md; SAP notes VPC Endpoints; SDK pattern per 09-05