Skip to content

Repository files navigation

wdsc — what-diff-secrets

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

Why it exists

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 knowsSecret Manager knows
what I changedyesno
what is live right nownoyes
whether I pushed itnono

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".

Install

go install github.com/ithaqua/wdsc/cmd/wdsc@latest

Then, 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 view

wdsc 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.

Try it without credentials

wdsc demo --tui # a generated repo and an in-memory Secret Manager
wdsc demo --diff # key-level diffs
wdsc demo --json # the machine-readable shape

The 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.

How it decides

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
statusmeaning
in synclocal and remote match
local aheadyou edited it; the remote is untouched → push
remote aheadsomeone changed it in GCP; your file is unchanged → pull
divergedboth moved since the last sync → resolve by hand
format onlysame JSON data, different bytes (indent, key order, trailing newline)
driftthey differ and there is no baseline, so the direction is unknowable
untrackeda local file with no secret in GCP yet
orphana secret in GCP with no local file
unmappeda file outside every project directory; shown, never pushed
errorunreadable, unresolvable, or a disabled newest version

The speed comes from version numbers

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 ~)

~ means inferred

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.

Secret values

  • 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.x in 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.
  • --json cannot 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.

Writing

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 nothing

Every write is previewed, and three things are refused unless answered specifically:

  1. 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--yes does not cover it.
  2. A protected project wants its name typed, once for the whole batch. Mark one in .wdsc.yaml with protected: true.
  3. Content that would break an app is blocked, not confirmed. An empty file, a .json file 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.

Pulls are cheaper than pushes

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.

History and rollback

wdsc history base-ai-esop-production-backend
wdsc rollback base-ai-esop-production-backend --to 4f21ab9

history 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.

In CI

wdsc status --json --exit-code
exitmeaning
0everything in sync
1wdsc could not complete
2the 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.

Interactive view

key
↑ ↓move · focus the diff pane · back
spacemark a row · a mark everything shown, or clear
pPpush / pull the marked rows, or the selected one
/filter · A only what needs attention · 0 clear
tabcycle project scope
xreveal a value · X reveal the whole secret
rrefresh · 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.

Configuration

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: true

Facet 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.

State files

  • .wdsc/journal.jsonlcommit this. Append-only. It is the baseline and the audit log: what was pushed, when, by whom, from which commit. Digests only.
  • .wdsc/cache.jsongitignored. Last-observed remote state, so the view opens populated. Disposable; deleting it costs one slower run.

Coming from a sync script

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.json

What is different:

  • -e alone works. wdsc status -e production is a question a per-file script cannot ask.

  • Nothing is written without a preview, and removals need their own answer.

  • A .json file that stopped parsing is blocked instead of pushed.

  • Diffs are masked by default rather than printing values to the terminal.

  • wdsc reads Application Default Credentials, not the gcloud CLI account. Being logged into one says nothing about the other:

    gcloud auth application-default login

    The roles needed are roles/secretmanager.viewer to see status and roles/secretmanager.secretAccessor to compare payloads.

Development

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, likewise

Everything 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 terminal

make 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.

Releasing

git tag v0.1.0
make dist # darwin and linux, arm64 and amd64, with checksums

The 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
scanrepo walk, mapping, facets, digests
journalappend-only log, folded into the baseline
cachelast-observed remote state
gcpSecret Manager client and fake
statethe three-phase resolution engine
jsondiffcanonicalisation, key-path diff, masking
applyplans, gates, execution
gitxgit via the command line
tuithe interactive view
clicommands
fakerepogenerated test data

About

A terminal tool for tracking a directory of secret files against GCP Secret Manager.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
GitHub - ITHALab/what-diff-secrets: A terminal tool for tracking a directory of secret files against GCP Secret Manager. · GitHub
Skip to content

Repository files navigation

wdsc — what-diff-secrets

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

Why it exists

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 knowsSecret Manager knows
what I changedyesno
what is live right nownoyes
whether I pushed itnono

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".

Install

go install github.com/ithaqua/wdsc/cmd/wdsc@latest

Then, 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 view

wdsc 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.

Try it without credentials

wdsc demo --tui # a generated repo and an in-memory Secret Manager
wdsc demo --diff # key-level diffs
wdsc demo --json # the machine-readable shape

The 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.

