Skip to content

feat(secret): --from-file for secret create and secret set - #14

Merged
anak10thn merged 4 commits into
mainfrom
feat/secret-from-file
Aug 6, 2026
Merged

feat(secret): --from-file for secret create and secret set#14
anak10thn merged 4 commits into
mainfrom
feat/secret-from-file

Conversation

@anak10thn

Copy link
Copy Markdown
Member

Problem

Importing a file into a Secret only worked through compose (secrets: {name: {file: ./path}}). From the CLI:

$ orcinus secret create foo --from-file ./bar.txt
Error: unknown flag: --from-file

The usual workaround is not equivalent:

file asli  : LS0tLS1CRUdJTiBLRVktLS0tLQphYmMKLS0tLS1FTkQgS0VZLS0tLS0K
via $(cat) : LS0tLS1CRUdJTiBLRVktLS0tLQphYmMKLS0tLS1FTkQgS0VZLS0tLS0=

$(cat f) strips the trailing newline, so a PEM key or token arrives subtly wrong and whatever rejects it points nowhere near the cause. Arguments are also capped at ARG_MAX and cannot carry NUL, so binary was impossible.

Change

orcinus secret create certs --from-file ./cert.pem            # key: "cert.pem"
orcinus secret create certs --from-file tls.pem=./cert.pem    # key: "tls.pem"
orcinus secret create appconf --from-file ./conf.d            # one key per file

Read with os.ReadFile, so bytes land verbatim. Repeatable, mixable with --from-literal, and secret set takes the same inputs and still merges.

Decisions worth reviewing:

  • Keys validated up front against what Kubernetes accepts, so not a key.txt is reported with the fix (pass KEY=./not a key.txt to name it) instead of being rejected later by the API server.
  • A key set twice is an error, whether by two files or a file and a literal. Silently keeping one of two values seemed worse than refusing and naming both.
  • Nested directories are skipped, not flattened, so keys stay predictable.
  • Nothing is written until every input parses — a bad flag leaves no half-built Secret.

API counterpart

SecretRequest gains dataBase64, honoured by both POST and PATCH:

curl -d "{\"name\":\"apikey\",\"dataBase64\":{\"apikey\":\"$(base64 < api.key | tr -d '\n')\"}}" .../api/v1/secrets

Deliberately not a file path: a path in a request body would name a file on the server, not the caller's machine, and invite reading arbitrary server files. A key may appear in data or dataBase64, not both.

Verification

Live against the 3-node cluster, all three --from-file forms plus a literal in one command → 5 keys:

KEY        VALUE
MODE       <5 bytes, hidden>     # --from-literal
api.key    <42 bytes, hidden>    # bare path
blob       <5 bytes, hidden>     # explicit key
one.conf   <4 bytes, hidden>     # directory
two.conf   <4 bytes, hidden>     # directory

Byte-fidelity, comparing the file's base64 to what the cluster stored:

file cluster
PEM with trailing newline …LS0tLS0K …LS0tLS0K
binary (A \x00 B \xFF C) QQBC/0M= QQBC/0M=

secret set --from-file updated one key and left the other four alone. Every error case reported clearly and created nothing:

Error: --from-file "./not a key.txt": "not a key.txt" is not a usable key; pass KEY=./not a key.txt to name it
Error: --from-file "./nope.txt": stat ./nope.txt: no such file or directory
Error: --from-file "./api.key": key "api.key" was already set by another flag
Error: --from-file "./emptydir": directory has no files

dataBase64 verified live too: binary round-tripped identically through POST, and PATCH merged without disturbing the existing key.

Offline: 12 new tests (base-name key, explicit key, directory with a nested dir, several files in one command, binary, all six error cases, base64 decode and validation), suite green. Test secrets cleaned up; the cluster's own are untouched.

Note

I caught a bug in my own first draft: addFiles returned after the first file instead of continuing the loop, so only one --from-file would have landed. TestSecretDataCombined covers exactly that now.

