diff --git a/Dockerfile b/Dockerfile index 404c768..ec2f1d8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,5 @@ -# Container image for pdbmux, the merging PuppetDB proxy daemon. pdbmux is a -# k8s-only service (deployed via argocd-apps), so it ships as a distroless -# static image rather than an RPM. +# Container image for pdbmux, the merging PuppetDB proxy daemon. It ships only +# as a distroless static image; there is no OS package. FROM golang:1.25-alpine AS builder RUN apk add --no-cache git diff --git a/README.md b/README.md index a11eccf..cfca658 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,23 @@ # pdbmux — merging PuppetDB proxy -`pdbmux` is a small HTTP daemon that fronts **two** PuppetDB backends and serves -a single, merged PuppetDB v4 query surface on one address. Point `node-lookup`, -`pblastreport`, Puppetboard, or anything else at `pdbmux` instead of a raw -PuppetDB and it sees one consistent view spanning both. +`pdbmux` is a small HTTP daemon that fronts **several** PuppetDB backends and +serves a single, merged PuppetDB v4 query surface on one address. Point +Puppetboard, or any other PuppetDB API client, at `pdbmux` instead of a raw +PuppetDB and it sees one consistent view spanning all of them. ## Why -During the VM→k8s Puppet migration there are two PuppetDBs: +Running more than one PuppetDB — during a migration between two of them, or +across regions — means a given node's current data lives in exactly one at any +moment, and consumers have to know which, or query each in turn. Consider two +backends being merged during a migration: -- **old** — the legacy Consul-registered `http://puppetdbapi.service.consul:8080` -- **new** — the k8s `https://puppetdb.k8s.syd1.au.unkin.net` (TLS terminated at - the gateway; backends are plain PuppetDB on 8080) +- **old** — the PuppetDB nodes are moving off, e.g. `http://puppetdb1.example.com:8080` +- **new** — the PuppetDB nodes are moving on to, e.g. `http://puppetdb2.example.com:8080` -Nodes move from old to new as they migrate, so at any moment a given node's -current data lives in exactly one of them. `pdbmux` merges both so consumers +Nodes move from old to new as they migrate. `pdbmux` merges both so consumers don't have to know (or query twice) which PuppetDB a node currently lives in. +The backend names are arbitrary labels; there is no fixed number of backends. ## Endpoints @@ -24,18 +26,18 @@ not PQL) is forwarded verbatim. | Path | Behaviour | |---|---| -| `GET /pdb/query/v4/nodes` | Fan out to both backends, dedupe by `certname`, keep the record with the newer `report_timestamp`. | -| `GET /pdb/query/v4/facts` | Fan out to both, and per `certname` keep **all** facts from the backend that owns that node (see merge semantics). | -| `GET /pdb/query/v4/reports` | Fan out to both and serve the **union**, deduped by report `hash`, re-ordered and re-paged across the two backends. | -| `GET /pdb/query/v4/events` | Fan out to both and serve the **union**, deduped by record identity, re-ordered and re-paged. | -| `GET /pdb/query/v4/event-counts` | Fan out to both and **sum** each subject's counts into one row per subject. | -| `GET /pdb/query/v4/aggregate-event-counts` | Fan out to both and **sum** the summary object's counts. | -| `GET /pdb/query/v4/reports//{events,logs,metrics}` | Ask both; serve the answer from whichever backend actually holds that report. `404` when neither does. | +| `GET /pdb/query/v4/nodes` | Fan out to all backends, dedupe by `certname`, keep the record with the newer `report_timestamp`. | +| `GET /pdb/query/v4/facts` | Fan out to all, and per `certname` keep **all** facts from the backend that owns that node (see merge semantics). | +| `GET /pdb/query/v4/reports` | Fan out to all and serve the **union**, deduped by report `hash`, re-ordered and re-paged across backends. | +| `GET /pdb/query/v4/events` | Fan out to all and serve the **union**, deduped by record identity, re-ordered and re-paged. | +| `GET /pdb/query/v4/event-counts` | Fan out to all and **sum** each subject's counts into one row per subject. | +| `GET /pdb/query/v4/aggregate-event-counts` | Fan out to all and **sum** the summary object's counts. | +| `GET /pdb/query/v4/reports//{events,logs,metrics}` | Ask every backend; serve the answer from whichever backend actually holds that report. `404` when neither does. | | `GET /pdb/query/v4/*` (any other) | Transparently proxied to the **primary** backend, unmerged, streamed verbatim. | | `GET /healthz` | Per-backend reachability. `200 {"status":"ok"}` if all reachable, `200 degraded` if some fail, `503 down` if all fail. | Fan-out is concurrent. If one backend errors or times out, `pdbmux` serves the -survivor's results and logs a warning; a merged endpoint only returns `502` when +surviving backends' results and logs a warning; a merged endpoint only returns `502` when **every** backend fails. Response records are passed through as raw JSON so unknown fields survive untouched. @@ -44,12 +46,12 @@ unknown fields survive untouched. - **`/nodes`** — dedupe by `certname`; the record with the strictly-newer `report_timestamp` wins. On a tie (or when a node exists in only one backend), the **preferred** backend's record is kept. -- **`/facts`** — node-level granularity. For a `certname` present in both - backends, `pdbmux` keeps **all** of that node's facts from **one** backend and +- **`/facts`** — node-level granularity. For a `certname` present in more than + one backend, `pdbmux` keeps **all** of that node's facts from **one** backend and drops the other's, chosen by the merge strategy: - **`freshness`** (default) — attribute each `certname` to whichever backend holds its newer `report_timestamp`. `pdbmux` derives this from a per-certname - freshness map built by querying `/nodes` from both backends, cached for + freshness map built by querying `/nodes` from every backend, cached for `freshness_ttl` (default 30s). Ties/fallbacks use `prefer`. - **`static`** — always keep the `prefer` backend's facts for shared nodes. No extra `/nodes` query. @@ -71,8 +73,8 @@ unknown fields survive untouched. or `summarize_by`), whose remaining fields are all counts. - Rows sharing a key collapse into one with their numeric columns summed. A key only one backend reported is passed through byte-for-byte. An aggregate column - that is absent or non-numeric in one backend's row is skipped, never zeroed, - so the other backend's value survives. + that is absent or non-numeric in a row is skipped, never zeroed, so the + backends that did report a number still count. - A `/reports` query with no `function` column is a projection of real reports, not an aggregate, and stays on the union path. - `include_total=true` on a summed endpoint reports the **merged** row count, @@ -98,18 +100,22 @@ Each backend applies `order_by`/`limit`/`offset` to its own slice only, so Precedence (lowest → highest): **defaults < config file < env vars (`PDBMUX_*`) < flags**. -Config file: `$XDG_CONFIG_HOME/pdbmux/config.yaml`. In Kubernetes, configuration -is supplied entirely via `PDBMUX_*` env vars (no config file), which is the -supported deployment path — see [Deployment](#deployment). +Config file: `$XDG_CONFIG_HOME/pdbmux/config.yaml`. In a container, configuration +is supplied entirely via `PDBMUX_*` env vars (no config file) — see +[Deployment](#deployment). + +**`backends` has no default.** `pdbmux` refuses to start until at least one +backend is configured, via the config file or `PDBMUX_BACKENDS`. `primary` and +`prefer` default to the first configured backend. ```yaml -# ~/.config/pdbmux/config.yaml (local dev; in k8s use PDBMUX_* env instead) +# ~/.config/pdbmux/config.yaml (local dev; in a container use PDBMUX_* env instead) listen: ":8080" backends: - name: old - url: http://puppetdbapi.service.consul:8080 + url: http://puppetdb1.example.com:8080 - name: new - url: https://puppetdb.k8s.syd1.au.unkin.net + url: https://puppetdb2.example.com primary: new # backend used for non-merged /pdb/query/v4/* pass-through merge: freshness # freshness | static prefer: new # winner on ties / static merge / fallback @@ -137,16 +143,18 @@ Flags: `--listen`, `--primary`, `--merge`. ```bash pdbmux # start the proxy (serve is the default action) pdbmux serve # explicit -pdbmux config init # write a default config file +pdbmux config init # write an example config file to edit pdbmux config show # print active config after all overrides pdbmux version ``` -Point a consumer at it: +Point a consumer at it — any PuppetDB v4 client works, it just needs the +`pdbmux` base URL in place of a PuppetDB one. For Puppetboard, that is its +PuppetDB host/port setting; for a raw query: ```bash -node-lookup --url http://localhost:8080/pdb/query/v4/facts -R -NODE_LOOKUP_URL=http://localhost:8080/pdb/query/v4/facts pblastreport somehost +curl -s --get http://localhost:8080/pdb/query/v4/nodes \ + --data-urlencode 'query=["=","certname","host1.example.com"]' ``` ## Build @@ -162,30 +170,31 @@ Requires Go 1.25+. Dependencies: `github.com/spf13/cobra` (CLI), ## Deployment -`pdbmux` runs **in Kubernetes** as a container, in line with the all-in-k8s -estate direction — it is not shipped as a per-VM RPM/systemd service. The image -is built and pushed on every `v*` tag (`.woodpecker/docker.yaml`) to: - -``` -artifactapi.k8s.syd1.au.unkin.net/docker-internal/pdbmux: -``` +`pdbmux` is distributed as a container image only — there is no OS package. The +image is built and pushed on every `v*` tag (`.woodpecker/docker.yaml`); the +registry and repository are pipeline settings, so point them at your own. It is a minimal static (`CGO_ENABLED=0`) binary on a distroless base (`Dockerfile`), configured entirely via `PDBMUX_*` env vars, with a single HTTP -listener and `/healthz` for liveness/readiness probes. +listener and `/healthz` for liveness/readiness probes. It is stateless, so run +as many replicas as you like behind an ordinary Service/Ingress. -The Deployment/Service/Gateway manifests live in the estate's `argocd-apps` repo -under `apps/base/pdbmux/` (namespace `pdbmux`, 2 replicas), and it is exposed to -VM/workstation `node-lookup` consumers over HTTPS at: +A container needs at minimum `PDBMUX_BACKENDS`; everything else has a default: -``` -https://pdbmux.k8s.syd1.au.unkin.net +```yaml +env: + - name: PDBMUX_BACKENDS + value: "old=http://puppetdb1.example.com:8080,new=http://puppetdb2.example.com:8080" + - name: PDBMUX_PRIMARY + value: "new" + - name: PDBMUX_PREFER + value: "new" ``` -Locally you can still run the binary directly for development: +Locally you can run the binary directly for development: ```bash -PDBMUX_BACKENDS='old=http://puppetdbapi.service.consul:8080,new=http://puppetdb.puppet.svc.cluster.local:8080' \ +PDBMUX_BACKENDS='old=http://puppetdb1.example.com:8080,new=http://puppetdb2.example.com:8080' \ pdbmux serve curl -s localhost:8080/healthz ``` diff --git a/config.go b/config.go index 3e8714b..76b8451 100644 --- a/config.go +++ b/config.go @@ -18,19 +18,18 @@ const ( // defaultListen is the default HTTP listen address. defaultListen = ":8080" - // defaultOldURL / defaultNewURL are the two PuppetDBs merged during the - // VM -> k8s migration. old = legacy Consul-registered puppetdbapi; new = - // the k8s PuppetDB behind the gateway (TLS terminated there). - defaultOldURL = "http://puppetdbapi.service.consul:8080" - defaultNewURL = "https://puppetdb.k8s.syd1.au.unkin.net" - // defaultPrimary is the backend name used for pass-through (non-merged) - // /pdb/query/v4/* paths and as static precedence for merge fallback. - defaultPrimary = "new" defaultTimeout = 10 * time.Second defaultFreshnessTTL = 30 * time.Second ) +// exampleBackends are the placeholder backends written by `config init`. They +// are a scaffold to edit, not a working configuration. +var exampleBackends = []Backend{ + {Name: "primary", URL: "http://puppetdb1.example.com:8080"}, + {Name: "secondary", URL: "http://puppetdb2.example.com:8080"}, +} + // Backend is one upstream PuppetDB. URL is the base URL (scheme://host[:port]), // without the /pdb/query/v4/... path — that is appended per request. type Backend struct { @@ -67,23 +66,28 @@ const ( mergeStatic = "static" ) -// DefaultConfig returns the built-in defaults: both migration PuppetDBs, -// freshness merge, "new" primary/preferred. +// DefaultConfig returns the built-in defaults. Backends have no default: they +// must come from the config file or PDBMUX_BACKENDS. Primary and Prefer default +// to the first configured backend (see normalize). func DefaultConfig() Config { return Config{ - Listen: defaultListen, - Backends: []Backend{ - {Name: "old", URL: defaultOldURL}, - {Name: "new", URL: defaultNewURL}, - }, - Primary: defaultPrimary, + Listen: defaultListen, Merge: mergeFreshness, - Prefer: defaultPrimary, Timeout: defaultTimeout, FreshnessTTL: defaultFreshnessTTL, } } +// ExampleConfig returns DefaultConfig with placeholder backends filled in, as +// written by `config init`. +func ExampleConfig() Config { + cfg := DefaultConfig() + cfg.Backends = append([]Backend(nil), exampleBackends...) + cfg.Primary = exampleBackends[0].Name + cfg.Prefer = exampleBackends[0].Name + return cfg +} + // ConfigDir returns the XDG_CONFIG_HOME/pdbmux directory. func ConfigDir() string { base := os.Getenv("XDG_CONFIG_HOME") @@ -103,6 +107,10 @@ func ConfigPath() string { // Precedence (lowest -> highest): defaults < config file < env vars < flags // (flags are applied by the caller). Backends can be overridden wholesale via // PDBMUX_BACKENDS ("name=url,name=url"). +// +// Load reports only read/parse errors; the result is not validated, so commands +// that do not serve (config init, version) still work on an unconfigured host. +// Callers that serve must call Validate after applying flags. func Load() (Config, error) { cfg := DefaultConfig() @@ -118,10 +126,7 @@ func Load() (Config, error) { } applyEnv(&cfg, os.Getenv) - - if err := cfg.Validate(); err != nil { - return cfg, err - } + cfg.normalize() return cfg, nil } @@ -175,10 +180,25 @@ func parseBackends(s string) []Backend { return out } +// normalize fills in values that default to something derived rather than +// constant: Primary and Prefer both fall back to the first configured backend. +func (c *Config) normalize() { + if len(c.Backends) == 0 { + return + } + if c.Primary == "" { + c.Primary = c.Backends[0].Name + } + if c.Prefer == "" { + c.Prefer = c.Backends[0].Name + } +} + // Validate checks the config is internally consistent and usable. func (c Config) Validate() error { if len(c.Backends) == 0 { - return fmt.Errorf("no backends configured") + return fmt.Errorf("no backends configured: set %sBACKENDS to \"name=url,name=url\" or add a backends list to %s", + envPrefix, ConfigPath()) } seen := map[string]bool{} for _, b := range c.Backends { @@ -228,9 +248,10 @@ func writeDefaultConfig() error { if _, err := os.Stat(path); err == nil { return fmt.Errorf("config already exists at %s", path) } - data, _ := yaml.Marshal(DefaultConfig()) + data, _ := yaml.Marshal(ExampleConfig()) header := []byte("# pdbmux configuration\n" + - "# A merging proxy over two PuppetDBs (old Consul + new k8s) during migration.\n" + + "# A merging proxy presenting one PuppetDB v4 query surface over several\n" + + "# PuppetDB backends. The backend URLs below are placeholders — edit them.\n" + "# Env overrides: PDBMUX_LISTEN, PDBMUX_PRIMARY, PDBMUX_MERGE, PDBMUX_PREFER,\n" + "# PDBMUX_TIMEOUT, PDBMUX_FRESHNESS_TTL, PDBMUX_BACKENDS (name=url,name=url).\n\n") if err := os.WriteFile(path, append(header, data...), 0o644); err != nil { diff --git a/config_test.go b/config_test.go index 647e0c5..9c567c9 100644 --- a/config_test.go +++ b/config_test.go @@ -3,26 +3,69 @@ package main import ( "os" "path/filepath" + "strings" "testing" "time" ) -func TestLoad_Defaults(t *testing.T) { +// testConfigValid returns a minimal valid config for Validate/merge tests, with +// neutral placeholder backends. +func testConfigValid() Config { + cfg := DefaultConfig() + cfg.Backends = []Backend{ + {Name: "old", URL: "http://localhost:18080"}, + {Name: "new", URL: "http://localhost:18081"}, + } + cfg.Primary = "new" + cfg.Prefer = "new" + return cfg +} + +func TestDefaultConfig_NoBackends(t *testing.T) { + cfg := DefaultConfig() + if cfg.Listen != defaultListen { + t.Errorf("listen = %q, want %q", cfg.Listen, defaultListen) + } + if len(cfg.Backends) != 0 { + t.Errorf("defaults must not ship backends, got %+v", cfg.Backends) + } + if cfg.Merge != mergeFreshness { + t.Errorf("merge = %q, want %q", cfg.Merge, mergeFreshness) + } + if err := cfg.Validate(); err == nil { + t.Error("defaults alone must not validate: backends are required") + } +} + +func TestLoad_NoBackendsLoadsButFailsValidation(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) clearEnv(t) + // Load itself must succeed so `config init` / `version` work unconfigured. + cfg, err := Load() + if err != nil { + t.Fatalf("load: %v", err) + } + err = cfg.Validate() + if err == nil { + t.Fatal("expected a validation error when no backends are configured") + } + if !strings.Contains(err.Error(), envPrefix+"BACKENDS") { + t.Errorf("error should name the env var to set, got: %v", err) + } +} + +func TestLoad_PrimaryDefaultsToFirstBackend(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + clearEnv(t) + t.Setenv(envPrefix+"BACKENDS", "a=http://localhost:18080,b=http://localhost:18081") + cfg, err := Load() if err != nil { t.Fatal(err) } - if cfg.Listen != defaultListen { - t.Errorf("listen = %q, want %q", cfg.Listen, defaultListen) - } - if len(cfg.Backends) != 2 || cfg.Backends[0].Name != "old" || cfg.Backends[1].Name != "new" { - t.Errorf("unexpected default backends: %+v", cfg.Backends) - } - if cfg.Merge != mergeFreshness || cfg.Primary != "new" { - t.Errorf("unexpected defaults merge=%s primary=%s", cfg.Merge, cfg.Primary) + if cfg.Primary != "a" || cfg.Prefer != "a" { + t.Errorf("primary/prefer should default to first backend, got %q/%q", cfg.Primary, cfg.Prefer) } } @@ -35,7 +78,8 @@ func TestLoad_FileAndEnvOverride(t *testing.T) { if err := os.MkdirAll(cfgDir, 0o755); err != nil { t.Fatal(err) } - body := "listen: :9999\nmerge: static\nprimary: old\nprefer: old\n" + body := "listen: :9999\nmerge: static\nprimary: old\nprefer: old\n" + + "backends:\n - name: old\n url: http://localhost:18080\n - name: new\n url: http://localhost:18081\n" if err := os.WriteFile(filepath.Join(cfgDir, configFileName), []byte(body), 0o644); err != nil { t.Fatal(err) } @@ -56,7 +100,7 @@ func TestLoad_FileAndEnvOverride(t *testing.T) { } func TestApplyEnv_Backends(t *testing.T) { - cfg := DefaultConfig() + cfg := testConfigValid() env := map[string]string{ envPrefix + "BACKENDS": "a=http://a:8080,b=http://b:8080", envPrefix + "PRIMARY": "a", @@ -91,7 +135,7 @@ func TestValidate(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - cfg := DefaultConfig() + cfg := testConfigValid() tc.mutate(&cfg) err := cfg.Validate() if (err != nil) != tc.wantErr { @@ -112,9 +156,21 @@ func TestParseBackends(t *testing.T) { } func TestPrimaryBackend(t *testing.T) { - cfg := DefaultConfig() - if cfg.PrimaryBackend().URL != defaultNewURL { - t.Errorf("primary backend URL = %q, want %q", cfg.PrimaryBackend().URL, defaultNewURL) + cfg := testConfigValid() + if got, want := cfg.PrimaryBackend().URL, cfg.Backends[1].URL; got != want { + t.Errorf("primary backend URL = %q, want %q", got, want) + } +} + +func TestExampleConfig_IsValidAndNeutral(t *testing.T) { + cfg := ExampleConfig() + if err := cfg.Validate(); err != nil { + t.Fatalf("example config must validate: %v", err) + } + for _, b := range cfg.Backends { + if !strings.Contains(b.URL, "example.com") { + t.Errorf("example backend %q must use a placeholder host, got %q", b.Name, b.URL) + } } } diff --git a/main.go b/main.go index 6557d65..42584b1 100644 --- a/main.go +++ b/main.go @@ -1,19 +1,18 @@ -// Command pdbmux is a small merging HTTP proxy over two PuppetDB backends. +// Command pdbmux is a small merging HTTP proxy over several PuppetDB backends. // -// During the VM -> k8s Puppet migration there are two PuppetDBs — the legacy -// Consul-registered one and the new k8s one — and nodes move between them as -// they migrate. pdbmux presents a single merged PuppetDB v4 query surface so -// node-lookup, pblastreport and Puppetboard (and anything else) see one -// consistent view: +// Running more than one PuppetDB — during a migration between them, or across +// regions — means a given node's current data lives in exactly one of them at +// any moment. pdbmux presents a single merged PuppetDB v4 query surface so +// clients see one consistent view: // -// - GET /pdb/query/v4/nodes — fan out to both backends, dedupe by certname, +// - GET /pdb/query/v4/nodes — fan out to all backends, dedupe by certname, // keep the record with the newer report_timestamp. -// - GET /pdb/query/v4/facts — fan out to both, and for a certname present in -// both keep ALL facts from the backend holding that node's newer report -// (freshness merge) or a static preferred backend (static merge). -// - GET /pdb/query/v4/reports and /events — fan out to both and serve the -// deduped union, re-ordered and re-paged across the two backends, because -// reports are immutable history and a migrated node has some in each. +// - GET /pdb/query/v4/facts — fan out to all, and for a certname present in +// more than one keep ALL facts from the backend holding that node's newer +// report (freshness merge) or a static preferred backend (static merge). +// - GET /pdb/query/v4/reports and /events — fan out to all and serve the +// deduped union, re-ordered and re-paged across backends, because reports +// are immutable history and a moved node has some in each. // - GET /pdb/query/v4/reports//{events,logs,metrics} — served by // whichever backend actually holds that report. // - any other GET /pdb/query/v4/* — transparently proxied to the primary. @@ -21,8 +20,8 @@ // // The query param is forwarded verbatim (PuppetDB AST JSON); order_by, limit, // offset and include_total are re-applied over the merged result set. If one -// backend errors/times out, the other's results are served; only if both fail -// does a merged endpoint return 502. +// backend errors/times out, the others' results are served; only if every +// backend fails does a merged endpoint return 502. package main import ( @@ -72,11 +71,11 @@ func main() { root := &cobra.Command{ Use: appName, - Short: "Merging HTTP proxy over two PuppetDB backends.", - Long: "pdbmux presents a single merged PuppetDB v4 query surface over the old\n" + - "(Consul) and new (k8s) PuppetDBs during the migration, so node-lookup,\n" + - "pblastreport and Puppetboard see one consistent view. Running pdbmux with\n" + - "no subcommand (or `pdbmux serve`) starts the proxy.", + Short: "Merging HTTP proxy over several PuppetDB backends.", + Long: "pdbmux presents a single merged PuppetDB v4 query surface over several\n" + + "PuppetDB backends, so clients see one consistent view of nodes, facts and\n" + + "reports spanning all of them. Running pdbmux with no subcommand (or\n" + + "`pdbmux serve`) starts the proxy.", SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { return serve(cmd) }, }