From 2d3f61feae9543293b1c279c0149c2c233921a8c Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Wed, 26 Aug 2026 16:22:21 +0200 Subject: [PATCH 1/3] fix(rpc): omit zero timestamps from domains status JSON omitempty is a no-op on time.Time, so an uncertified registered host reported expires_at as 0001-01-01 and an unpolled source did the same for fetched_at. omitzero drops them, which matters now that the struct is about to become a machine-readable CLI surface. Refs #121 --- internal/server/commands.go | 4 ++-- internal/server/commands_test.go | 29 +++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/internal/server/commands.go b/internal/server/commands.go index 3046b4d..90f8f04 100644 --- a/internal/server/commands.go +++ b/internal/server/commands.go @@ -102,7 +102,7 @@ type DomainStatus struct { type DomainsServiceStatus struct { Source string `json:"source"` Domains []DomainStatus `json:"domains"` - FetchedAt time.Time `json:"fetched_at"` + FetchedAt time.Time `json:"fetched_at,omitzero"` // HeldRemovals lists domains the source stopped reporting but whose // removal is held by the shrink guard, pending confirmation. @@ -126,7 +126,7 @@ type QuarantineStatus struct { type RegisteredDomainStatus struct { Service string `json:"service"` Certified bool `json:"certified"` - ExpiresAt time.Time `json:"expires_at,omitempty"` + ExpiresAt time.Time `json:"expires_at,omitzero"` } type DomainsStatusResponse struct { diff --git a/internal/server/commands_test.go b/internal/server/commands_test.go index 231047b..1048a08 100644 --- a/internal/server/commands_test.go +++ b/internal/server/commands_test.go @@ -1,10 +1,12 @@ package server import ( + "encoding/json" "net/http" "path/filepath" "sync" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -149,3 +151,30 @@ func TestCommandHandler_CertsExport(t *testing.T) { require.NoError(t, handler.CertsExport(CertsExportArgs{Path: outputPath}, &summary)) assert.Equal(t, 1, summary.Certificates) } + +func TestDomainsStatusResponse_JSONOmitsZeroTimes(t *testing.T) { + expires := time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC) + response := DomainsStatusResponse{ + Services: map[string]DomainsServiceStatus{ + "tenants": {Source: "http://source", Domains: []DomainStatus{}}, + }, + Quarantine: map[string]QuarantineStatus{}, + Registered: map[string]RegisteredDomainStatus{ + "pending.example.com": {Service: "web"}, + "live.example.com": {Service: "web", Certified: true, ExpiresAt: expires}, + }, + } + + data, err := json.Marshal(response) + require.NoError(t, err) + + var decoded struct { + Services map[string]map[string]any `json:"services"` + Registered map[string]map[string]any `json:"registered"` + } + require.NoError(t, json.Unmarshal(data, &decoded)) + + assert.NotContains(t, decoded.Services["tenants"], "fetched_at") + assert.NotContains(t, decoded.Registered["pending.example.com"], "expires_at") + assert.Equal(t, "2026-09-01T12:00:00Z", decoded.Registered["live.example.com"]["expires_at"]) +} From 14e063983440533c971cbe688488fe06948835cd Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Wed, 26 Aug 2026 16:22:21 +0200 Subject: [PATCH 2/3] feat(cmd): --json on domains list and domains stats The gem's dash doctor needs hold state (until/kind) without scraping the table. list --json emits DomainsStatusResponse verbatim so the RPC struct is the one contract; stats --json emits a CLI-side summary folded from the same response. Tables stay the default. Closes #121 --- internal/cmd/domains.go | 187 ++++++++++++++++++++++------------- internal/cmd/domains_test.go | 128 ++++++++++++++++++++++++ 2 files changed, 248 insertions(+), 67 deletions(-) create mode 100644 internal/cmd/domains_test.go diff --git a/internal/cmd/domains.go b/internal/cmd/domains.go index 8e98d50..1c1f02f 100644 --- a/internal/cmd/domains.go +++ b/internal/cmd/domains.go @@ -1,6 +1,7 @@ package cmd import ( + "encoding/json" "errors" "fmt" "maps" @@ -32,7 +33,7 @@ func newDomainsCommand() *domainsCommand { return domainsCommand } -func fetchDomainsStatus(fn func(response server.DomainsStatusResponse)) error { +func fetchDomainsStatus(fn func(response server.DomainsStatusResponse) error) error { return withRPCClient(globalConfig.SocketPath(), func(client *rpc.Client) error { var response server.DomainsStatusResponse @@ -41,13 +42,23 @@ func fetchDomainsStatus(fn func(response server.DomainsStatusResponse)) error { return err } - fn(response) - return nil + return fn(response) }) } +func printJSON(value any) error { + data, err := json.MarshalIndent(value, "", " ") + if err != nil { + return err + } + + fmt.Println(string(data)) + return nil +} + type domainsListCommand struct { - cmd *cobra.Command + cmd *cobra.Command + json bool } func newDomainsListCommand() *domainsListCommand { @@ -60,58 +71,69 @@ func newDomainsListCommand() *domainsListCommand { Aliases: []string{"ls"}, } + domainsListCommand.cmd.Flags().BoolVar(&domainsListCommand.json, "json", false, "Output the domain list as JSON") + return domainsListCommand } func (c *domainsListCommand) run(cmd *cobra.Command, args []string) error { - return fetchDomainsStatus(func(response server.DomainsStatusResponse) { - table := NewTable() - table.AddRow([]string{"Service", "Domain", "Certified", "Quarantined until", "Removal held"}) - - for _, name := range slices.Sorted(maps.Keys(response.Services)) { - service := response.Services[name] - domains := slices.SortedFunc(slices.Values(service.Domains), func(a, b server.DomainStatus) int { - return strings.Compare(a.Domain, b.Domain) - }) - - heldRemovals := make(map[string]struct{}, len(service.HeldRemovals)) - for _, domain := range service.HeldRemovals { - heldRemovals[domain] = struct{}{} - } + return fetchDomainsStatus(func(response server.DomainsStatusResponse) error { + if c.json { + return printJSON(response) + } - for _, domain := range domains { - certified := "no" - if domain.Certified { - certified = "yes" - } + c.displayTable(response) + return nil + }) +} - quarantined := holdDescription(response.Quarantine, domain.Domain) +func (c *domainsListCommand) displayTable(response server.DomainsStatusResponse) { + table := NewTable() + table.AddRow([]string{"Service", "Domain", "Certified", "Quarantined until", "Removal held"}) - held := "" - if _, ok := heldRemovals[domain.Domain]; ok { - held = "yes" - } + for _, name := range slices.Sorted(maps.Keys(response.Services)) { + service := response.Services[name] + domains := slices.SortedFunc(slices.Values(service.Domains), func(a, b server.DomainStatus) int { + return strings.Compare(a.Domain, b.Domain) + }) - table.AddRow([]string{name, domain.Domain, certified, quarantined, held}) - } + heldRemovals := make(map[string]struct{}, len(service.HeldRemovals)) + for _, domain := range service.HeldRemovals { + heldRemovals[domain] = struct{}{} } - // Deploy-registered hosts have no domain source to group them under, - // but they are the common case — and the one an operator is staring at - // during a DNS cutover, wondering why the certificate has not arrived. - for _, domain := range slices.Sorted(maps.Keys(response.Registered)) { - entry := response.Registered[domain] - + for _, domain := range domains { certified := "no" - if entry.Certified { + if domain.Certified { certified = "yes" } - table.AddRow([]string{entry.Service, domain, certified, holdDescription(response.Quarantine, domain), ""}) + quarantined := holdDescription(response.Quarantine, domain.Domain) + + held := "" + if _, ok := heldRemovals[domain.Domain]; ok { + held = "yes" + } + + table.AddRow([]string{name, domain.Domain, certified, quarantined, held}) } + } - table.Print() - }) + // Deploy-registered hosts have no domain source to group them under, + // but they are the common case — and the one an operator is staring at + // during a DNS cutover, wondering why the certificate has not arrived. + for _, domain := range slices.Sorted(maps.Keys(response.Registered)) { + entry := response.Registered[domain] + + certified := "no" + if entry.Certified { + certified = "yes" + } + + table.AddRow([]string{entry.Service, domain, certified, holdDescription(response.Quarantine, domain), ""}) + } + + table.Print() } // holdDescription renders a domain's issuance hold for the listing: when it @@ -130,7 +152,21 @@ func holdDescription(quarantine map[string]server.QuarantineStatus, domain strin } type domainsStatsCommand struct { - cmd *cobra.Command + cmd *cobra.Command + json bool +} + +// DomainsStatsSummary is the machine-readable form of `domains stats`. +type DomainsStatsSummary struct { + Services int `json:"services"` + DynamicDomains int `json:"dynamic_domains"` + DynamicCertified int `json:"dynamic_certified"` + RegisteredHosts int `json:"registered_hosts"` + RegisteredCertified int `json:"registered_certified"` + Queued int `json:"queued"` + Quarantined int `json:"quarantined"` + HeldRemovals int `json:"held_removals"` + Certificates int `json:"certificates"` } func newDomainsStatsCommand() *domainsStatsCommand { @@ -142,41 +178,58 @@ func newDomainsStatsCommand() *domainsStatsCommand { Args: cobra.NoArgs, } + domainsStatsCommand.cmd.Flags().BoolVar(&domainsStatsCommand.json, "json", false, "Output the counters as JSON") + return domainsStatsCommand } func (c *domainsStatsCommand) run(cmd *cobra.Command, args []string) error { - return fetchDomainsStatus(func(response server.DomainsStatusResponse) { - domains := 0 - certified := 0 - held := 0 - for _, service := range response.Services { - domains += len(service.Domains) - held += len(service.HeldRemovals) - for _, domain := range service.Domains { - if domain.Certified { - certified++ - } - } + return fetchDomainsStatus(func(response server.DomainsStatusResponse) error { + summary := summarizeDomains(response) + + if c.json { + return printJSON(summary) } - registeredCertified := 0 - for _, entry := range response.Registered { - if entry.Certified { - registeredCertified++ + fmt.Printf("Services with domain sources: %d\n", summary.Services) + fmt.Printf("Dynamic domains: %d\n", summary.DynamicDomains) + fmt.Printf("Dynamic certified: %d\n", summary.DynamicCertified) + fmt.Printf("Registered hosts: %d\n", summary.RegisteredHosts) + fmt.Printf("Registered certified: %d\n", summary.RegisteredCertified) + fmt.Printf("Queued for issuance: %d\n", summary.Queued) + fmt.Printf("Quarantined: %d\n", summary.Quarantined) + fmt.Printf("Held removals: %d\n", summary.HeldRemovals) + fmt.Printf("Managed certificates: %d\n", summary.Certificates) + return nil + }) +} + +func summarizeDomains(response server.DomainsStatusResponse) DomainsStatsSummary { + summary := DomainsStatsSummary{ + Services: len(response.Services), + RegisteredHosts: len(response.Registered), + Queued: response.QueueLength, + Quarantined: len(response.Quarantine), + Certificates: response.Certificates, + } + + for _, service := range response.Services { + summary.DynamicDomains += len(service.Domains) + summary.HeldRemovals += len(service.HeldRemovals) + for _, domain := range service.Domains { + if domain.Certified { + summary.DynamicCertified++ } } + } - fmt.Printf("Services with domain sources: %d\n", len(response.Services)) - fmt.Printf("Dynamic domains: %d\n", domains) - fmt.Printf("Dynamic certified: %d\n", certified) - fmt.Printf("Registered hosts: %d\n", len(response.Registered)) - fmt.Printf("Registered certified: %d\n", registeredCertified) - fmt.Printf("Queued for issuance: %d\n", response.QueueLength) - fmt.Printf("Quarantined: %d\n", len(response.Quarantine)) - fmt.Printf("Held removals: %d\n", held) - fmt.Printf("Managed certificates: %d\n", response.Certificates) - }) + for _, entry := range response.Registered { + if entry.Certified { + summary.RegisteredCertified++ + } + } + + return summary } type domainsRefreshCommand struct { diff --git a/internal/cmd/domains_test.go b/internal/cmd/domains_test.go new file mode 100644 index 0000000..7ae06fa --- /dev/null +++ b/internal/cmd/domains_test.go @@ -0,0 +1,128 @@ +package cmd + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/basecamp/kamal-proxy/internal/server" +) + +func sampleDomainsStatus() server.DomainsStatusResponse { + return server.DomainsStatusResponse{ + Services: map[string]server.DomainsServiceStatus{ + "tenants": { + Source: "http://tenants.internal/domains", + Domains: []server.DomainStatus{ + {Domain: "a.example.com", Certified: true}, + {Domain: "b.example.com", Certified: false}, + }, + FetchedAt: time.Date(2026, 8, 26, 10, 0, 0, 0, time.UTC), + HeldRemovals: []string{"gone.example.com"}, + }, + }, + QueueLength: 3, + Quarantine: map[string]server.QuarantineStatus{ + "b.example.com": { + Until: time.Date(2026, 8, 26, 14, 32, 10, 0, time.UTC), + Failures: 2, + Kind: "preflight", + }, + }, + Certificates: 4, + Registered: map[string]server.RegisteredDomainStatus{ + "web.example.com": {Service: "web", Certified: true, ExpiresAt: time.Date(2026, 11, 1, 0, 0, 0, 0, time.UTC)}, + "new.example.com": {Service: "web"}, + }, + } +} + +func TestDomainsListCommand_JSONFlag(t *testing.T) { + flag := newDomainsListCommand().cmd.Flags().Lookup("json") + require.NotNil(t, flag) + assert.Equal(t, "false", flag.DefValue) +} + +func TestDomainsStatsCommand_JSONFlag(t *testing.T) { + flag := newDomainsStatsCommand().cmd.Flags().Lookup("json") + require.NotNil(t, flag) + assert.Equal(t, "false", flag.DefValue) +} + +func TestDomainsListCommand_JSONOutputShape(t *testing.T) { + data, err := json.Marshal(sampleDomainsStatus()) + require.NoError(t, err) + + var decoded map[string]any + require.NoError(t, json.Unmarshal(data, &decoded)) + + quarantine := decoded["quarantine"].(map[string]any)["b.example.com"].(map[string]any) + assert.Equal(t, "preflight", quarantine["kind"]) + assert.Equal(t, float64(2), quarantine["failures"]) + assert.Equal(t, "2026-08-26T14:32:10Z", quarantine["until"]) + + service := decoded["services"].(map[string]any)["tenants"].(map[string]any) + assert.Equal(t, []any{"gone.example.com"}, service["held_removals"]) + assert.Equal(t, "2026-08-26T10:00:00Z", service["fetched_at"]) + + registered := decoded["registered"].(map[string]any) + assert.Equal(t, "web", registered["web.example.com"].(map[string]any)["service"]) + assert.NotContains(t, registered["new.example.com"].(map[string]any), "expires_at") + + assert.Equal(t, float64(3), decoded["queue_length"]) + assert.Equal(t, float64(4), decoded["certificates"]) +} + +func TestSummarizeDomains(t *testing.T) { + tests := []struct { + name string + response server.DomainsStatusResponse + expected DomainsStatsSummary + }{ + { + name: "empty", + response: server.DomainsStatusResponse{}, + expected: DomainsStatsSummary{}, + }, + { + name: "mixed", + response: sampleDomainsStatus(), + expected: DomainsStatsSummary{ + Services: 1, + DynamicDomains: 2, + DynamicCertified: 1, + RegisteredHosts: 2, + RegisteredCertified: 1, + Queued: 3, + Quarantined: 1, + HeldRemovals: 1, + Certificates: 4, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, summarizeDomains(tt.response)) + }) + } +} + +func TestDomainsStatsSummary_JSONKeys(t *testing.T) { + data, err := json.Marshal(summarizeDomains(sampleDomainsStatus())) + require.NoError(t, err) + + var decoded map[string]any + require.NoError(t, json.Unmarshal(data, &decoded)) + + for _, key := range []string{ + "services", "dynamic_domains", "dynamic_certified", "registered_hosts", + "registered_certified", "queued", "quarantined", "held_removals", "certificates", + } { + assert.Contains(t, decoded, key) + } + assert.Equal(t, float64(2), decoded["dynamic_domains"]) +} From cf91808ba35d1c62f565894436aff3837223d480 Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Wed, 26 Aug 2026 16:22:21 +0200 Subject: [PATCH 3/3] docs: note domains --json in README and roadmap Refs #121 --- README.md | 4 ++++ ROADMAP.md | 1 + 2 files changed, 5 insertions(+) diff --git a/README.md b/README.md index 758fc39..93ec58d 100644 --- a/README.md +++ b/README.md @@ -812,6 +812,10 @@ the proxy will pick it up on its own. An `acme` hold means the certificate authority rejected an order, and lifts the same way. A `rate_limited` hold is the authority's own window, and waits it out. +Both `domains list` and `domains stats` take `--json` for scripts and the `dash` +gem; the hold shows up under `quarantine.` with `until`, `failures` and +`kind`. + If you know the cause is already fixed and do not want to wait: kamal-proxy domains retry new.example.com diff --git a/ROADMAP.md b/ROADMAP.md index f5a5e5b..9b54051 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -37,6 +37,7 @@ Proxy-side roadmap for the dash fork. The cross-repo release sequencing, strateg | ~~**On-demand TLS with `ask` endpoint**~~ | DONE (PR #50) | `internal/server/tls_on_demand.go`; per-handshake gate in `Router.GetCertificate`, which asks the on-demand endpoint before any shared manager sees the name | | Min-TLS version / ciphers (no `MinVersion` today → TLS 1.2 default) | port PR #199 | `server.go:158` TLS config; `run` flag | | Cert observability — dashboards/alerts on the R1-wired metrics | — | `internal/metrics` | +| Machine-readable domain status for the gem | #121 | DONE — `domains list --json` emits `DomainsStatusResponse` verbatim (`internal/cmd/domains.go`), the same struct the RPC already carries, so there is one contract, not a CLI-only copy of it; `domains stats --json` emits a CLI-side `DomainsStatsSummary` folded from the same response. The JSON tags on the status structs (`internal/server/commands.go`) are now a public surface: `quarantine..{until,failures,kind}` is what `dash doctor` reads to name a hold. `expires_at`/`fetched_at` switched from `omitempty` (a no-op on `time.Time`) to `omitzero`, so an uncertified host no longer reports a year-0001 expiry. The "dynamic domains are not enabled" RPC error stays a non-zero exit in JSON mode — the gem degrades on exit status, not on parsing `{}` | ## R5 — Traffic shaping & headers