Importing a file into a Secret was only possible through compose
(`secrets: {name: {file: ./path}}`). From the CLI the closest thing was
--from-literal KEY="$(cat file)", which is not the same thing: command
substitution strips the trailing newline, so a PEM key or token arrives subtly
wrong and the parser that rejects it points nowhere near the cause. Arguments
are also capped at ARG_MAX and cannot carry NUL, so binary was out entirely.

    --from-file ./api.key          key is the file name
    --from-file apikey=./api.key   key is given explicitly
    --from-file ./conf.d           every file in the directory becomes a key

Files are read with os.ReadFile, so the bytes land verbatim. Both flags are
repeatable and can be mixed with --from-literal; `secret set` takes the same
inputs and still merges.

Keys are validated against what Kubernetes accepts before anything is written,
so a file named "not a key.txt" is reported with the KEY=path form to fix it
rather than being rejected later by the API server. A key set twice — by two
files, or by a file and a literal — is an error naming both, because silently
keeping one of two values is worse than refusing. Nested directories are
skipped rather than flattened, so keys stay predictable.

The HTTP counterpart is a dataBase64 map on SecretRequest, honoured by both
POST and PATCH. Not a file path: a path in a request body would name a file on
the server, not on the caller's machine, and invite reading arbitrary server
files.

Verified against a live cluster — a PEM with a trailing newline and a file
containing NUL and 0xFF both round-trip byte-identical, through the CLI and
through dataBase64.
COMPOSE.md showed `secrets: {x: {file: ./path}}` in an example and never said
what it produces, so the compose-side equivalent of --from-file was effectively
undiscoverable.

The part worth spelling out is that creating a Secret and consuming it are
separate steps. The top-level entry creates it whether or not any service
references it, so pairing it with x-orcinus-env-from-secret turns a file into
environment variables with no volume anywhere — and listing it under a service's
`secrets:` mounts it as a file. Both, either, or neither.

Also records the two edges found while checking: the data key is always the
secret's name, so the variable that reaches the container is named after the
secret rather than the file (use the CLI's KEY=path form to choose); and
`environment:` as a secret source parses and is then silently skipped, unlike
`content:`, which the schema rejects outright.

Two tests pin the file-to-env path, including that it produces no volumes, since
nothing else in the suite covered it.
Auditing every claim the docs make about secrets turned up four gaps, all of
the same kind: the tables describe one route and omit the others, so a reader
scanning them draws the wrong conclusion.

- USAGE.md's compose mapping table had no `secrets:` or `configs:` row at all.
  Both are supported; someone scanning that table would conclude otherwise.
- COMPOSE.md's service-key table said `secrets` is "mounted at the target",
  which reads as the only option. Creating a Secret and consuming it are
  separate steps, and the env route is not mentioned.
- USAGE.md described x-orcinus-env-from-secret as loading an "existing" Secret
  "created with orcinus secret create". True but narrow: the Secret can equally
  come from a `secrets: {name: {file: …}}` entry in the same compose file.
- ARCHITECTURE.md's mapping table also had no configs/secrets row.

The skills card gained the same correction, since it is what an agent reads
before answering this question.

Every YAML example in the three edited COMPOSE.md sections was run through
--dry-run: the "env var and mounted file together" example produces both from
one Secret, and `target: apikey` does land at /run/secrets/apikey as its
comment claims.
…sts did not

Two gaps between what I had verified by hand and what the suite would catch on
its own.

A directory whose contents include a name Kubernetes cannot use as a key goes
through addDir → addFile, a different path from passing that file directly, and
only the direct one was covered.

The live e2e now exercises --from-file, asserting the stored bytes match the
file exactly — trailing newline included, which is the whole reason the flag
exists — and that it leaves the Secret's other keys alone. It stays behind
ORCINUS_E2E_LIVE like the rest.

Also checked by hand, all behaving correctly and left as-is: a dotfile is a
valid key, the same directory passed twice collides rather than silently
winning, symlinks are followed, and `set --from-file` on a TLS Secret keeps its
kubernetes.io/tls type.
@anak10thn
anak10thn merged commit f63a7f2 into main Aug 6, 2026
2 checks passed
@anak10thn
anak10thn deleted the feat/secret-from-file branch August 6, 2026 05:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant