From aa1746881516bd687a1fe8a827885687a9d54bc5 Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Fri, 14 Aug 2026 11:02:30 +0200 Subject: [PATCH 1/2] docs(acme): derive the provider table and --acme-dns-provider help from the registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The supported-provider list was hand-written in three places that had already drifted: the README credentials table was missing Cloudflare's CF_DNS_API_TOKEN alternative and misfiled gcloud's optional variables as required. Now the registry is the single source: - providers.Names() / ProviderListForHelp() feed the --acme-dns-provider usage string, so `run --help` always matches the registry - ProviderTableMarkdown() renders the README table (docs-linked names, the same OR-of-ANDs credential rule the boot check enforces, optional vars from the entry), rewritten between markers by `go generate ./internal/server/acme/providers` - TestREADMEProviderTable_MatchesRegistry fails naming the missing rows when the committed table does not match the registry — a generator with no drift check is a generator nobody reruns - the README flag-table row no longer enumerates providers; it points at the generated table ## Test Coverage - TestNames_CoversRegistryExactlySorted - TestProviderTableMarkdown_RendersEveryRegistryEntry - TestReplaceProviderTable_RewritesOnlyTheMarkedBlock / _FailsWithoutMarkers - TestREADMEProviderTable_MatchesRegistry (the drift gate) - TestProviderListForHelp_MatchesRegistry - TestRunCommand_DNSProviderHelpMatchesRegistry ## Verification - [x] gofmt/vet clean, make test green, make lint 0 issues, make build clean - [x] bin/kamal-proxy run -h shows the registry-derived list Closes #75 --- README.md | 24 +++-- internal/cmd/run.go | 3 +- internal/cmd/run_providers_test.go | 23 ++++ internal/server/acme/providers/docs.go | 110 ++++++++++++++++++++ internal/server/acme/providers/docs_test.go | 98 +++++++++++++++++ internal/server/acme/providers/gen/main.go | 38 +++++++ 6 files changed, 284 insertions(+), 12 deletions(-) create mode 100644 internal/cmd/run_providers_test.go create mode 100644 internal/server/acme/providers/docs.go create mode 100644 internal/server/acme/providers/docs_test.go create mode 100644 internal/server/acme/providers/gen/main.go diff --git a/README.md b/README.md index 7086d47..cb7d27b 100644 --- a/README.md +++ b/README.md @@ -1088,16 +1088,18 @@ needs access to your DNS provider's API. **Supported DNS Providers:** -| Provider | Environment Variables | -|----------|----------------------| -| Cloudflare | `CF_API_TOKEN` or (`CF_API_KEY` + `CF_API_EMAIL`) | -| AWS Route53 | `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY` | -| DigitalOcean | `DO_AUTH_TOKEN` | -| Google Cloud DNS | `GCE_PROJECT` + `GOOGLE_APPLICATION_CREDENTIALS` | -| Namecheap | `NAMECHEAP_API_USER` + `NAMECHEAP_API_KEY` | -| GoDaddy | `GODADDY_API_KEY` + `GODADDY_API_SECRET` | -| Hetzner | `HETZNER_API_KEY` | -| Vultr | `VULTR_API_KEY` | + +| Provider | Credentials | Optional | +|----------|-------------|----------| +| [Cloudflare](https://go-acme.github.io/lego/dns/cloudflare/) | `CF_API_TOKEN` or `CF_DNS_API_TOKEN` or (`CF_API_KEY` + `CF_API_EMAIL`) | `CF_API_EMAIL`, `CF_API_KEY`, `CF_DNS_API_TOKEN`, `CF_ZONE_API_TOKEN` | +| [DigitalOcean](https://go-acme.github.io/lego/dns/digitalocean/) | `DO_AUTH_TOKEN` | — | +| [Google Cloud DNS](https://go-acme.github.io/lego/dns/gcloud/) | `GCE_PROJECT` | `GCE_SERVICE_ACCOUNT_FILE`, `GOOGLE_APPLICATION_CREDENTIALS` | +| [GoDaddy](https://go-acme.github.io/lego/dns/godaddy/) | (`GODADDY_API_KEY` + `GODADDY_API_SECRET`) | — | +| [Hetzner](https://go-acme.github.io/lego/dns/hetzner/) | `HETZNER_API_KEY` | — | +| [Namecheap](https://go-acme.github.io/lego/dns/namecheap/) | (`NAMECHEAP_API_USER` + `NAMECHEAP_API_KEY`) | `NAMECHEAP_SANDBOX` | +| [AWS Route53](https://go-acme.github.io/lego/dns/route53/) | (`AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY`) | `AWS_REGION`, `AWS_HOSTED_ZONE_ID`, `AWS_PROFILE` | +| [Vultr](https://go-acme.github.io/lego/dns/vultr/) | `VULTR_API_KEY` | — | + **Enabling wildcard certificates:** @@ -1134,7 +1136,7 @@ rate limits (50 certificates per registered domain per week). | Flag | Environment Variable | Default | Description | |------|---------------------|---------|-------------| | `--acme-email` | `ACME_EMAIL` | (required) | Contact email for Let's Encrypt | -| `--acme-dns-provider` | `ACME_DNS_PROVIDER` | `auto` | DNS provider (cloudflare, route53, digitalocean, gcloud, namecheap, godaddy, hetzner, vultr, auto). Repeatable; `zone=provider` entries pin a zone to its DNS host | +| `--acme-dns-provider` | `ACME_DNS_PROVIDER` | `auto` | DNS provider for DNS-01 challenges — any name from the supported-providers table above, or `auto`. Repeatable; `zone=provider` entries pin a zone to its DNS host | | `--acme-directory` | `ACME_DIRECTORY` | Let's Encrypt production | ACME directory URL | | `--acme-prefer-wildcard` | `ACME_PREFER_WILDCARD` | `true` | Prefer wildcard certificates when DNS provider available | | `--acme-http-fallback` | `ACME_HTTP_FALLBACK` | `true` | Fall back to HTTP-01 challenge if DNS-01 fails | diff --git a/internal/cmd/run.go b/internal/cmd/run.go index be63ac5..2b64b9f 100644 --- a/internal/cmd/run.go +++ b/internal/cmd/run.go @@ -12,6 +12,7 @@ import ( "github.com/basecamp/kamal-proxy/internal/server" "github.com/basecamp/kamal-proxy/internal/server/acme" + "github.com/basecamp/kamal-proxy/internal/server/acme/providers" ) type runCommand struct { @@ -66,7 +67,7 @@ func newRunCommand() *runCommand { runCommand.cmd.Flags().StringVar(&globalConfig.MinTLS, "min-tls", getEnvString("MIN_TLS", server.DefaultMinTLSVersion), "Lowest TLS version the HTTPS listener will negotiate: 1.2 or 1.3 (TLS 1.0 and 1.1 cannot be enabled; HTTP/3 is always 1.3)") runCommand.cmd.Flags().StringVar(&globalConfig.ACMEEmail, "acme-email", getEnvString("ACME_EMAIL", ""), "Email address for ACME account registration (required for automatic TLS)") runCommand.cmd.Flags().StringVar(&globalConfig.ACMEDirectory, "acme-directory", getEnvString("ACME_DIRECTORY", server.LetsEncryptProduction), "ACME directory URL") - runCommand.cmd.Flags().StringSliceVar(&runCommand.acmeDNSProviders, "acme-dns-provider", strings.Split(getEnvString("ACME_DNS_PROVIDER", "auto"), ","), "DNS provider for DNS-01 challenges (cloudflare, route53, digitalocean, gcloud, namecheap, godaddy, hetzner, vultr, auto). Repeatable: zone=provider entries pin a zone to the DNS host that serves it, and one bare entry is the default for unmatched zones") + runCommand.cmd.Flags().StringSliceVar(&runCommand.acmeDNSProviders, "acme-dns-provider", strings.Split(getEnvString("ACME_DNS_PROVIDER", "auto"), ","), "DNS provider for DNS-01 challenges (one of: "+providers.ProviderListForHelp()+"). Repeatable: zone=provider entries pin a zone to the DNS host that serves it, and one bare entry is the default for unmatched zones") runCommand.cmd.Flags().BoolVar(&globalConfig.ACMEPreferWildcard, "acme-prefer-wildcard", getEnvBool("ACME_PREFER_WILDCARD", true), "Prefer wildcard certificates when DNS provider available") runCommand.cmd.Flags().BoolVar(&globalConfig.ACMEHTTPFallback, "acme-http-fallback", getEnvBool("ACME_HTTP_FALLBACK", true), "Fall back to HTTP-01 challenge if DNS-01 fails") diff --git a/internal/cmd/run_providers_test.go b/internal/cmd/run_providers_test.go new file mode 100644 index 0000000..e04c416 --- /dev/null +++ b/internal/cmd/run_providers_test.go @@ -0,0 +1,23 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/basecamp/kamal-proxy/internal/server/acme" + "github.com/basecamp/kamal-proxy/internal/server/acme/providers" +) + +// The --acme-dns-provider help enumerates the registry, not a hand-written +// list: a provider added to the registry appears here with no other change. +func TestRunCommand_DNSProviderHelpMatchesRegistry(t *testing.T) { + flag := newRunCommand().cmd.Flags().Lookup("acme-dns-provider") + require.NotNil(t, flag) + + for _, name := range providers.Names() { + assert.Contains(t, flag.Usage, string(name)) + } + assert.Contains(t, flag.Usage, string(acme.ProviderAuto)) +} diff --git a/internal/server/acme/providers/docs.go b/internal/server/acme/providers/docs.go new file mode 100644 index 0000000..c882f34 --- /dev/null +++ b/internal/server/acme/providers/docs.go @@ -0,0 +1,110 @@ +package providers + +import ( + "fmt" + "slices" + "strings" + + "github.com/basecamp/kamal-proxy/internal/server/acme" +) + +// Documentation derived from the registry: the --acme-dns-provider help list, +// the README's supported-provider table, and the marker-based replacement +// shared by the generator tool and the drift test. The registry is the single +// source of truth; nothing here is hand-maintained. + +//go:generate go run ./gen + +const ( + providerTableBeginMarker = "" + providerTableEndMarker = "" +) + +// Names returns every registered provider name, sorted. +func Names() []acme.ProviderName { + names := make([]acme.ProviderName, 0, len(registry)) + for name := range registry { + names = append(names, name) + } + slices.Sort(names) + return names +} + +// ProviderListForHelp renders the provider names for the --acme-dns-provider +// flag help: the registry, sorted, plus the auto pseudo-provider. +func ProviderListForHelp() string { + parts := make([]string, 0, len(registry)+1) + for _, name := range Names() { + parts = append(parts, string(name)) + } + parts = append(parts, string(acme.ProviderAuto)) + return strings.Join(parts, ", ") +} + +// ProviderTableMarkdown renders the supported-provider table: one row per +// registry entry, the display name linked to its lego documentation, the +// credential rule as the same OR-of-ANDs the boot check enforces, and the +// optional variables the entry names. +func ProviderTableMarkdown() string { + var b strings.Builder + b.WriteString("| Provider | Credentials | Optional |\n") + b.WriteString("|----------|-------------|----------|\n") + + for _, name := range Names() { + provider := registry[name] + fmt.Fprintf(&b, "| [%s](%s) | %s | %s |\n", + provider.DisplayName, provider.Docs, + markdownCredentialSets(provider.credentialSets()), + markdownVars(provider.Optional)) + } + + return b.String() +} + +// ReplaceProviderTable substitutes the generated provider table between the +// README's markers, leaving everything else untouched. It errors when the +// markers are missing — silently appending a table nobody asked for is how a +// generator corrupts a document. +func ReplaceProviderTable(document string) (string, error) { + begin := strings.Index(document, providerTableBeginMarker) + end := strings.Index(document, providerTableEndMarker) + if begin == -1 || end == -1 || end < begin { + return "", fmt.Errorf("provider table markers not found (need %q ... %q)", + providerTableBeginMarker, providerTableEndMarker) + } + + return document[:begin+len(providerTableBeginMarker)] + + "\n" + ProviderTableMarkdown() + + document[end:], nil +} + +// markdownCredentialSets renders an OR-of-ANDs credential rule with each +// variable in backticks, e.g. "`CF_API_TOKEN` or (`CF_API_KEY` + `CF_API_EMAIL`)". +func markdownCredentialSets(sets [][]string) string { + parts := make([]string, 0, len(sets)) + for _, set := range sets { + joined := make([]string, 0, len(set)) + for _, envVar := range set { + joined = append(joined, "`"+envVar+"`") + } + if len(set) > 1 { + parts = append(parts, "("+strings.Join(joined, " + ")+")") + } else { + parts = append(parts, strings.Join(joined, " + ")) + } + } + return strings.Join(parts, " or ") +} + +// markdownVars renders a plain list of env vars in backticks, or a dash for +// none. +func markdownVars(vars []string) string { + if len(vars) == 0 { + return "—" + } + quoted := make([]string, 0, len(vars)) + for _, envVar := range vars { + quoted = append(quoted, "`"+envVar+"`") + } + return strings.Join(quoted, ", ") +} diff --git a/internal/server/acme/providers/docs_test.go b/internal/server/acme/providers/docs_test.go new file mode 100644 index 0000000..38b2818 --- /dev/null +++ b/internal/server/acme/providers/docs_test.go @@ -0,0 +1,98 @@ +package providers + +import ( + "os" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/basecamp/kamal-proxy/internal/server/acme" +) + +func TestNames_CoversRegistryExactlySorted(t *testing.T) { + names := Names() + + require.Len(t, names, len(registry)) + for _, name := range names { + assert.Contains(t, registry, name) + } + assert.IsIncreasing(t, names) +} + +func TestProviderTableMarkdown_RendersEveryRegistryEntry(t *testing.T) { + table := ProviderTableMarkdown() + + for name, provider := range registry { + assert.Contains(t, table, "["+provider.DisplayName+"]("+provider.Docs+")", + "provider %s must appear with its docs link", name) + } + + // The credential column renders the full OR-of-ANDs rule, not prose: + // Cloudflare's third alternative was exactly what the hand-written table + // had already lost. + assert.Contains(t, table, "`CF_API_TOKEN` or `CF_DNS_API_TOKEN` or (`CF_API_KEY` + `CF_API_EMAIL`)") + assert.Contains(t, table, "`AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY`") + + // Optional vars come from the registry entry, not prose. + assert.Contains(t, table, "`NAMECHEAP_SANDBOX`") + + // Deterministic: two renders are identical. + assert.Equal(t, table, ProviderTableMarkdown()) +} + +func TestReplaceProviderTable_RewritesOnlyTheMarkedBlock(t *testing.T) { + doc := "before\n" + providerTableBeginMarker + "\nstale table\n" + providerTableEndMarker + "\nafter\n" + + replaced, err := ReplaceProviderTable(doc) + require.NoError(t, err) + + assert.True(t, strings.HasPrefix(replaced, "before\n")) + assert.True(t, strings.HasSuffix(replaced, "\nafter\n")) + assert.NotContains(t, replaced, "stale table") + assert.Contains(t, replaced, ProviderTableMarkdown()) + + // Idempotent: replacing again changes nothing. + again, err := ReplaceProviderTable(replaced) + require.NoError(t, err) + assert.Equal(t, replaced, again) +} + +func TestReplaceProviderTable_FailsWithoutMarkers(t *testing.T) { + _, err := ReplaceProviderTable("a document with no markers") + require.Error(t, err) +} + +// The committed README table must match what the registry renders; a provider +// added to the registry without regenerating fails here, named. +func TestREADMEProviderTable_MatchesRegistry(t *testing.T) { + data, err := os.ReadFile("../../../../README.md") + require.NoError(t, err) + + regenerated, err := ReplaceProviderTable(string(data)) + require.NoError(t, err, "README.md must carry the provider table markers") + + if string(data) == regenerated { + return + } + + missing := []string{} + for _, name := range Names() { + if !strings.Contains(string(data), "["+registry[name].DisplayName+"](") { + missing = append(missing, string(name)) + } + } + t.Fatalf("README.md provider table is out of date (run `go generate ./internal/server/acme/providers`); missing rows: %v", missing) +} + +// The flag help must enumerate the registry, plus the auto pseudo-provider, +// with no hand-maintained copy anywhere. +func TestProviderListForHelp_MatchesRegistry(t *testing.T) { + help := ProviderListForHelp() + + for name := range registry { + assert.Contains(t, help, string(name)) + } + assert.True(t, strings.HasSuffix(help, ", "+string(acme.ProviderAuto))) +} diff --git a/internal/server/acme/providers/gen/main.go b/internal/server/acme/providers/gen/main.go new file mode 100644 index 0000000..cf2ae1e --- /dev/null +++ b/internal/server/acme/providers/gen/main.go @@ -0,0 +1,38 @@ +// Command gen rewrites the README's supported-DNS-provider table from the +// provider registry. Run via `go generate ./internal/server/acme/providers`; +// TestREADMEProviderTable_MatchesRegistry fails the build when the committed +// table does not match what this would write. +package main + +import ( + "fmt" + "os" + + "github.com/basecamp/kamal-proxy/internal/server/acme/providers" +) + +func main() { + const readme = "../../../../README.md" + + data, err := os.ReadFile(readme) + if err != nil { + fmt.Fprintf(os.Stderr, "gen: %v\n", err) + os.Exit(1) + } + + replaced, err := providers.ReplaceProviderTable(string(data)) + if err != nil { + fmt.Fprintf(os.Stderr, "gen: %v\n", err) + os.Exit(1) + } + + if replaced == string(data) { + return + } + + if err := os.WriteFile(readme, []byte(replaced), 0644); err != nil { + fmt.Fprintf(os.Stderr, "gen: %v\n", err) + os.Exit(1) + } + fmt.Println("gen: README.md provider table regenerated") +} From 9323e30098eae45675b7d14fada74814205892dc Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Fri, 14 Aug 2026 13:54:51 +0200 Subject: [PATCH 2/2] docs(acme): flag values in the table, sharper drift message, drop dead list Review findings on PR #107: - The generated table leads with the name the --acme-dns-provider flag actually accepts: "AWS Route53" is a display name, not a flag value, and the flag-table row tells readers to use names from this table - The drift test names stale rows, not just absent providers: a changed credentials or optional column now surfaces as the differing line instead of "missing rows: []" - acme.GetSupportedProviders() is removed: it had no non-test callers, was itself a hand-maintained provider list kept honest only by a cross-check test, and deriving it from the registry would cycle the acme <- providers import. ParseProviderName remains the validation --- README.md | 20 +++++++++---------- internal/server/acme/provider.go | 15 -------------- internal/server/acme/provider_test.go | 17 ---------------- internal/server/acme/providers/docs.go | 16 ++++++++------- internal/server/acme/providers/docs_test.go | 19 +++++++++++------- .../server/acme/providers/registry_test.go | 20 ------------------- 6 files changed, 31 insertions(+), 76 deletions(-) diff --git a/README.md b/README.md index cb7d27b..463b61b 100644 --- a/README.md +++ b/README.md @@ -1089,16 +1089,16 @@ needs access to your DNS provider's API. **Supported DNS Providers:** -| Provider | Credentials | Optional | -|----------|-------------|----------| -| [Cloudflare](https://go-acme.github.io/lego/dns/cloudflare/) | `CF_API_TOKEN` or `CF_DNS_API_TOKEN` or (`CF_API_KEY` + `CF_API_EMAIL`) | `CF_API_EMAIL`, `CF_API_KEY`, `CF_DNS_API_TOKEN`, `CF_ZONE_API_TOKEN` | -| [DigitalOcean](https://go-acme.github.io/lego/dns/digitalocean/) | `DO_AUTH_TOKEN` | — | -| [Google Cloud DNS](https://go-acme.github.io/lego/dns/gcloud/) | `GCE_PROJECT` | `GCE_SERVICE_ACCOUNT_FILE`, `GOOGLE_APPLICATION_CREDENTIALS` | -| [GoDaddy](https://go-acme.github.io/lego/dns/godaddy/) | (`GODADDY_API_KEY` + `GODADDY_API_SECRET`) | — | -| [Hetzner](https://go-acme.github.io/lego/dns/hetzner/) | `HETZNER_API_KEY` | — | -| [Namecheap](https://go-acme.github.io/lego/dns/namecheap/) | (`NAMECHEAP_API_USER` + `NAMECHEAP_API_KEY`) | `NAMECHEAP_SANDBOX` | -| [AWS Route53](https://go-acme.github.io/lego/dns/route53/) | (`AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY`) | `AWS_REGION`, `AWS_HOSTED_ZONE_ID`, `AWS_PROFILE` | -| [Vultr](https://go-acme.github.io/lego/dns/vultr/) | `VULTR_API_KEY` | — | +| Name | Provider | Credentials | Optional | +|------|----------|-------------|----------| +| `cloudflare` | [Cloudflare](https://go-acme.github.io/lego/dns/cloudflare/) | `CF_API_TOKEN` or `CF_DNS_API_TOKEN` or (`CF_API_KEY` + `CF_API_EMAIL`) | `CF_API_EMAIL`, `CF_API_KEY`, `CF_DNS_API_TOKEN`, `CF_ZONE_API_TOKEN` | +| `digitalocean` | [DigitalOcean](https://go-acme.github.io/lego/dns/digitalocean/) | `DO_AUTH_TOKEN` | — | +| `gcloud` | [Google Cloud DNS](https://go-acme.github.io/lego/dns/gcloud/) | `GCE_PROJECT` | `GCE_SERVICE_ACCOUNT_FILE`, `GOOGLE_APPLICATION_CREDENTIALS` | +| `godaddy` | [GoDaddy](https://go-acme.github.io/lego/dns/godaddy/) | (`GODADDY_API_KEY` + `GODADDY_API_SECRET`) | — | +| `hetzner` | [Hetzner](https://go-acme.github.io/lego/dns/hetzner/) | `HETZNER_API_KEY` | — | +| `namecheap` | [Namecheap](https://go-acme.github.io/lego/dns/namecheap/) | (`NAMECHEAP_API_USER` + `NAMECHEAP_API_KEY`) | `NAMECHEAP_SANDBOX` | +| `route53` | [AWS Route53](https://go-acme.github.io/lego/dns/route53/) | (`AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY`) | `AWS_REGION`, `AWS_HOSTED_ZONE_ID`, `AWS_PROFILE` | +| `vultr` | [Vultr](https://go-acme.github.io/lego/dns/vultr/) | `VULTR_API_KEY` | — | **Enabling wildcard certificates:** diff --git a/internal/server/acme/provider.go b/internal/server/acme/provider.go index f369f63..0d7431e 100644 --- a/internal/server/acme/provider.go +++ b/internal/server/acme/provider.go @@ -34,21 +34,6 @@ const DefaultProductionDirectory = "https://acme-v02.api.letsencrypt.org/directo // DefaultStagingDirectory is the Let's Encrypt staging ACME directory const DefaultStagingDirectory = "https://acme-staging-v02.api.letsencrypt.org/directory" -// GetSupportedProviders returns a list of supported DNS providers -func GetSupportedProviders() []ProviderName { - return []ProviderName{ - ProviderCloudflare, - ProviderRoute53, - ProviderDigitalOcean, - ProviderGoogleCloud, - ProviderNamecheap, - ProviderGoDaddy, - ProviderHetzner, - ProviderVultr, - ProviderAuto, - } -} - // ParseProviderName parses a string into a ProviderName func ParseProviderName(s string) (ProviderName, error) { switch strings.ToLower(s) { diff --git a/internal/server/acme/provider_test.go b/internal/server/acme/provider_test.go index 260dfce..e64c208 100644 --- a/internal/server/acme/provider_test.go +++ b/internal/server/acme/provider_test.go @@ -75,23 +75,6 @@ func TestParseProviderName(t *testing.T) { } } -func TestGetSupportedProviders(t *testing.T) { - providers := GetSupportedProviders() - - // Should have all expected providers - assert.Contains(t, providers, ProviderCloudflare) - assert.Contains(t, providers, ProviderRoute53) - assert.Contains(t, providers, ProviderDigitalOcean) - assert.Contains(t, providers, ProviderGoogleCloud) - assert.Contains(t, providers, ProviderNamecheap) - assert.Contains(t, providers, ProviderGoDaddy) - assert.Contains(t, providers, ProviderHetzner) - assert.Contains(t, providers, ProviderVultr) - assert.Contains(t, providers, ProviderAuto) - - assert.Len(t, providers, 9) -} - func TestCheckEnvVars(t *testing.T) { tests := []struct { name string diff --git a/internal/server/acme/providers/docs.go b/internal/server/acme/providers/docs.go index c882f34..0a61e2d 100644 --- a/internal/server/acme/providers/docs.go +++ b/internal/server/acme/providers/docs.go @@ -42,18 +42,20 @@ func ProviderListForHelp() string { } // ProviderTableMarkdown renders the supported-provider table: one row per -// registry entry, the display name linked to its lego documentation, the -// credential rule as the same OR-of-ANDs the boot check enforces, and the -// optional variables the entry names. +// registry entry, leading with the name the --acme-dns-provider flag accepts +// (a display name like "AWS Route53" is not a flag value), then the display +// name linked to its lego documentation, the credential rule as the same +// OR-of-ANDs the boot check enforces, and the optional variables the entry +// names. func ProviderTableMarkdown() string { var b strings.Builder - b.WriteString("| Provider | Credentials | Optional |\n") - b.WriteString("|----------|-------------|----------|\n") + b.WriteString("| Name | Provider | Credentials | Optional |\n") + b.WriteString("|------|----------|-------------|----------|\n") for _, name := range Names() { provider := registry[name] - fmt.Fprintf(&b, "| [%s](%s) | %s | %s |\n", - provider.DisplayName, provider.Docs, + fmt.Fprintf(&b, "| `%s` | [%s](%s) | %s | %s |\n", + name, provider.DisplayName, provider.Docs, markdownCredentialSets(provider.credentialSets()), markdownVars(provider.Optional)) } diff --git a/internal/server/acme/providers/docs_test.go b/internal/server/acme/providers/docs_test.go index 38b2818..da52090 100644 --- a/internal/server/acme/providers/docs_test.go +++ b/internal/server/acme/providers/docs_test.go @@ -25,8 +25,8 @@ func TestProviderTableMarkdown_RendersEveryRegistryEntry(t *testing.T) { table := ProviderTableMarkdown() for name, provider := range registry { - assert.Contains(t, table, "["+provider.DisplayName+"]("+provider.Docs+")", - "provider %s must appear with its docs link", name) + assert.Contains(t, table, "| `"+string(name)+"` | ["+provider.DisplayName+"]("+provider.Docs+")", + "provider %s must appear with its flag value and docs link", name) } // The credential column renders the full OR-of-ANDs rule, not prose: @@ -77,13 +77,18 @@ func TestREADMEProviderTable_MatchesRegistry(t *testing.T) { return } - missing := []string{} - for _, name := range Names() { - if !strings.Contains(string(data), "["+registry[name].DisplayName+"](") { - missing = append(missing, string(name)) + // Name the rows the registry renders that the committed table lacks — + // a brand-new provider AND a changed row (credentials, optional vars) + // both surface as an expected line the document does not contain. + stale := []string{} + for line := range strings.Lines(ProviderTableMarkdown()) { + line = strings.TrimSuffix(line, "\n") + if line != "" && !strings.Contains(string(data), line) { + stale = append(stale, line) } } - t.Fatalf("README.md provider table is out of date (run `go generate ./internal/server/acme/providers`); missing rows: %v", missing) + t.Fatalf("README.md provider table is out of date (run `go generate ./internal/server/acme/providers`); stale or missing rows:\n%s", + strings.Join(stale, "\n")) } // The flag help must enumerate the registry, plus the auto pseudo-provider, diff --git a/internal/server/acme/providers/registry_test.go b/internal/server/acme/providers/registry_test.go index 8cee167..71bceb6 100644 --- a/internal/server/acme/providers/registry_test.go +++ b/internal/server/acme/providers/registry_test.go @@ -1,7 +1,6 @@ package providers import ( - "slices" "testing" "github.com/basecamp/kamal-proxy/internal/server/acme" @@ -113,22 +112,3 @@ func TestNewProvider_Route53SkipsCredentialCheck(t *testing.T) { require.True(t, ok) assert.True(t, provider.NoBootCheck) } - -// GetSupportedProviders and the registry must agree, "auto" aside. -func TestRegistry_MatchesSupportedProviders(t *testing.T) { - supported := acme.GetSupportedProviders() - - for name := range registry { - assert.Contains(t, supported, name) - } - - for _, name := range supported { - if name == acme.ProviderAuto { - continue - } - _, ok := registry[name] - assert.True(t, ok, "supported provider %s missing from registry", name) - } - - assert.True(t, slices.Contains(supported, acme.ProviderAuto)) -}