A terminal tool for tracking a directory of secret files against GCP Secret Manager.
It answers the question a plain diff cannot. Not "do these differ" but which side changed: a local edit you have not pushed, a change someone made in the console, or both at once.
wdsc all projects 175 secrets · 6 payload reads · 1.2s · as of now
⚠ 1 diverged ✗ 1 error ? 2 drift ⇣ 2 remote ⇡ 3 local + 1 new ✓ 159 in sync
STATUS GIT SECRET (base-…) VER AGE │ base-ai-geoip-staging-backend
⚠ diverged edited ai-serviceflow-dev-config v2 35m │ dev/staging · base-ggcloud-dev
✗ error clean …eflow-production-backend v1 now │ ⇣ remote ahead · verified
? drift clean …-townhall-staging-config v1 now │ gcp v2 · 35m ago
▌⇣ remote ahe… clean ai-geoip-staging-backend v2 35m │ last sync: push v1 · 1d ago
⇡ local ahead staged ai-bpm-staging-backend v1~ now │
+ untracked new …geoip-production-backend - now │ ~ /API_KEY sha:dff3 → sha:5337
│ = 13 paths unchanged
Secrets live in Secret Manager, but they are edited as files in a git repo and pushed with a script. That leaves a gap nothing fills:
| git knows | Secret Manager knows | |
|---|---|---|
| what I changed | yes | no |
| what is live right now | no | yes |
| whether I pushed it | no | no |
With a dozen files you remember. With a few hundred you do not, and checking
properly is expensive enough that nobody does it — one gcloud secrets versions access per file is a second or two each, so a full check takes ten minutes.
wdsc closes the gap with a journal: a record, committed alongside the secrets, of what content sat at which remote version after each sync. That third reference point is what turns "these differ" into "you changed this" or "someone else did".
go install github.com/ithaqua/wdsc/cmd/wdsc@latestThen, in your secrets repo:
wdsc init # inspects the layout and writes .wdsc.yaml
wdsc adopt # records a baseline for everything already in sync
wdsc # opens the interactive viewwdsc adopt is the step that makes the tool fast, and it is worth understanding
why. Until there is a journal entry for a secret, wdsc has to read its payload to
say anything at all. After adopting, most secrets are settled from version metadata
alone.
wdsc demo --tui # a generated repo and an in-memory Secret Manager
wdsc demo --diff # key-level diffs
wdsc demo --json # the machine-readable shapeThe demo builds ~170 secrets across two projects with a realistic mix of situations.
No network, no real secret, every generated value prefixed FAKE-. This is also how
the tool is developed: nothing in the test suite touches a real repo.
Three sources, compared pairwise:
- local — the bytes on disk
- remote — the payload of the version an app would mount
- baseline — what the journal says was in sync last time
| status | meaning |
|---|---|
in sync | local and remote match |
local ahead | you edited it; the remote is untouched → push |
remote ahead | someone changed it in GCP; your file is unchanged → pull |
diverged | both moved since the last sync → resolve by hand |
format only | same JSON data, different bytes (indent, key order, trailing newline) |
drift | they differ and there is no baseline, so the direction is unknowable |
untracked | a local file with no secret in GCP yet |
orphan | a secret in GCP with no local file |
unmapped | a file outside every project directory; shown, never pushed |
error | unreadable, unresolvable, or a disabled newest version |
Secret Manager version ids increase monotonically. The journal records the id each push created. So if the remote is still on that version, the remote content is already known and there is nothing to fetch — comparing the local digest against the journal settles it.
Payloads are read only where that check is inconclusive: the version moved, or there is no journal entry. On a normal day that is a handful of secrets out of hundreds.
Every run reports what it cost, so the claim is checkable rather than trusted:
175 secrets in 1.2s · 169 metadata calls · 6 payload reads · 162 inferred (marked ~)
A row showing v7~ rests on the version number matching the journal, not on a
payload comparison. Press v — or run wdsc verify — to replace every inference
with a real comparison. Opening a secret's diff also verifies that one, and the
tilde disappears from it.
Nothing in wdsc presents an inference as a fact.
- Masked by default. A diff shows the key path, the size, and a four-character
fingerprint:
sha:a3f9 31B. Enough to see that two values differ; useless for recovering either. - Reveal is deliberate.
xin the diff pane reveals one value. Revealing a whole secret asks first. While anything is revealed the header says so, and a refresh clears it. - The alternate screen matters. A revealed value lives on the alternate buffer, so quitting takes it off the screen instead of leaving it in scrollback.
--jsoncannot leak. Payloads live in unexported fields, so no amount of marshalling reaches them. Safe to pipe into CI output.- The journal and cache hold digests only, never a value.
wdsc push -e production -a esop,task # add a version from each local file
wdsc pull -e dev -a webform # overwrite local files from the live payload
wdsc push --dry-run -e production # show what would happen, write nothingEvery write is previewed, and three things are refused unless answered specifically:
- Key removals get their own question. Dropping a key is what takes a service
down at its next restart, and it is the line an eye skips over in a diff. The
preview lists every key that would disappear, and confirming means typing
remove—--yesdoes not cover it. - A protected project wants its name typed, once for the whole batch. Mark one
in
.wdsc.yamlwithprotected: true. - Content that would break an app is blocked, not confirmed. An empty file, a
.jsonfile that no longer parses, a payload over Secret Manager's 64 KiB limit. The API would accept all three.
A file already matching the live payload is skipped, so the version history stays
useful. So is a change that is only formatting — pass --include-noop to override.
Without a terminal there is no way to obtain a confirmation, so a gated write is refused rather than assumed. That is what makes the tool safe to have on a CI box.
A pull writes local files, which git can undo, so it does not ask for the protected name. It writes the remote bytes exactly — reformatting to the repo's house style would make the file differ from the live payload the moment it landed.
A secret that exists only in GCP is pulled into a new file under its project's directory. That is the other half of the console-edit story: a colleague creating a secret there is as common as editing one.
wdsc history base-ai-esop-production-backend
wdsc rollback base-ai-esop-production-backend --to 4f21ab9history interleaves three records by time: which versions exist (Secret Manager),
which of them wdsc wrote and from which commit (the journal), and how the file
changed (git).
Rollback reads from git, not from an old version. The version policy leaves previous versions disabled, and a disabled version's payload cannot be read at all — so Secret Manager is not a place content can be recovered from. Git is. This is why each push records the commit its content came from, and why a push makes two commits: the content, then the record. They are two facts.
Rollback puts both sides back — the local file and a new remote version — so the repo and Secret Manager agree afterwards.
wdsc status --json --exit-code| exit | meaning |
|---|---|
| 0 | everything in sync |
| 1 | wdsc could not complete |
| 2 | the check ran and found secrets needing attention |
Telling "there is drift" apart from "the tool broke" is the point of separating 1 and
2. format only and orphan do not count as drift by default.
| key | |
|---|---|
↑ ↓ | move · → focus the diff pane · ← back |
space | mark a row · a mark everything shown, or clear |
pP | push / pull the marked rows, or the selected one |
/ | filter · A only what needs attention · 0 clear |
tab | cycle project scope |
x | reveal a value · X reveal the whole secret |
r | refresh · v verify every payload |
? | help |
The filter mirrors the selection flags: env:production app:esop kind:backend,
status:diverged, git:staged, confidence:inferred, and bare words match the
name. Repeat a key to widen it: app:esop app:task.
wdsc init writes a starter .wdsc.yaml by inspecting the directory. The parts worth
knowing:
projects:
- dir: base-ggcloud-dev # the directory is the projectproject_id: base-ggcloud-devlabel: dev/staging
- dir: base-ggcloud-productionproject_id: base-ggcloud-productionlabel: PRODprotected: true # pushes here need the label typednaming:
secret_name: "{basename_no_ext}"facets: # drive filtering; first capture group is the valueenv: '-(dev|staging|production)(-|$)'app: '^base-ai-([a-z0-9]+)-'selection_shortcut: # expands -e/-t/-atemplate: "base-ai-{app}-{env}-{type}"default_types: [backend, config]env_to_dir:
dev: base-ggcloud-devstaging: base-ggcloud-dev # several environments may share a directoryproduction: base-ggcloud-productionpush:
disable_previous_versions: true # only the newest version stays readableon_key_removal: warn # or blockgit:
auto_commit_push: trueauto_commit_pull: trueFacet patterns are searched anywhere in the name, not at a fixed position, because
naming conventions drift: base-ai-<app>-<env>-<kind> and
base-ai-coreservices-<svc>-config-<env> both occur, with the environment in
different places.
Two files in one project resolving to the same secret name is an error, not a guess — pushing either would silently clobber the other. The same name in different projects is fine and common.
.wdsc/journal.jsonl— commit this. Append-only. It is the baseline and the audit log: what was pushed, when, by whom, from which commit. Digests only..wdsc/cache.json— gitignored. Last-observed remote state, so the view opens populated. Disposable; deleting it costs one slower run.
The selection flags are deliberately the same, so muscle memory carries over:
wdsc push -e production -t backend -a esop,task
wdsc status base-ggcloud-dev/base-ai-platform-config-dev.jsonWhat is different:
-ealone works.wdsc status -e productionis a question a per-file script cannot ask.Nothing is written without a preview, and removals need their own answer.
A
.jsonfile that stopped parsing is blocked instead of pushed.Diffs are masked by default rather than printing values to the terminal.
wdscreads Application Default Credentials, not thegcloudCLI account. Being logged into one says nothing about the other:gcloud auth application-default login
The roles needed are
roles/secretmanager.viewerto see status androles/secretmanager.secretAccessorto compare payloads.
make # list the targets
make check # formatting, vet, and the full suite under -race
make demo # run against a generated repo, no credentials
make tui # the interactive view, likewiseEverything is built and tested against generated data. internal/fakerepo produces a
repo with the awkward shapes that matter: two projects, three naming variants, a name
shared across projects, loose files, ignored files, a non-JSON secret, and a full
spread of situations. internal/gcp has an in-memory Secret Manager that counts
calls, which is how the "payload reads stay proportional to suspects" property is
asserted rather than assumed.
make scale # the perf property, reported as a ratio
make cover # coverage summary
make frames # dump TUI frames to /tmp for reviewing layout without a terminalmake lint and make staticcheck are optional passes and both refuse to run a
binary that is too old to typecheck this module — an old golangci-lint reports
imaginary undefined imports rather than real findings, which is worse than not
running. make tools installs current versions. go vet always works and is part of
make check.
git tag v0.1.0
make dist # darwin and linux, arm64 and amd64, with checksumsThe version, commit and build date are stamped in with -ldflags from
git describe, so wdsc --version identifies the exact build. Release builds are
-trimpath and stripped. make completions generates bash, zsh and fish
completions.
CI calls make ci, so the steps live in one place and moving to another CI system is
a ten-line wrapper rather than a rewrite.
Layout:
| package | |
|---|---|
config | .wdsc.yaml, walk-up discovery |
scan | repo walk, mapping, facets, digests |
journal | append-only log, folded into the baseline |
cache | last-observed remote state |
gcp | Secret Manager client and fake |
state | the three-phase resolution engine |
jsondiff | canonicalisation, key-path diff, masking |
apply | plans, gates, execution |
gitx | git via the command line |
tui | the interactive view |
cli | commands |
fakerepo | generated test data |