How it decides

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
statusmeaning
in synclocal and remote match
local aheadyou edited it; the remote is untouched → push
remote aheadsomeone changed it in GCP; your file is unchanged → pull
divergedboth moved since the last sync → resolve by hand
format onlysame JSON data, different bytes (indent, key order, trailing newline)
driftthey differ and there is no baseline, so the direction is unknowable
untrackeda local file with no secret in GCP yet
orphana secret in GCP with no local file
unmappeda file outside every project directory; shown, never pushed
errorunreadable, unresolvable, or a disabled newest version

The speed comes from version numbers

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 ~)

~ means inferred

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.

Secret values

  • 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.x in 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.
  • --json cannot 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.

Writing

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 nothing

Every write is previewed, and three things are refused unless answered specifically:

  1. 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--yes does not cover it.
  2. A protected project wants its name typed, once for the whole batch. Mark one in .wdsc.yaml with protected: true.
  3. Content that would break an app is blocked, not confirmed. An empty file, a .json file 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.

Pulls are cheaper than pushes

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.

History and rollback

wdsc history base-ai-esop-production-backend
wdsc rollback base-ai-esop-production-backend --to 4f21ab9

history 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.

In CI

wdsc status --json --exit-code
exitmeaning
0everything in sync
1wdsc could not complete
2the 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.

Interactive view

key
↑ ↓move · focus the diff pane · back
spacemark a row · a mark everything shown, or clear
pPpush / pull the marked rows, or the selected one
/filter · A only what needs attention · 0 clear
tabcycle project scope
xreveal a value · X reveal the whole secret
rrefresh · 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.

Configuration

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: true

Facet 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.

State files

  • .wdsc/journal.jsonlcommit this. Append-only. It is the baseline and the audit log: what was pushed, when, by whom, from which commit. Digests only.
  • .wdsc/cache.jsongitignored. Last-observed remote state, so the view opens populated. Disposable; deleting it costs one slower run.

Coming from a sync script

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.json

What is different:

  • -e alone works. wdsc status -e production is a question a per-file script cannot ask.

  • Nothing is written without a preview, and removals need their own answer.

  • A .json file that stopped parsing is blocked instead of pushed.

  • Diffs are masked by default rather than printing values to the terminal.

  • wdsc reads Application Default Credentials, not the gcloud CLI account. Being logged into one says nothing about the other:

    gcloud auth application-default login

    The roles needed are roles/secretmanager.viewer to see status and roles/secretmanager.secretAccessor to compare payloads.

Development

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, likewise

Everything 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 terminal

make 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.

Releasing

git tag v0.1.0
make dist # darwin and linux, arm64 and amd64, with checksums

The 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
scanrepo walk, mapping, facets, digests
journalappend-only log, folded into the baseline
cachelast-observed remote state
gcpSecret Manager client and fake
statethe three-phase resolution engine
jsondiffcanonicalisation, key-path diff, masking
applyplans, gates, execution
gitxgit via the command line
tuithe interactive view
clicommands
fakerepogenerated test data

About

A terminal tool for tracking a directory of secret files against GCP Secret Manager.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' GitHub - ITHALab/what-diff-secrets: A terminal tool for tracking a directory of secret files against GCP Secret Manager. · GitHub
Skip to content

Repository files navigation

wdsc — what-diff-secrets

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

Why it exists

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 knowsSecret Manager knows
what I changedyesno
what is live right nownoyes
whether I pushed itnono

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".

Install

go install github.com/ithaqua/wdsc/cmd/wdsc@latest

Then, 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 view

wdsc 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.

Try it without credentials

wdsc demo --tui # a generated repo and an in-memory Secret Manager
wdsc demo --diff # key-level diffs
wdsc demo --json # the machine-readable shape

The 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.

How it decides

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
statusmeaning
in synclocal and remote match
local aheadyou edited it; the remote is untouched → push
remote aheadsomeone changed it in GCP; your file is unchanged → pull
divergedboth moved since the last sync → resolve by hand
format onlysame JSON data, different bytes (indent, key order, trailing newline)
driftthey differ and there is no baseline, so the direction is unknowable
untrackeda local file with no secret in GCP yet
orphana secret in GCP with no local file
unmappeda file outside every project directory; shown, never pushed
errorunreadable, unresolvable, or a disabled newest version

The speed comes from version numbers

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 ~)

~ means inferred

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.

Secret values

  • 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.x in 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.
  • --json cannot 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.

Writing

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 nothing

Every write is previewed, and three things are refused unless answered specifically:

  1. 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--yes does not cover it.
  2. A protected project wants its name typed, once for the whole batch. Mark one in .wdsc.yaml with protected: true.
  3. Content that would break an app is blocked, not confirmed. An empty file, a .json file 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.

Pulls are cheaper than pushes

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.

History and rollback

wdsc history base-ai-esop-production-backend
wdsc rollback base-ai-esop-production-backend --to 4f21ab9

history 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.

In CI

wdsc status --json --exit-code
exitmeaning
0everything in sync
1wdsc could not complete
2the 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.

Interactive view

key
↑ ↓move · focus the diff pane · back
spacemark a row · a mark everything shown, or clear
pPpush / pull the marked rows, or the selected one
/filter · A only what needs attention · 0 clear
tabcycle project scope
xreveal a value · X reveal the whole secret
rrefresh · 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.

Configuration

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: true

Facet 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.

State files

  • .wdsc/journal.jsonlcommit this. Append-only. It is the baseline and the audit log: what was pushed, when, by whom, from which commit. Digests only.
  • .wdsc/cache.jsongitignored. Last-observed remote state, so the view opens populated. Disposable; deleting it costs one slower run.

Coming from a sync script

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.json

What is different:

  • -e alone works. wdsc status -e production is a question a per-file script cannot ask.

  • Nothing is written without a preview, and removals need their own answer.

  • A .json file that stopped parsing is blocked instead of pushed.

  • Diffs are masked by default rather than printing values to the terminal.

  • wdsc reads Application Default Credentials, not the gcloud CLI account. Being logged into one says nothing about the other:

    gcloud auth application-default login

    The roles needed are roles/secretmanager.viewer to see status and roles/secretmanager.secretAccessor to compare payloads.

Development

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, likewise

Everything 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 terminal

make 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.

Releasing

git tag v0.1.0
make dist # darwin and linux, arm64 and amd64, with checksums

The 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
scanrepo walk, mapping, facets, digests
journalappend-only log, folded into the baseline
cachelast-observed remote state
gcpSecret Manager client and fake
statethe three-phase resolution engine
jsondiffcanonicalisation, key-path diff, masking
applyplans, gates, execution
gitxgit via the command line
tuithe interactive view
clicommands
fakerepogenerated test data

About

A terminal tool for tracking a directory of secret files against GCP Secret Manager.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' GitHub - ITHALab/what-diff-secrets: A terminal tool for tracking a directory of secret files against GCP Secret Manager. · GitHub
Skip to content

Repository files navigation

wdsc — what-diff-secrets

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

Why it exists

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 knowsSecret Manager knows
what I changedyesno
what is live right nownoyes
whether I pushed itnono

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".

Install

go install github.com/ithaqua/wdsc/cmd/wdsc@latest

Then, 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 view

wdsc 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.

Try it without credentials

wdsc demo --tui # a generated repo and an in-memory Secret Manager
wdsc demo --diff # key-level diffs
wdsc demo --json # the machine-readable shape

The 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.

How it decides

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
statusmeaning
in synclocal and remote match
local aheadyou edited it; the remote is untouched → push
remote aheadsomeone changed it in GCP; your file is unchanged → pull
divergedboth moved since the last sync → resolve by hand
format onlysame JSON data, different bytes (indent, key order, trailing newline)
driftthey differ and there is no baseline, so the direction is unknowable
untrackeda local file with no secret in GCP yet
orphana secret in GCP with no local file
unmappeda file outside every project directory; shown, never pushed
errorunreadable, unresolvable, or a disabled newest version

The speed comes from version numbers

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 ~)

~ means inferred

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.

Secret values

  • 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.x in 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.
  • --json cannot 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.

Writing

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 nothing

Every write is previewed, and three things are refused unless answered specifically:

  1. 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--yes does not cover it.
  2. A protected project wants its name typed, once for the whole batch. Mark one in .wdsc.yaml with protected: true.
  3. Content that would break an app is blocked, not confirmed. An empty file, a .json file 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.

Pulls are cheaper than pushes

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.

