YmerNode.Secrets (Ymer Node v0.2.1)

Copy Markdown View Source

The values a script resolves by name — one file of NAME=value lines, outside every database.

Why a file, and not a table

node.db is rebuildable by construction, and the durability rule (YmerNode) admitted it on exactly that ground: deleting it costs nothing a boot does not put back, and mix ecto.drop is an ordinary development move. A secrets table would make one row of it precious and the rule would have to grow an exception. A file beside the databases leaves the rule intact — the store stays the one thing worth keeping, and a lost secrets.env costs a re-set rather than a restore.

It is also hand-editable, in the dotenv shape an install's .env already uses, which is what an operator reaches for when no CLI is to hand.

Read at every resolution

Nothing is cached. A secret set while the node runs is seen by the next run, with no restart, because every get/1 opens the file. The registry is small and a run is already crossing a network, so the read costs nothing worth saving.

Parsing is deliberately small: a blank line and a #-opened line are skipped, the FIRST = splits the line, the name is trimmed, and the value is trimmed and then unwrapped once if it is wholly single- or double-quoted. There is no escape grammar and no interpolation — a value needing either belongs in a file the script reads, not in a line here. A later duplicate name wins, which is what a hand-edit appending a line expects.

Mode is checked, never assumed

set/2 creates the file 0600 and chmods it back to 0600 on every write. get/1 and list/0 refuse a file any group or other bit can read — {:error, {:secrets_file_permissive, "644"}} — because a secret in a world-readable file is not one, and an operator has to be told rather than quietly served. The check is Bitwise.band(mode, 0o077) == 0, so 0600 and 0400 both pass.

Every write is a rename

set/2 and unset/1 rewrite the whole file, so a write interrupted part-way — a kill, a stop signal during a deploy, a full disk — would take every other secret with it and not only the one being changed. A neighbouring temp file is created empty, chmodded 0600, written, and then renamed over the target: a rename within one directory is atomic, so a reader sees the old file or the new one and never half of either. Chmod before the write and not after it, because a file is created at the process umask, and one written first would hold every value readable for as long as the write took.

No value leaves this module except through get/1. list/0 answers names, errors name the secret and never its value, and nothing here logs.

Summary

Functions

Resolves one secret by name.

Every name the file sets, sorted. Names only — never a value.

Absolute path of the secrets file (config :ymer_node, YmerNode.Secrets, :path).

Writes one secret, creating the file 0600 on the first set.

Removes one secret. {:error, :secret_not_found} when the name is not set, so an unset that changed nothing says so rather than reporting success.

Functions

get(name)

Resolves one secret by name.

{:error, :secret_not_found} when the file has no such name — the same answer a file with the name commented out gives, because both mean "not set here".

list()

Every name the file sets, sorted. Names only — never a value.

path()

Absolute path of the secrets file (config :ymer_node, YmerNode.Secrets, :path).

Keyword.fetch! and not a default: every environment sets it — config/prod.exs carries /data/secrets.env, config/runtime.exs overrides it from SECRETS_PATH, dev and test point at the checkout root — and a node that cannot say where its secrets live must fail loudly rather than invent a path and write a file there.

set(name, value)

Writes one secret, creating the file 0600 on the first set.

A name already in the file is replaced in place, so hand-written comments, blank lines and ordering survive a set; a new name is appended. The whole file is rewritten, which is why set/2 refuses a value carrying a newline: one line per secret is the format's only structural rule.

unset(name)

Removes one secret. {:error, :secret_not_found} when the name is not set, so an unset that changed nothing says so rather than reporting success.