From 40013aa341f82e3283bc7db78160c79628ca2e54 Mon Sep 17 00:00:00 2001 From: Dov Benyomin Sohacheski Date: Tue, 30 Jun 2026 12:02:10 +0300 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=93=9D=20Rewrite=20seed=20docs=20for?= =?UTF-8?q?=20the=20unified=20engine,=20tombstone=20vault?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tools/seed.md: full rewrite for the ws-cli seed engine — version:v1 dest-keyed manifest, two tiers (bare mirror + behavior overlay), ops (copy/merge/append/prepend), templating, inline + whole-file secrets, ownership boundary, force/ephemerality, apply/ls. Documents the accepted caveats (non-idempotent append/prepend, JSON large-int precision, keep ciphertext outside the mirror, re-author for key rotation). - settings/vault.md: tombstone — the vault is removed (no migrate); redirect to the seed manifest + secrets primitives. Drop the sidebar entry. - tools/ws-cli.md: drop vault from secrets, add a `ws seed` section. - settings/secrets.md, settings/tls.md, editor/storage.md: repoint dead /settings/vault links and the killed "vault" term to /tools/seed. --- .vitepress/_sidebar.ts | 1 - docs/editor/storage.md | 2 +- docs/settings/secrets.md | 10 +- docs/settings/tls.md | 2 +- docs/settings/vault.md | 128 +++-------------------- docs/tools/seed.md | 221 ++++++++++++++++++++++++--------------- docs/tools/ws-cli.md | 14 ++- 7 files changed, 172 insertions(+), 206 deletions(-) diff --git a/.vitepress/_sidebar.ts b/.vitepress/_sidebar.ts index a76d2a7..625294a 100644 --- a/.vitepress/_sidebar.ts +++ b/.vitepress/_sidebar.ts @@ -49,7 +49,6 @@ export default { { text: 'Configuration', link: '/settings/configuration' }, { text: 'TLS & Certificates', link: '/settings/tls' }, { text: 'Secrets', link: '/settings/secrets' }, - { text: 'Vault', link: '/settings/vault' }, ] }, { diff --git a/docs/editor/storage.md b/docs/editor/storage.md index eb064a8..14e6202 100644 --- a/docs/editor/storage.md +++ b/docs/editor/storage.md @@ -66,7 +66,7 @@ docker volume ls Several workspace features store their configuration under `~/.ws` (`/home/kloud/.ws`), including [sideloaded extensions](/editor/extensions), [startup and session scripts](/settings/autoload-scripts), [drop-in CA certificates](/settings/tls#drop-in-directory-ws-ca-d), -the [secrets vault](/settings/secrets), and your shell and REPL history (`~/.ws/history`). +the [seed source](/tools/seed), and your shell and REPL history (`~/.ws/history`). Because this directory lives inside the container, its contents are lost on restart unless you persist it separately: diff --git a/docs/settings/secrets.md b/docs/settings/secrets.md index de456a8..f83debb 100644 --- a/docs/settings/secrets.md +++ b/docs/settings/secrets.md @@ -1,8 +1,8 @@ --- description: "Manage sensitive credentials in Kloud Workspace with the ws-cli secrets command — encrypt passwords, API keys, and SSH keys instead of storing plaintext." see: - - name: Vault - link: /settings/vault + - name: Seed + link: /tools/seed - name: ws secrets Command Reference link: /tools/ws-cli#secrets-ws-secrets target: _self @@ -78,10 +78,10 @@ echo "my-secret" | ws secrets encrypt - --master .master.key cat encrypted.txt | ws secrets decrypt - --master .master.key ``` -## Vault +## Seed -For declarative bulk secret injection using YAML vault files, see the -[Vault documentation](/settings/vault). +For declarative secret projection — inline `${secrets.NAME}` values and whole-file `secret: true` +entries in the seed manifest — see the [seed documentation](/tools/seed). ## Authentication Passwords diff --git a/docs/settings/tls.md b/docs/settings/tls.md index 69d11b6..c783a80 100644 --- a/docs/settings/tls.md +++ b/docs/settings/tls.md @@ -126,7 +126,7 @@ docker run \ This is the recommended path for ad-hoc certificate injection on a running workspace — the same persisted volume already used for [autoload scripts](/settings/autoload-scripts), extensions, and the -[secrets vault](/settings/vault). +[seed source](/tools/seed). ### Install Certificate from HTTPS Endpoint diff --git a/docs/settings/vault.md b/docs/settings/vault.md index ebfdd93..be3c4c0 100644 --- a/docs/settings/vault.md +++ b/docs/settings/vault.md @@ -1,127 +1,33 @@ --- -description: "YAML vaults in Kloud Workspace enable declarative, bulk secret injection, building on the ws-cli secrets encryption primitives." +description: "The secrets vault has been removed. Secrets are now declared in the seed manifest and projected by the ws-cli seed engine." see: + - name: Seed + link: /tools/seed - name: Secrets link: /settings/secrets - - name: ws secrets Command Reference - link: /tools/ws-cli#secrets-ws-secrets - target: _self --- # Vault -![Vault](/icons/vault.svg){.doc-image} +::: danger -YAML vaults allow bulk secret injection into the workspace. Vaults build on the -[encryption primitives](/settings/secrets) provided by `ws-cli secrets` and add structured, -declarative secret management. - -## Vault Format - -A vault is a YAML file listing one or more secrets with their encrypted values, -destinations, and types: - -```yaml -secrets: - database-password: - encrypted: Xy1z2A3... - destination: /workspace/.secrets/db-password - type: generic - mode: 0o600 - - api-key: - encrypted: Ab1c2D3... - destination: API_KEY - type: env - - ssh-private-key: - encrypted: Kl1m2N3... - destination: ~/.ssh/id_rsa - type: ssh -``` - -:::warning - -Vault secrets can only be written to user-writable paths such as `$HOME`, `/workspace`, and `/tmp`. - -System paths like `/etc/` and `/usr/` are protected by Linux file permissions since the workspace -runs as the `kloud` *(non-root)* user. +The secrets vault has been removed. The `ws-cli secrets vault` subcommand, the +`~/.ws/vault/secrets.yaml` manifest, and the standalone autoload step no longer exist. ::: -## File References - -For long encrypted values, store them in separate files using the `file:` prefix: - -```yaml -secrets: - ssh-private-key: - encrypted: file:./secrets/ssh-key.enc - destination: ~/.ssh/id_rsa - type: ssh -``` - -File paths are relative to the current working directory. - -## Secret Types - -Each secret has a `type` field that determines how it's written and its default permissions: - -| Type | Destination | Default Mode | Description | -| ------------------ | ------------- | ------------ | ---------------------------------- | -| `generic` | File path | `0o600` | General-purpose secrets | -| `ssh` | File path | `0o600` | SSH private keys | -| `env` | Variable name | `0o644` | Written to `~/.zshenv` | -| `kubeconfig` | File path | `0o600` | Kubernetes config files | -| `dockerconfigjson` | File path | `0o600` | Docker authentication config files | - -- **`--destination`:** For file types *(generic, ssh, kubeconfig, dockerconfigjson)*, this - is the file path where the secret will be written. Relative paths are resolved - from `/workspace`. - For `env` types, this is the environment variable name. -- **`--mode` *(optional)*:** File permissions in octal *(e.g., `0o600`)* or decimal *(e.g., `384`)*. - If omitted, the default mode for the type is used. -- **`--type` *(optional)***. - -## Processing a Vault - -```sh -# All secrets -ws secrets vault --input vault.yaml --master .master.key - -# Specific keys -ws secrets vault --input vault.yaml --key database-password --master .master.key - -# Inspect values -ws secrets vault --input vault.yaml --stdout --master .master.key -``` - -## Autoloading - -At startup the workspace automatically processes `~/.ws/vault/secrets.yaml` when it exists. -Place your vault manifest and any referenced encrypted files together in that directory: - -```text -~/.ws/vault/ -├── secrets.yaml # vault manifest -├── db-password.enc # encrypted file referenced via file: -└── ssh-key.enc -``` - -`~/.ws/vault/secrets.yaml` is the sole supported autoload location. To use -a vault stored elsewhere on the host, bind-mount it into the convention -path: +Secrets now live in the [seed manifest](/tools/seed), projected by the unified `ws-cli seed` engine. +There is no automatic migration — re-author your secrets into `/.seed.yaml`: -```sh -docker run \ - -v /host/path/secrets.yaml:/home/kloud/.ws/vault/secrets.yaml \ - ghcr.io/kloudkit/workspace:v0.4.0 -``` +- **Inline values** *(the old `type: env` exports)* go in the top-level `secrets:` map and render + through `${secrets.NAME}` in a `template: true` entry. +- **Whole-file secrets** *(the old `type: ssh`, `kubeconfig`, `generic`, `dockerconfigjson`)* become + `secret: true` entries keyed by their destination. -## Vault Flags +The [`ws-cli secrets`](/settings/secrets) encryption primitives — `encrypt`, `decrypt` and +`generate` — are unchanged. -- **`--input `:** Vault file path *(defaults to `~/.ws/vault/secrets.yaml`)*. -- **`--key `:** Process specific secret *(repeatable)*. -- **`--stdout`:** Inspect without writing. +## Next Steps -See [ws secrets command reference](/tools/ws-cli#secrets-ws-secrets) for complete syntax. +- [Seed](/tools/seed): declare and project secrets in the unified manifest. +- [Secrets](/settings/secrets): encrypt and decrypt values with `ws-cli secrets`. diff --git a/docs/tools/seed.md b/docs/tools/seed.md index 98a8daf..1884daa 100644 --- a/docs/tools/seed.md +++ b/docs/tools/seed.md @@ -1,155 +1,208 @@ --- -description: Project files from a durable seed directory onto the workspace filesystem on every boot, within hard security boundaries. +description: Project files and secrets from a durable seed directory onto the workspace filesystem on every boot, within an ownership boundary. see: + - name: Secrets + link: /settings/secrets - name: Git link: /tools/git - - name: Ansible - link: /tools/ansible - name: Autoload Scripts link: /settings/autoload-scripts --- # Seed -Seeding projects files from a durable source directory onto the filesystem on every container boot. -Fill the source once and it re-projects each restart, with no hand-run setup scripts. +Seeding projects files and secrets from a durable source directory onto the filesystem on every +container boot. Fill the source once and it re-projects each restart, with no hand-run setup scripts. -There are two tiers in a single source tree: +The engine lives in `ws-cli`. One source tree carries two tiers, resolved into a single plan and +written once per destination: -- **Bare files**: an FS-rooted mirror of the target filesystem. -- **A `.seed.yaml` manifest**: an Ansible tasks-list run through a hardened wrapper play. +- **Bare files** mirror the target filesystem and are copied verbatim. +- **A `.seed.yaml` manifest** overlays declarative behaviors — merge, templating, secrets — onto + that mirror. ## At a Glance -- **Source:** *(default `~/.ws/seed.d`)*. - An empty or absent directory is a clean no-op. +- **Source:** *(default `~/.ws/seed.d`)*. An empty or absent + directory is a clean no-op. +- **Manifest:** a single `/.seed.yaml` at the source root, excluded from the bare mirror. - **Runs:** on every boot, before the workspace's own configuration steps. -- **Writes:** only under `$HOME` and `${WS_SERVER_ROOT}`, minus a fixed deny-set. - System directories require an explicit opt-in. +- **Writes:** only where you own the destination — anywhere your account owns the nearest existing + parent directory. ## Bare Files: FS-Rooted Mirror -Plain files mirror the target filesystem tree. -The path under the source maps directly onto the root filesystem: +Plain files mirror the target filesystem tree. The path under the source maps directly onto the root +filesystem: ```text ~/.ws/seed.d/home/kloud/.gitconfig → ~/.gitconfig ~/.ws/seed.d/etc/workspace/x → /etc/workspace/x ``` -A home file therefore lives at `seed.d/home/kloud/`, not at the source root. -Each file is written with a fixed mode *(644 for files, 755 for new directories)* and owned by `kloud`. -Bare files reconcile every boot, overwriting the destination whether or not it changed. +A home file therefore lives at `seed.d/home/kloud/`, not at the source root. Bare files copy +verbatim with mode `644` *(new directories `755`)* and never write if the destination already +exists, unless forced. -## Task Tier: `.seed.yaml` +## The Manifest -A single hidden `.seed.yaml` at the source root is an Ansible **tasks-list** *(not a full play)*. -It is excluded from the bare mirror and never copied verbatim. -The workspace generates the play it controls *(`localhost`, local connection, no fact-gathering, `become: false`)* -and runs it with the plugin path emptied from a clean temporary directory. +A single hidden `.seed.yaml` at the source root declares behaviors. It opens with a `version` key and +two maps: -Supported modules: `copy`, `template`, `file`, `blockinfile`, `lineinfile` and `set_fact` -*(for `combine` ergonomics)*. +```yaml +version: v1 + +secrets: + GH_TOKEN: kZ9... # inline ciphertext, or file:/run/secrets/gh_token + +seeds: + ~/.gitconfig: + op: merge + ~/.ssh/id_ed25519: + secret: true + ~/.zshenv: + op: append + content: "export EDITOR=nano\n" +``` + +The `seeds:` map is **keyed by destination** — `~`, `${ws_home}`, `${ws_server_root}` and +`${ws_user}` expand. The source for each entry is implied by that key: the **rhyming mirror file** +*(`/`)*, or an inline `content:` literal. There is no `file:` +pointer inside `seeds:`. + +Each entry must carry at least one behavior — `secret`, `mode`, a non-`copy` `op`, or `template`. A +plain copy belongs in the mirror tier, so a copy-only manifest entry is a parse error. When a +destination is produced by both a bare file and a manifest entry, the manifest entry wins. + +## Operations + +`op` is one of `copy` *(default)*, `merge`, `append` or `prepend`. -Inline `content:` may template over a closed set of variables *(`ws_home`, `ws_user` and `ws_server_root`)*, -and the `combine` filter performs YAML/JSON deep-merge. +`merge` deep-merges structured data, with the format inferred from the destination extension +*(`.json`, `.yaml`, `.toml`)*. Maps merge recursively, scalars override, and **lists replace** +wholesale. A scalar-versus-map conflict at a key is a hard error that leaves the destination +byte-unchanged. ```yaml -- name: Write a merged config - copy: - dest: "{{ ws_home }}/.config/app/config.json" - content: "{{ {'theme': 'dark'} | combine({'telemetry': false}) | to_nice_json }}" - -- name: Append a shell line once - lineinfile: - path: "{{ ws_home }}/.bashrc" - line: export EDITOR=nano +seeds: + ~/.config/app/config.json: + op: merge + content: '{"telemetry": false}' ``` -### Propagation +`append` and `prepend` concatenate `content` onto the existing destination. -Task entries inherit Ansible's native `force: true`, reconciling every boot. -Set `force: false` per entry for "seed-once, then let the destination drift". -There is no global mode switch. +::: warning + +`append` and `prepend` are **not idempotent** — they add their content every time they run. They are +safe on ephemeral destinations rebuilt each boot *(such as `~/.zshenv`)*; on a persistent destination +they accumulate duplicates across boots. Ephemerality is a deployment assumption, not an image +guarantee. + +::: ::: info -`force: false` only applies to `copy` and `template`. `file`, `blockinfile` and `lineinfile` have -no `force` and reconcile every boot: a `lineinfile` with a non-matching `regexp` appends a duplicate -line across boots. +JSON `merge` normalizes numbers through `float64`, so integers larger than 2⁵³ lose precision. YAML +and TOML decode native integers and are unaffected. ::: -## Security Boundaries +## Templating + +Set `template: true` to substitute a closed variable set in the source before writing: -Three boundaries constrain the seed. They hold in every mode and are never governed by `force`. +```yaml +seeds: + ~/.config/app/env: + template: true + content: "HOME=${ws_home}\nTOKEN=${secrets.GH_TOKEN}\n" +``` -### Deny-Set +The available tokens are `${ws_home}`, `${ws_user}`, `${ws_server_root}` and `${secrets.NAME}`. An +unknown `${...}` token is a hard error — there is no expression language and no escape syntax. To +emit a literal `${...}`, leave `template` unset. -Any path a later startup script or shell-init autoloads, executes, or feeds to a root-capable process -is rejected: the seed can never plant code or trust that a less-hardened consumer later runs. -A rejected entry is skipped with a warning; the boot continues. +## Secrets -Rejected destinations include `~/.ws/{startup.d,session.d,ca.d,features.d,extensions}`, -`~/.ws/{vault,state,history}`, `~/.ssh`, `~/.kube`, `~/.zshenv`, any `.git/` directory, and system -paths such as `/etc/sudoers.d`, `/etc/ssh`, `/etc/ansible`, `/etc/profile.d`, `/usr`, `/bin` and -`/sbin`. +Two secret shapes share one ciphertext format, both produced by +[`ws-cli secrets encrypt`](/settings/secrets): -### System-Tier Gate +- **Inline values** live in the top-level `secrets:` map *(`NAME: ` or + `NAME: file:`)* and are referenced only through `${secrets.NAME}` in a `template: true` + entry. +- **Whole-file secrets** set `secret: true` on the entry. The engine decrypts the implied source — + the rhyming mirror ciphertext file or an inline `content:` ciphertext — and writes the plaintext + verbatim. -The `.seed.yaml` task tier never writes system paths. -System seeding is bare-file plain-copy only, gated by two opt-ins: +A secret-bearing output is forced to mode `0600`, and its cleartext never reaches logs. A secret that +will not decrypt *(missing key, corrupt ciphertext)* is skipped with a warning and nothing is +written — never the ciphertext, never a partial. A manifest with no secrets needs no key. -- set to `true` -- password-less `sudo` available *(`WS_AUTH_DISABLE_SUDO=false`)*. +::: warning -The system deny-set is rejected even when `allow_system` is on. +Keep ciphertext files **outside** the mirror tree unless a manifest entry claims them. An undeclared +encrypted file in the source is copied verbatim as ciphertext, exactly like any other bare file. -### Hardened Mode +::: -When `WS_AUTH_DISABLE_SUDO=true`, the seed runs the bare-file copy tier into user-space only: no -Ansible interpreter is invoked at all, and `seed.allow_system` is ignored. +::: info -A system-path bare file is a clean skip, never a half-write. +Key rotation re-authors the source: re-encrypt the `secrets:` values and any `secret: true` ciphertext +with [`ws-cli secrets encrypt`](/settings/secrets) under the new key. There is no `seed rotate` +command yet. -## Edit the Source, Not the Projection +::: -The seed is **reconcile**: edit `~/.ws/seed.d`, not the live projection. -A change made directly to a seeded destination on a persistent volume reverts on the next boot. +## Ownership Boundary -Reconcile is also **additive**: removing a file from the source does **not** delete its earlier -projection; the projection survives the next boot. +The engine writes only where you own the destination. It stats the nearest existing parent directory +of the resolved path and compares its owner to your account *(`st_uid == geteuid()`)*: owner-owned +directories are allowed — `~/.ssh`, `~/.ws/startup.d`, dotfiles, a `/opt/mine` you created — and +anything else is skipped with a warning. There is no system gate and no `sudo`: a root-owned parent +simply fails the check, which is also exactly where your account cannot write. -::: warning +Every write is anchored and TOCTOU-safe: paths resolve through `os.Root`, a component that escapes the +anchor is refused, and a final-component symlink is refused outright rather than followed. -The seed is a **base layer, not the source of truth.** +::: tip -Because it runs before the workspace's own configuration steps, the files those steps manage -*(the editor `settings.json`, shell configuration and server configuration)* are overridden by the -workspace on every boot. Seeding wins for everything else; it loses for the files the workspace -configures. +A write into `~/.ws/{startup.d,ca.d,session.d,features.d}` is allowed and emits a notice — seeding +your own startup script is a legitimate use. Mark it executable if it needs to run. ::: -::: warning +## Force and Ephemerality -`combine` merges inline and seeded fragments only. -It **cannot** read an existing on-disk file to patch an override into it: reading for merge is not -available to the task tier. +The default is **write-if-absent**: an entry writes only when the destination is missing. This +re-seeds ephemeral paths every boot for free and preserves a hand-edited persistent file. To +overwrite an existing destination, set `force: true` on the entry or pass `--force` to re-apply +everything. For `merge`, `force` gates the merge too — without it, an existing destination is left +alone. -::: +## Apply and Inspect -::: danger +The boot hook runs `ws-cli seed apply` with no arguments. Run it by hand to re-project, or scope it to +specific destinations: -Seeding into `${WS_SERVER_ROOT}` trips the clone guard and **suppresses** `WS_GIT_CLONE_REPO`. -If the server root is a persistent volume, reconcile reverts live edits on every boot. +```sh +# Project everything +ws-cli seed apply -A whole repository cannot be planted: any `.git/` path is denied. +# Re-apply, overwriting existing destinations +ws-cli seed apply --force -::: +# Project a single destination +ws-cli seed apply ~/.gitconfig + +# List the resolved plan +ws-cli seed ls +``` + +A named destination matches its entry regardless of `~`, `$HOME` or absolute form. ## Next Steps +- [Secrets](/settings/secrets): the `ws-cli secrets` encryption primitives behind seeded secrets. - [Git](/tools/git): automated repository cloning into `${WS_SERVER_ROOT}`. -- [Ansible](/tools/ansible): the engine behind the `.seed.yaml` task tier. - [Autoload Scripts](/settings/autoload-scripts): the `~/.ws/*.d` drop-in convention. diff --git a/docs/tools/ws-cli.md b/docs/tools/ws-cli.md index 9a7a8f6..cc52a7a 100644 --- a/docs/tools/ws-cli.md +++ b/docs/tools/ws-cli.md @@ -109,8 +109,8 @@ ws logs --level=error --tail=100 --follow Manage encryption, decryption, and master key generation for secure secrets handling. -For comprehensive documentation including vault management, secret types, and security -best practices, see the [dedicated secrets documentation](/settings/secrets). +For comprehensive documentation including secret types and security best practices, see the +[dedicated secrets documentation](/settings/secrets). #### Quick Reference @@ -119,7 +119,15 @@ best practices, see the [dedicated secrets documentation](/settings/secrets). - **`login`:** Generate a login password hash for authentication. - **`encrypt `:** Encrypt a plaintext value. - **`decrypt <encrypted>`:** Decrypt an encrypted value. -- **`vault`:** Process a vault file and write secrets to destinations. + +### Seed (`ws seed`) + +Project files and secrets from a durable source directory onto the filesystem. See the +[seed documentation](/tools/seed) for the manifest schema and ownership boundary. + +- **`apply [dest...]`:** Project the seed plan, optionally scoped to named destinations *(`--force` + overwrites existing destinations)*. +- **`ls`:** List the resolved seed plan. ### Serve (`ws serve`) From 22d0acfde9f9c8dad624abe203d6759e681c953d Mon Sep 17 00:00:00 2001 From: Dov Benyomin Sohacheski <b@kloud.email> Date: Tue, 30 Jun 2026 22:04:26 +0300 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=93=9D=20Add=20`op:=20block`=20docs,?= =?UTF-8?q?=20relocate=20seed=20to=20settings,=20drop=20vault=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the new `op: block` managed-marker-block operation and its `comment:` prefix, and correct the append/prepend behaviour to write-if-absent. Move the guide from `tools/` to `settings/` (seed is a configuration feature, not an installed tool) and give it a `seed.svg` hero icon. Delete the vault page outright (no tombstone), de-vault the secrets icon, and repoint every inbound link. Rewrite developer-facing phrasing for the end-user audience and trim duplicated explanations. --- .vitepress/_sidebar.ts | 2 +- docs/editor/storage.md | 4 +- docs/public/icons/{vault.svg => secrets.svg} | 0 docs/public/icons/seed.svg | 5 + docs/settings/secrets.md | 6 +- docs/settings/seed.md | 256 +++++++++++++++++++ docs/settings/tls.md | 2 +- docs/settings/vault.md | 33 --- docs/tools/seed.md | 208 --------------- docs/tools/ws-cli.md | 2 +- 10 files changed, 269 insertions(+), 249 deletions(-) rename docs/public/icons/{vault.svg => secrets.svg} (100%) create mode 100644 docs/public/icons/seed.svg create mode 100644 docs/settings/seed.md delete mode 100644 docs/settings/vault.md delete mode 100644 docs/tools/seed.md diff --git a/.vitepress/_sidebar.ts b/.vitepress/_sidebar.ts index 625294a..5399c40 100644 --- a/.vitepress/_sidebar.ts +++ b/.vitepress/_sidebar.ts @@ -49,6 +49,7 @@ export default { { text: 'Configuration', link: '/settings/configuration' }, { text: 'TLS & Certificates', link: '/settings/tls' }, { text: 'Secrets', link: '/settings/secrets' }, + { text: 'Seed', link: '/settings/seed' }, ] }, { @@ -69,7 +70,6 @@ export default { { text: 'Kind', link: '/tools/kind' }, { text: 'Python', link: '/tools/python' }, { text: 'Rust', link: '/tools/rust' }, - { text: 'Seed', link: '/tools/seed' }, { text: 'SSH', link: '/tools/ssh' }, { text: 'ws-cli', link: '/tools/ws-cli' }, ] diff --git a/docs/editor/storage.md b/docs/editor/storage.md index 14e6202..5520921 100644 --- a/docs/editor/storage.md +++ b/docs/editor/storage.md @@ -66,7 +66,7 @@ docker volume ls Several workspace features store their configuration under `~/.ws` (`/home/kloud/.ws`), including [sideloaded extensions](/editor/extensions), [startup and session scripts](/settings/autoload-scripts), [drop-in CA certificates](/settings/tls#drop-in-directory-ws-ca-d), -the [seed source](/tools/seed), and your shell and REPL history (`~/.ws/history`). +the [seed source](/settings/seed), and your shell and REPL history (`~/.ws/history`). Because this directory lives inside the container, its contents are lost on restart unless you persist it separately: @@ -114,7 +114,7 @@ volumeMounts: subPath: workspace - name: data - mountPath: /home/kloud/.ws # also persists shell/REPL history (~/.ws/history) + mountPath: /home/kloud/.ws subPath: ws - name: data diff --git a/docs/public/icons/vault.svg b/docs/public/icons/secrets.svg similarity index 100% rename from docs/public/icons/vault.svg rename to docs/public/icons/secrets.svg diff --git a/docs/public/icons/seed.svg b/docs/public/icons/seed.svg new file mode 100644 index 0000000..d9c6150 --- /dev/null +++ b/docs/public/icons/seed.svg @@ -0,0 +1,5 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"> + <path d="M0 0h24v24H0z" fill="none" /> + <path fill="#a6d189" + d="M17.2 5c.6 0 1.2 0 1.7.1c.2 2.3.2 6.9-2.5 10.1c-2 2.5-5.4 3.8-10 3.8H5.1c-.2-4.6.7-8.2 2.8-10.5C10.4 5.6 14.4 5 17.2 5m0-2c-5.5 0-15.6 2.1-14 17.8c1.1.1 2.2.2 3.2.2C24.3 21 20.7 3.3 20.7 3.3S19.3 3 17.2 3M17 7C7 7 7 17 7 17C11 9 17 7 17 7" /> +</svg> diff --git a/docs/settings/secrets.md b/docs/settings/secrets.md index f83debb..9e10249 100644 --- a/docs/settings/secrets.md +++ b/docs/settings/secrets.md @@ -2,7 +2,7 @@ description: "Manage sensitive credentials in Kloud Workspace with the ws-cli secrets command — encrypt passwords, API keys, and SSH keys instead of storing plaintext." see: - name: Seed - link: /tools/seed + link: /settings/seed - name: ws secrets Command Reference link: /tools/ws-cli#secrets-ws-secrets target: _self @@ -13,7 +13,7 @@ see: # Secrets -![Vault](/icons/vault.svg){.doc-image} +![Secrets](/icons/secrets.svg){.doc-image} Kloud Workspace applications often require sensitive credentials: database passwords, API keys, SSH keys, and authentication tokens. @@ -81,7 +81,7 @@ cat encrypted.txt | ws secrets decrypt - --master .master.key ## Seed For declarative secret projection — inline `${secrets.NAME}` values and whole-file `secret: true` -entries in the seed manifest — see the [seed documentation](/tools/seed). +entries in the seed manifest — see the [seed documentation](/settings/seed). ## Authentication Passwords diff --git a/docs/settings/seed.md b/docs/settings/seed.md new file mode 100644 index 0000000..4c6dbf4 --- /dev/null +++ b/docs/settings/seed.md @@ -0,0 +1,256 @@ +--- +description: Project files and secrets from a durable seed directory onto the workspace filesystem on every boot, within an ownership boundary. +see: + - name: Secrets + link: /settings/secrets + - name: Autoload Scripts + link: /settings/autoload-scripts +--- + +# Seed + +![Seed](/icons/seed.svg){.doc-image} + +Seeding projects files and secrets from a durable source directory onto the filesystem on every +container boot. +Fill the source once and it re-projects each restart, with no hand-run setup scripts. + +One source tree carries two tiers: plain **bare files** copied verbatim, and a **`.seed.yaml` +manifest** that layers merge, templating, and secrets over them. + +## At a Glance + +- **Source:** <EnvVar group="seed" name="source" /> *(default `~/.ws/seed.d`)*. An empty or absent + directory is a clean no-op. +- **Manifest:** a single `<source>/.seed.yaml` at the source root, excluded from the bare mirror. +- **Runs:** at boot, before the workspace's own configuration steps. +- **Writes:** only where you own the destination, anywhere your account owns the nearest existing + parent directory. + +## Bare Files: FS-Rooted Mirror + +The path under the source maps directly onto the root filesystem: + +```text +~/.ws/seed.d/home/kloud/.gitconfig → ~/.gitconfig +~/.ws/seed.d/etc/workspace/x → /etc/workspace/x +``` + +A home file therefore lives at `seed.d/home/kloud/<path>`, not at the source root. +Bare files copy verbatim with mode `644` *(new directories `755`)* and never write if the +destination already exists, unless forced. + +## The Manifest + +A single hidden `.seed.yaml` at the source root declares behaviors. +It opens with a `version` key and two sections: + +```yaml +version: v1 + +secrets: + GH_TOKEN: kZ9... # inline ciphertext, or file:/run/secrets/gh_token + +seeds: + ~/.gitconfig: + op: merge + + ~/.ssh/id_ed25519: + secret: true + + ~/.zshenv: + op: append + content: | + export EDITOR=nano +``` + +The `seeds:` map is **keyed by destination**, `~`, `${ws_home}`, `${ws_server_root}` and +`${ws_user}` expand. + +The source for each entry is implied by that key: the **rhyming mirror file** +*(`<source>/<dest-without-leading-slash>`)*, or an inline `content:` literal. +There is no `file:` pointer inside `seeds:`. + +Each entry must carry at least one behavior, `secret`, `mode`, a non-`copy` `op`, or `template`. +A plain copy belongs in the mirror tier, so a copy-only manifest entry is a parse error. + +When a destination is produced by both a bare file and a manifest entry, the manifest entry wins. + +## Operations + +`op` is one of `copy` *(default)*, `merge`, `append`, `prepend` or `block`. + +`merge` deep-merges structured data, with the format inferred from the destination extension +*(`.json`, `.yaml`, `.toml`)*. +Maps merge recursively, scalars override, and **lists replace** wholesale. +A scalar-versus-map conflict at a key is a hard error that leaves the destination unchanged. + +```yaml +seeds: + ~/.config/app/config.json: + op: merge + content: '{"telemetry": false}' +``` + +`append` and `prepend` add `content` to the end or start of the destination. + +::: warning + +`append` and `prepend` follow the same **write-if-absent** rule as every entry +*(see [Force and Ephemerality](#force-and-ephemerality))*: an existing destination is skipped unless forced. + +Forced, they re-apply on every boot *(**naive, not idempotent**)*, so the content is added again each +time and accumulates *(if a persistent volume is mounted)*. + +To manage a region inside an existing file idempotently, use [`op: block`](#managed-blocks). + +::: + +::: info + +JSON `merge` can lose precision on integers larger than 2⁵³. YAML and TOML keep them exact. + +::: + +### Managed Blocks + +`op: block` manages a marked region inside a file. + +It wraps `content` between two marker lines and reconciles that block on every boot, idempotently, +even when the content changes: + +```yaml +seeds: + ~/.zshenv: + op: block + content: "export EDITOR=nano\n" +``` + +The first apply appends the block and inserts the markers for you: + +```text +# >>> ws-seed >>> +export EDITOR=nano +# <<< ws-seed <<< +``` + +Later applies find those markers and replace only the body between them, so the region never +duplicates and tracks content changes. + +You never write the markers by hand. +A missing file is created; an existing file keeps its content and gains the block at the end. + +`block` ignores `force`: it is safe to re-run, and rewrites the file only when the block's contents +change. Malformed markers *(a begin without an end, or markers out of order)* are a hard error that +leaves the destination unchanged. + +The marker text is fixed, but its comment prefix defaults to `#` and is set per entry with `comment` +for files where `#` is not a comment: + +```yaml +seeds: + ~/.config/app/config.js: + op: block + comment: // + content: "module.exports = { telemetry: false }\n" +``` + +```text +// >>> ws-seed >>> +module.exports = { telemetry: false } +// <<< ws-seed <<< +``` + +`block` is plain text and does not infer a file's comment syntax; `comment` is valid only with +`op: block`. For structured files, prefer `merge` over a marked block. + +## Templating + +Set `template: true` to substitute a closed variable set in the source before writing: + +```yaml +seeds: + ~/.config/app/env: + template: true + content: "HOME=${ws_home}\nTOKEN=${secrets.GH_TOKEN}\n" +``` + +The available tokens are `${ws_home}`, `${ws_user}`, `${ws_server_root}` and `${secrets.NAME}`. An +unknown `${...}` token is a hard error. There is no expression language and no escape syntax. To +emit a literal `${...}`, leave `template` unset. + +## Secrets + +Two secret shapes share one ciphertext format, both produced by +[`ws-cli secrets encrypt`](/settings/secrets): + +- **Inline values:** live in the top-level `secrets:` map *(`NAME: <ciphertext>` or `NAME: file:<path>`)* + and are referenced only through `${secrets.NAME}` in a `template: true` entry. +- **Whole-file secrets:** set `secret: true` on the entry. Its source *(the rhyming mirror ciphertext + file, or an inline `content:` ciphertext)* is decrypted and written as plaintext. + +A secret-bearing output is forced to mode `0600`, and its cleartext never reaches logs. +A secret that will not decrypt *(missing key, corrupt ciphertext)* is skipped with a warning and +nothing is written, never the ciphertext, never a partial. + +A manifest with no secrets needs no key. + +::: warning + +Keep ciphertext files **outside** the mirror tree unless a manifest entry claims them. +An undeclared encrypted file in the source is copied verbatim as ciphertext, exactly like any other +bare file. + +::: + +::: info + +Key rotation re-authors the source: re-encrypt the `secrets:` values and any `secret: true` ciphertext +with [`ws-cli secrets encrypt`](/settings/secrets) under the new key. + +::: + +## Ownership Boundary + +Seed writes only to destinations you own, anywhere your account owns the nearest existing parent +directory: `~/.ssh`, `~/.ws/startup.d`, dotfiles, a `/opt/mine` you created. Anything else is skipped +with a warning. + +There is no `sudo` and no escalation: a location you do not own simply fails the check, which is also +exactly where you could not write by hand. + +::: tip + +A write into `~/.ws/{startup.d,ca.d,session.d,features.d}` is allowed and emits a notice. Seeding +your own startup script is a legitimate use. Mark it executable if it needs to run. + +::: + +## Force and Ephemerality + +The default is **write-if-absent**: an entry writes only when the destination is missing. +This re-seeds ephemeral paths every boot for free and preserves a hand-edited persistent file. +To overwrite an existing destination, set `force: true` on the entry or pass `--force` to re-apply +everything. +For `merge`, `force` gates the merge too, and without it an existing destination is left alone. + +## Apply and Inspect + +The boot hook runs `ws-cli seed apply` with no arguments. +Run it by hand to re-project, or scope it to specific destinations: + +```sh +# Project everything +ws-cli seed apply + +# Re-apply, overwriting existing destinations +ws-cli seed apply --force + +# Project a single destination +ws-cli seed apply ~/.gitconfig + +# List the resolved plan +ws-cli seed ls +``` + +A named destination matches its entry regardless of `~`, `$HOME` or absolute form. diff --git a/docs/settings/tls.md b/docs/settings/tls.md index c783a80..5c3b510 100644 --- a/docs/settings/tls.md +++ b/docs/settings/tls.md @@ -126,7 +126,7 @@ docker run \ This is the recommended path for ad-hoc certificate injection on a running workspace — the same persisted volume already used for [autoload scripts](/settings/autoload-scripts), extensions, and the -[seed source](/tools/seed). +[seed source](/settings/seed). ### Install Certificate from HTTPS Endpoint diff --git a/docs/settings/vault.md b/docs/settings/vault.md deleted file mode 100644 index be3c4c0..0000000 --- a/docs/settings/vault.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -description: "The secrets vault has been removed. Secrets are now declared in the seed manifest and projected by the ws-cli seed engine." -see: - - name: Seed - link: /tools/seed - - name: Secrets - link: /settings/secrets ---- - -# Vault - -::: danger - -The secrets vault has been removed. The `ws-cli secrets vault` subcommand, the -`~/.ws/vault/secrets.yaml` manifest, and the standalone autoload step no longer exist. - -::: - -Secrets now live in the [seed manifest](/tools/seed), projected by the unified `ws-cli seed` engine. -There is no automatic migration — re-author your secrets into `<seed.source>/.seed.yaml`: - -- **Inline values** *(the old `type: env` exports)* go in the top-level `secrets:` map and render - through `${secrets.NAME}` in a `template: true` entry. -- **Whole-file secrets** *(the old `type: ssh`, `kubeconfig`, `generic`, `dockerconfigjson`)* become - `secret: true` entries keyed by their destination. - -The [`ws-cli secrets`](/settings/secrets) encryption primitives — `encrypt`, `decrypt` and -`generate` — are unchanged. - -## Next Steps - -- [Seed](/tools/seed): declare and project secrets in the unified manifest. -- [Secrets](/settings/secrets): encrypt and decrypt values with `ws-cli secrets`. diff --git a/docs/tools/seed.md b/docs/tools/seed.md deleted file mode 100644 index 1884daa..0000000 --- a/docs/tools/seed.md +++ /dev/null @@ -1,208 +0,0 @@ ---- -description: Project files and secrets from a durable seed directory onto the workspace filesystem on every boot, within an ownership boundary. -see: - - name: Secrets - link: /settings/secrets - - name: Git - link: /tools/git - - name: Autoload Scripts - link: /settings/autoload-scripts ---- - -# Seed - -Seeding projects files and secrets from a durable source directory onto the filesystem on every -container boot. Fill the source once and it re-projects each restart, with no hand-run setup scripts. - -The engine lives in `ws-cli`. One source tree carries two tiers, resolved into a single plan and -written once per destination: - -- **Bare files** mirror the target filesystem and are copied verbatim. -- **A `.seed.yaml` manifest** overlays declarative behaviors — merge, templating, secrets — onto - that mirror. - -## At a Glance - -- **Source:** <EnvVar group="seed" name="source" /> *(default `~/.ws/seed.d`)*. An empty or absent - directory is a clean no-op. -- **Manifest:** a single `<source>/.seed.yaml` at the source root, excluded from the bare mirror. -- **Runs:** on every boot, before the workspace's own configuration steps. -- **Writes:** only where you own the destination — anywhere your account owns the nearest existing - parent directory. - -## Bare Files: FS-Rooted Mirror - -Plain files mirror the target filesystem tree. The path under the source maps directly onto the root -filesystem: - -```text -~/.ws/seed.d/home/kloud/.gitconfig → ~/.gitconfig -~/.ws/seed.d/etc/workspace/x → /etc/workspace/x -``` - -A home file therefore lives at `seed.d/home/kloud/<path>`, not at the source root. Bare files copy -verbatim with mode `644` *(new directories `755`)* and never write if the destination already -exists, unless forced. - -## The Manifest - -A single hidden `.seed.yaml` at the source root declares behaviors. It opens with a `version` key and -two maps: - -```yaml -version: v1 - -secrets: - GH_TOKEN: kZ9... # inline ciphertext, or file:/run/secrets/gh_token - -seeds: - ~/.gitconfig: - op: merge - ~/.ssh/id_ed25519: - secret: true - ~/.zshenv: - op: append - content: "export EDITOR=nano\n" -``` - -The `seeds:` map is **keyed by destination** — `~`, `${ws_home}`, `${ws_server_root}` and -`${ws_user}` expand. The source for each entry is implied by that key: the **rhyming mirror file** -*(`<source>/<dest-without-leading-slash>`)*, or an inline `content:` literal. There is no `file:` -pointer inside `seeds:`. - -Each entry must carry at least one behavior — `secret`, `mode`, a non-`copy` `op`, or `template`. A -plain copy belongs in the mirror tier, so a copy-only manifest entry is a parse error. When a -destination is produced by both a bare file and a manifest entry, the manifest entry wins. - -## Operations - -`op` is one of `copy` *(default)*, `merge`, `append` or `prepend`. - -`merge` deep-merges structured data, with the format inferred from the destination extension -*(`.json`, `.yaml`, `.toml`)*. Maps merge recursively, scalars override, and **lists replace** -wholesale. A scalar-versus-map conflict at a key is a hard error that leaves the destination -byte-unchanged. - -```yaml -seeds: - ~/.config/app/config.json: - op: merge - content: '{"telemetry": false}' -``` - -`append` and `prepend` concatenate `content` onto the existing destination. - -::: warning - -`append` and `prepend` are **not idempotent** — they add their content every time they run. They are -safe on ephemeral destinations rebuilt each boot *(such as `~/.zshenv`)*; on a persistent destination -they accumulate duplicates across boots. Ephemerality is a deployment assumption, not an image -guarantee. - -::: - -::: info - -JSON `merge` normalizes numbers through `float64`, so integers larger than 2⁵³ lose precision. YAML -and TOML decode native integers and are unaffected. - -::: - -## Templating - -Set `template: true` to substitute a closed variable set in the source before writing: - -```yaml -seeds: - ~/.config/app/env: - template: true - content: "HOME=${ws_home}\nTOKEN=${secrets.GH_TOKEN}\n" -``` - -The available tokens are `${ws_home}`, `${ws_user}`, `${ws_server_root}` and `${secrets.NAME}`. An -unknown `${...}` token is a hard error — there is no expression language and no escape syntax. To -emit a literal `${...}`, leave `template` unset. - -## Secrets - -Two secret shapes share one ciphertext format, both produced by -[`ws-cli secrets encrypt`](/settings/secrets): - -- **Inline values** live in the top-level `secrets:` map *(`NAME: <ciphertext>` or - `NAME: file:<path>`)* and are referenced only through `${secrets.NAME}` in a `template: true` - entry. -- **Whole-file secrets** set `secret: true` on the entry. The engine decrypts the implied source — - the rhyming mirror ciphertext file or an inline `content:` ciphertext — and writes the plaintext - verbatim. - -A secret-bearing output is forced to mode `0600`, and its cleartext never reaches logs. A secret that -will not decrypt *(missing key, corrupt ciphertext)* is skipped with a warning and nothing is -written — never the ciphertext, never a partial. A manifest with no secrets needs no key. - -::: warning - -Keep ciphertext files **outside** the mirror tree unless a manifest entry claims them. An undeclared -encrypted file in the source is copied verbatim as ciphertext, exactly like any other bare file. - -::: - -::: info - -Key rotation re-authors the source: re-encrypt the `secrets:` values and any `secret: true` ciphertext -with [`ws-cli secrets encrypt`](/settings/secrets) under the new key. There is no `seed rotate` -command yet. - -::: - -## Ownership Boundary - -The engine writes only where you own the destination. It stats the nearest existing parent directory -of the resolved path and compares its owner to your account *(`st_uid == geteuid()`)*: owner-owned -directories are allowed — `~/.ssh`, `~/.ws/startup.d`, dotfiles, a `/opt/mine` you created — and -anything else is skipped with a warning. There is no system gate and no `sudo`: a root-owned parent -simply fails the check, which is also exactly where your account cannot write. - -Every write is anchored and TOCTOU-safe: paths resolve through `os.Root`, a component that escapes the -anchor is refused, and a final-component symlink is refused outright rather than followed. - -::: tip - -A write into `~/.ws/{startup.d,ca.d,session.d,features.d}` is allowed and emits a notice — seeding -your own startup script is a legitimate use. Mark it executable if it needs to run. - -::: - -## Force and Ephemerality - -The default is **write-if-absent**: an entry writes only when the destination is missing. This -re-seeds ephemeral paths every boot for free and preserves a hand-edited persistent file. To -overwrite an existing destination, set `force: true` on the entry or pass `--force` to re-apply -everything. For `merge`, `force` gates the merge too — without it, an existing destination is left -alone. - -## Apply and Inspect - -The boot hook runs `ws-cli seed apply` with no arguments. Run it by hand to re-project, or scope it to -specific destinations: - -```sh -# Project everything -ws-cli seed apply - -# Re-apply, overwriting existing destinations -ws-cli seed apply --force - -# Project a single destination -ws-cli seed apply ~/.gitconfig - -# List the resolved plan -ws-cli seed ls -``` - -A named destination matches its entry regardless of `~`, `$HOME` or absolute form. - -## Next Steps - -- [Secrets](/settings/secrets): the `ws-cli secrets` encryption primitives behind seeded secrets. -- [Git](/tools/git): automated repository cloning into `${WS_SERVER_ROOT}`. -- [Autoload Scripts](/settings/autoload-scripts): the `~/.ws/*.d` drop-in convention. diff --git a/docs/tools/ws-cli.md b/docs/tools/ws-cli.md index cc52a7a..4d1d531 100644 --- a/docs/tools/ws-cli.md +++ b/docs/tools/ws-cli.md @@ -123,7 +123,7 @@ For comprehensive documentation including secret types and security best practic ### Seed (`ws seed`) Project files and secrets from a durable source directory onto the filesystem. See the -[seed documentation](/tools/seed) for the manifest schema and ownership boundary. +[seed documentation](/settings/seed) for the manifest schema and ownership boundary. - **`apply [dest...]`:** Project the seed plan, optionally scoped to named destinations *(`--force` overwrites existing destinations)*. From faf544b881faeefd90e67b24e865540b00198142 Mon Sep 17 00:00:00 2001 From: Dov Benyomin Sohacheski <b@kloud.email> Date: Wed, 1 Jul 2026 08:37:52 +0300 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=93=9D=20Document=20seed=20rotate,=20?= =?UTF-8?q?op:=20lineinfile,=20and=20large-int=20JSON=20merge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the `op: lineinfile` operation and a `seed rotate` section, and drop the JSON large-integer precision caveat now that merge preserves them. --- docs/settings/seed.md | 55 +++++++++++++++++++++++++++++++++---------- 1 file changed, 43 insertions(+), 12 deletions(-) diff --git a/docs/settings/seed.md b/docs/settings/seed.md index 4c6dbf4..2d7fcc6 100644 --- a/docs/settings/seed.md +++ b/docs/settings/seed.md @@ -78,7 +78,7 @@ When a destination is produced by both a bare file and a manifest entry, the man ## Operations -`op` is one of `copy` *(default)*, `merge`, `append`, `prepend` or `block`. +`op` is one of `copy` *(default)*, `merge`, `append`, `prepend`, `block` or `lineinfile`. `merge` deep-merges structured data, with the format inferred from the destination extension *(`.json`, `.yaml`, `.toml`)*. @@ -102,13 +102,8 @@ seeds: Forced, they re-apply on every boot *(**naive, not idempotent**)*, so the content is added again each time and accumulates *(if a persistent volume is mounted)*. -To manage a region inside an existing file idempotently, use [`op: block`](#managed-blocks). - -::: - -::: info - -JSON `merge` can lose precision on integers larger than 2⁵³. YAML and TOML keep them exact. +To add a single line idempotently, use [`op: lineinfile`](#managed-lines); to manage a region, use +[`op: block`](#managed-blocks). ::: @@ -164,6 +159,25 @@ module.exports = { telemetry: false } `block` is plain text and does not infer a file's comment syntax; `comment` is valid only with `op: block`. For structured files, prefer `merge` over a marked block. +### Managed Lines + +`op: lineinfile` manages a single line, matched by its key, idempotently: + +```yaml +seeds: + ~/.zshenv: + op: lineinfile + content: "export EDITOR=nano\n" +``` + +The key is the text up to and including the first `=` *(here `export EDITOR=`)*. On each apply a +line with that key is replaced in place; if none exists the line is appended, creating the file if +needed. The content is exactly one line, so an interior newline is a hard error *(use `op: block` +for a multi-line region)*. + +Like `block`, `lineinfile` ignores `force` and rewrites the file only when the line changes. It is +the idempotent counterpart to `append` for `key=value` files such as `~/.zshenv`. + ## Templating Set `template: true` to substitute a closed variable set in the source before writing: @@ -186,8 +200,9 @@ Two secret shapes share one ciphertext format, both produced by - **Inline values:** live in the top-level `secrets:` map *(`NAME: <ciphertext>` or `NAME: file:<path>`)* and are referenced only through `${secrets.NAME}` in a `template: true` entry. -- **Whole-file secrets:** set `secret: true` on the entry. Its source *(the rhyming mirror ciphertext - file, or an inline `content:` ciphertext)* is decrypted and written as plaintext. +- **Whole-file secrets:** set `secret: true` on the entry. Its source + *(the rhyming mirror ciphertext file, or an inline `content:` ciphertext)* is decrypted and + written as plaintext. A secret-bearing output is forced to mode `0600`, and its cleartext never reaches logs. A secret that will not decrypt *(missing key, corrupt ciphertext)* is skipped with a warning and @@ -205,8 +220,8 @@ bare file. ::: info -Key rotation re-authors the source: re-encrypt the `secrets:` values and any `secret: true` ciphertext -with [`ws-cli secrets encrypt`](/settings/secrets) under the new key. +To change the master key, run [`ws-cli seed rotate`](#apply-and-inspect) — it re-encrypts every +managed ciphertext in place under the new key. ::: @@ -254,3 +269,19 @@ ws-cli seed ls ``` A named destination matches its entry regardless of `~`, `$HOME` or absolute form. + +Rotate the master key across every managed ciphertext — the `secrets:` map, its `file:` targets, and +every `secret: true` source — in one pass: + +```sh +# Re-encrypt everything from the current key to a new one +ws-cli seed rotate --master <current> --new-master <new> +``` + +::: warning + +`seed rotate` rewrites ciphertext **in place** — there is no dry-run and no backup. It fails closed: +every secret is decrypted under the current key *before* anything is written, so a wrong key changes +nothing. If a run is interrupted, re-run it with the new key to finish. + +:::