History and rollback

wdsc history base-ai-esop-production-backend
wdsc rollback base-ai-esop-production-backend --to 4f21ab9

history 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.

In CI

wdsc status --json --exit-code
exitmeaning
0everything in sync
1wdsc could not complete
2the 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.

Interactive view

key
↑ ↓move · focus the diff pane · back
spacemark a row · a mark everything shown, or clear
pPpush / pull the marked rows, or the selected one
/filter · A only what needs attention · 0 clear
tabcycle project scope
xreveal a value · X reveal the whole secret
rrefresh · 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.

Configuration

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: true

Facet 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.

State files

  • .wdsc/journal.jsonlcommit this. Append-only. It is the baseline and the audit log: what was pushed, when, by whom, from which commit. Digests only.
  • .wdsc/cache.jsongitignored. Last-observed remote state, so the view opens populated. Disposable; deleting it costs one slower run.

Coming from a sync script

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.json

What is different:

  • -e alone works. wdsc status -e production is a question a per-file script cannot ask.

  • Nothing is written without a preview, and removals need their own answer.

  • A .json file that stopped parsing is blocked instead of pushed.

  • Diffs are masked by default rather than printing values to the terminal.

  • wdsc reads Application Default Credentials, not the gcloud CLI account. Being logged into one says nothing about the other:

    gcloud auth application-default login

    The roles needed are roles/secretmanager.viewer to see status and roles/secretmanager.secretAccessor to compare payloads.

Development

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, likewise

Everything 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 terminal

make 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.

Releasing

git tag v0.1.0
make dist # darwin and linux, arm64 and amd64, with checksums

The 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
scanrepo walk, mapping, facets, digests
journalappend-only log, folded into the baseline
cachelast-observed remote state
gcpSecret Manager client and fake
statethe three-phase resolution engine
jsondiffcanonicalisation, key-path diff, masking
applyplans, gates, execution
gitxgit via the command line
tuithe interactive view
clicommands
fakerepogenerated test data

About

A terminal tool for tracking a directory of secret files against GCP Secret Manager.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' GitHub - ITHALab/what-diff-secrets: A terminal tool for tracking a directory of secret files against GCP Secret Manager. · GitHub
Skip to content

Repository files navigation

wdsc — what-diff-secrets

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

Why it exists

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 knowsSecret Manager knows
what I changedyesno
what is live right nownoyes
whether I pushed itnono

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".

Install

go install github.com/ithaqua/wdsc/cmd/wdsc@latest

Then, 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 view

wdsc 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.

Try it without credentials

wdsc demo --tui # a generated repo and an in-memory Secret Manager
wdsc demo --diff # key-level diffs
wdsc demo --json # the machine-readable shape

The 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.

How it decides

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
statusmeaning
in synclocal and remote match
local aheadyou edited it; the remote is untouched → push
remote aheadsomeone changed it in GCP; your file is unchanged → pull
divergedboth moved since the last sync → resolve by hand
format onlysame JSON data, different bytes (indent, key order, trailing newline)
driftthey differ and there is no baseline, so the direction is unknowable
untrackeda local file with no secret in GCP yet
orphana secret in GCP with no local file
unmappeda file outside every project directory; shown, never pushed
errorunreadable, unresolvable, or a disabled newest version

The speed comes from version numbers

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 ~)

~ means inferred

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.

Secret values

  • 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.x in 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.
  • --json cannot 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.

Writing

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 nothing

Every write is previewed, and three things are refused unless answered specifically:

  1. 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--yes does not cover it.
  2. A protected project wants its name typed, once for the whole batch. Mark one in .wdsc.yaml with protected: true.
  3. Content that would break an app is blocked, not confirmed. An empty file, a .json file 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.

Pulls are cheaper than pushes

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.

History and rollback

wdsc history base-ai-esop-production-backend
wdsc rollback base-ai-esop-production-backend --to 4f21ab9

history 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.

In CI

wdsc status --json --exit-code
exitmeaning
0everything in sync
1wdsc could not complete
2the 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.

Interactive view

key
↑ ↓move · focus the diff pane · back
spacemark a row · a mark everything shown, or clear
pPpush / pull the marked rows, or the selected one
/filter · A only what needs attention · 0 clear
tabcycle project scope
xreveal a value · X reveal the whole secret
rrefresh · 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.

Configuration

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: true

Facet 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.

State files

  • .wdsc/journal.jsonlcommit this. Append-only. It is the baseline and the audit log: what was pushed, when, by whom, from which commit. Digests only.
  • .wdsc/cache.jsongitignored. Last-observed remote state, so the view opens populated. Disposable; deleting it costs one slower run.

Coming from a sync script

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.json

What is different:

  • -e alone works. wdsc status -e production is a question a per-file script cannot ask.

  • Nothing is written without a preview, and removals need their own answer.

  • A .json file that stopped parsing is blocked instead of pushed.

  • Diffs are masked by default rather than printing values to the terminal.

  • wdsc reads Application Default Credentials, not the gcloud CLI account. Being logged into one says nothing about the other:

    gcloud auth application-default login

    The roles needed are roles/secretmanager.viewer to see status and roles/secretmanager.secretAccessor to compare payloads.

Development

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, likewise

Everything 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 terminal

make 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.

Releasing

git tag v0.1.0
make dist # darwin and linux, arm64 and amd64, with checksums

The 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
scanrepo walk, mapping, facets, digests
journalappend-only log, folded into the baseline
cachelast-observed remote state
gcpSecret Manager client and fake
statethe three-phase resolution engine
jsondiffcanonicalisation, key-path diff, masking
applyplans, gates, execution
gitxgit via the command line
tuithe interactive view
clicommands
fakerepogenerated test data

About

A terminal tool for tracking a directory of secret files against GCP Secret Manager.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' GitHub - ITHALab/what-diff-secrets: A terminal tool for tracking a directory of secret files against GCP Secret Manager. · GitHub
Skip to content

Repository files navigation

wdsc — what-diff-secrets

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

Why it exists

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 knowsSecret Manager knows
what I changedyesno
what is live right nownoyes
whether I pushed itnono

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".

Install

go install github.com/ithaqua/wdsc/cmd/wdsc@latest

Then, 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 view

wdsc 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.

Try it without credentials

wdsc demo --tui # a generated repo and an in-memory Secret Manager
wdsc demo --diff # key-level diffs
wdsc demo --json # the machine-readable shape

The 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.

How it decides

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
statusmeaning
in synclocal and remote match
local aheadyou edited it; the remote is untouched → push
remote aheadsomeone changed it in GCP; your file is unchanged → pull
divergedboth moved since the last sync → resolve by hand
format onlysame JSON data, different bytes (indent, key order, trailing newline)
driftthey differ and there is no baseline, so the direction is unknowable
untrackeda local file with no secret in GCP yet
orphana secret in GCP with no local file
unmappeda file outside every project directory; shown, never pushed
errorunreadable, unresolvable, or a disabled newest version

The speed comes from version numbers

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 ~)

~ means inferred

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.

Secret values

  • 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.x in 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.
  • --json cannot 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.

Writing

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 nothing

Every write is previewed, and three things are refused unless answered specifically:

  1. 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--yes does not cover it.
  2. A protected project wants its name typed, once for the whole batch. Mark one in .wdsc.yaml with protected: true.
  3. Content that would break an app is blocked, not confirmed. An empty file, a .json file 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.

Pulls are cheaper than pushes

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.

History and rollback

wdsc history base-ai-esop-production-backend
wdsc rollback base-ai-esop-production-backend --to 4f21ab9

history 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.

In CI

wdsc status --json --exit-code
exitmeaning
0everything in sync
1wdsc could not complete
2the 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.

Interactive view

key
↑ ↓move · focus the diff pane · back
spacemark a row · a mark everything shown, or clear
pPpush / pull the marked rows, or the selected one
/filter · A only what needs attention · 0 clear
tabcycle project scope
xreveal a value · X reveal the whole secret
rrefresh · 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.

Configuration

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: true

Facet 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.

State files

  • .wdsc/journal.jsonlcommit this. Append-only. It is the baseline and the audit log: what was pushed, when, by whom, from which commit. Digests only.
  • .wdsc/cache.jsongitignored. Last-observed remote state, so the view opens populated. Disposable; deleting it costs one slower run.

Coming from a sync script

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.json

What is different:

  • -e alone works. wdsc status -e production is a question a per-file script cannot ask.

  • Nothing is written without a preview, and removals need their own answer.

  • A .json file that stopped parsing is blocked instead of pushed.

  • Diffs are masked by default rather than printing values to the terminal.

  • wdsc reads Application Default Credentials, not the gcloud CLI account. Being logged into one says nothing about the other:

    gcloud auth application-default login

    The roles needed are roles/secretmanager.viewer to see status and roles/secretmanager.secretAccessor to compare payloads.

Development

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, likewise

Everything 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 terminal

make 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.

Releasing

git tag v0.1.0
make dist # darwin and linux, arm64 and amd64, with checksums

The 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
scanrepo walk, mapping, facets, digests
journalappend-only log, folded into the baseline
cachelast-observed remote state
gcpSecret Manager client and fake
statethe three-phase resolution engine
jsondiffcanonicalisation, key-path diff, masking
applyplans, gates, execution
gitxgit via the command line
tuithe interactive view
clicommands
fakerepogenerated test data

About

A terminal tool for tracking a directory of secret files against GCP Secret Manager.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' GitHub - ITHALab/what-diff-secrets: A terminal tool for tracking a directory of secret files against GCP Secret Manager. · GitHub
Skip to content

Repository files navigation

wdsc — what-diff-secrets

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

Why it exists

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 knowsSecret Manager knows
what I changedyesno
what is live right nownoyes
whether I pushed itnono

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".

Install

go install github.com/ithaqua/wdsc/cmd/wdsc@latest

Then, 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 view

wdsc 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.

Try it without credentials

wdsc demo --tui # a generated repo and an in-memory Secret Manager
wdsc demo --diff # key-level diffs
wdsc demo --json # the machine-readable shape

The 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.

How it decides

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
statusmeaning
in synclocal and remote match
local aheadyou edited it; the remote is untouched → push
remote aheadsomeone changed it in GCP; your file is unchanged → pull
divergedboth moved since the last sync → resolve by hand
format onlysame JSON data, different bytes (indent, key order, trailing newline)
driftthey differ and there is no baseline, so the direction is unknowable
untrackeda local file with no secret in GCP yet
orphana secret in GCP with no local file
unmappeda file outside every project directory; shown, never pushed
errorunreadable, unresolvable, or a disabled newest version

The speed comes from version numbers

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 ~)

~ means inferred

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.

Secret values

  • 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.x in 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.
  • --json cannot 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.

Writing

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 nothing

Every write is previewed, and three things are refused unless answered specifically:

  1. 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--yes does not cover it.
  2. A protected project wants its name typed, once for the whole batch. Mark one in .wdsc.yaml with protected: true.
  3. Content that would break an app is blocked, not confirmed. An empty file, a .json file 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.

Pulls are cheaper than pushes

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.

History and rollback

wdsc history base-ai-esop-production-backend
wdsc rollback base-ai-esop-production-backend --to 4f21ab9

history 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.

In CI

wdsc status --json --exit-code
exitmeaning
0everything in sync
1wdsc could not complete
2the 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.

Interactive view

key
↑ ↓move · focus the diff pane · back
spacemark a row · a mark everything shown, or clear
pPpush / pull the marked rows, or the selected one
/filter · A only what needs attention · 0 clear
tabcycle project scope
xreveal a value · X reveal the whole secret
rrefresh · 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.

Configuration

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: true

Facet 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.

State files

  • .wdsc/journal.jsonlcommit this. Append-only. It is the baseline and the audit log: what was pushed, when, by whom, from which commit. Digests only.
  • .wdsc/cache.jsongitignored. Last-observed remote state, so the view opens populated. Disposable; deleting it costs one slower run.

Coming from a sync script

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.json

What is different:

  • -e alone works. wdsc status -e production is a question a per-file script cannot ask.

  • Nothing is written without a preview, and removals need their own answer.

  • A .json file that stopped parsing is blocked instead of pushed.

  • Diffs are masked by default rather than printing values to the terminal.

  • wdsc reads Application Default Credentials, not the gcloud CLI account. Being logged into one says nothing about the other:

    gcloud auth application-default login

    The roles needed are roles/secretmanager.viewer to see status and roles/secretmanager.secretAccessor to compare payloads.

Development

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, likewise

Everything 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 terminal

make 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.

Releasing

git tag v0.1.0
make dist # darwin and linux, arm64 and amd64, with checksums

The 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
scanrepo walk, mapping, facets, digests
journalappend-only log, folded into the baseline
cachelast-observed remote state
gcpSecret Manager client and fake
statethe three-phase resolution engine
jsondiffcanonicalisation, key-path diff, masking
applyplans, gates, execution
gitxgit via the command line
tuithe interactive view
clicommands
fakerepogenerated test data

About

A terminal tool for tracking a directory of secret files against GCP Secret Manager.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); GitHub - ITHALab/what-diff-secrets: A terminal tool for tracking a directory of secret files against GCP Secret Manager. · GitHub
Skip to content

Repository files navigation

wdsc — what-diff-secrets

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

Why it exists

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 knowsSecret Manager knows
what I changedyesno
what is live right nownoyes
whether I pushed itnono

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".

Install

go install github.com/ithaqua/wdsc/cmd/wdsc@latest

Then, 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 view

wdsc 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.

Try it without credentials

wdsc demo --tui # a generated repo and an in-memory Secret Manager
wdsc demo --diff # key-level diffs
wdsc demo --json # the machine-readable shape

The 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.

How it decides

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
statusmeaning
in synclocal and remote match
local aheadyou edited it; the remote is untouched → push
remote aheadsomeone changed it in GCP; your file is unchanged → pull
divergedboth moved since the last sync → resolve by hand
format onlysame JSON data, different bytes (indent, key order, trailing newline)
driftthey differ and there is no baseline, so the direction is unknowable
untrackeda local file with no secret in GCP yet
orphana secret in GCP with no local file
unmappeda file outside every project directory; shown, never pushed
errorunreadable, unresolvable, or a disabled newest version

The speed comes from version numbers

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 ~)

~ means inferred

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.

Secret values

  • 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.x in 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.
  • --json cannot 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.

Writing

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 nothing

Every write is previewed, and three things are refused unless answered specifically:

  1. 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--yes does not cover it.
  2. A protected project wants its name typed, once for the whole batch. Mark one in .wdsc.yaml with protected: true.
  3. Content that would break an app is blocked, not confirmed. An empty file, a .json file 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.

Pulls are cheaper than pushes

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.

History and rollback

wdsc history base-ai-esop-production-backend
wdsc rollback base-ai-esop-production-backend --to 4f21ab9

history 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.

In CI

wdsc status --json --exit-code
exitmeaning
0everything in sync
1wdsc could not complete
2the 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.

Interactive view

key
↑ ↓move · focus the diff pane · back
spacemark a row · a mark everything shown, or clear
pPpush / pull the marked rows, or the selected one
/filter · A only what needs attention · 0 clear
tabcycle project scope
xreveal a value · X reveal the whole secret
rrefresh · 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.

Configuration

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: true

Facet 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.

State files

  • .wdsc/journal.jsonlcommit this. Append-only. It is the baseline and the audit log: what was pushed, when, by whom, from which commit. Digests only.
  • .wdsc/cache.jsongitignored. Last-observed remote state, so the view opens populated. Disposable; deleting it costs one slower run.

Coming from a sync script

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.json

What is different:

  • -e alone works. wdsc status -e production is a question a per-file script cannot ask.

  • Nothing is written without a preview, and removals need their own answer.

  • A .json file that stopped parsing is blocked instead of pushed.

  • Diffs are masked by default rather than printing values to the terminal.

  • wdsc reads Application Default Credentials, not the gcloud CLI account. Being logged into one says nothing about the other:

    gcloud auth application-default login

    The roles needed are roles/secretmanager.viewer to see status and roles/secretmanager.secretAccessor to compare payloads.

Development

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, likewise

Everything 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 terminal

make 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.

Releasing

git tag v0.1.0
make dist # darwin and linux, arm64 and amd64, with checksums

The 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
scanrepo walk, mapping, facets, digests
journalappend-only log, folded into the baseline
cachelast-observed remote state
gcpSecret Manager client and fake
statethe three-phase resolution engine
jsondiffcanonicalisation, key-path diff, masking
applyplans, gates, execution
gitxgit via the command line
tuithe interactive view
clicommands
fakerepogenerated test data

About

A terminal tool for tracking a directory of secret files against GCP Secret Manager.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages