diff --git a/.vitepress/_sidebar.ts b/.vitepress/_sidebar.ts index a76d2a7..5399c40 100644 --- a/.vitepress/_sidebar.ts +++ b/.vitepress/_sidebar.ts @@ -49,7 +49,7 @@ export default { { text: 'Configuration', link: '/settings/configuration' }, { text: 'TLS & Certificates', link: '/settings/tls' }, { text: 'Secrets', link: '/settings/secrets' }, - { text: 'Vault', link: '/settings/vault' }, + { text: 'Seed', link: '/settings/seed' }, ] }, { @@ -70,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 eb064a8..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 [secrets vault](/settings/secrets), 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 @@ + + + + diff --git a/docs/settings/secrets.md b/docs/settings/secrets.md index de456a8..9e10249 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: /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. @@ -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](/settings/seed). ## Authentication Passwords diff --git a/docs/settings/seed.md b/docs/settings/seed.md new file mode 100644 index 0000000..2d7fcc6 --- /dev/null +++ b/docs/settings/seed.md @@ -0,0 +1,287 @@ +--- +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:** *(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:** 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/`, 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** +*(`/`)*, 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`, `block` or `lineinfile`. + +`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 add a single line idempotently, use [`op: lineinfile`](#managed-lines); to manage a region, use +[`op: block`](#managed-blocks). + +::: + +### 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. + +### 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: + +```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: ` or `NAME: file:`)* + 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 + +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. + +::: + +## 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. + +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 --new-master +``` + +::: 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. + +::: diff --git a/docs/settings/tls.md b/docs/settings/tls.md index 69d11b6..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 -[secrets vault](/settings/vault). +[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 ebfdd93..0000000 --- a/docs/settings/vault.md +++ /dev/null @@ -1,127 +0,0 @@ ---- -description: "YAML vaults in Kloud Workspace enable declarative, bulk secret injection, building on the ws-cli secrets encryption primitives." -see: - - 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} - -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. - -::: - -## 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: - -```sh -docker run \ - -v /host/path/secrets.yaml:/home/kloud/.ws/vault/secrets.yaml \ - ghcr.io/kloudkit/workspace:v0.4.0 -``` - -## Vault Flags - -- **`--input `:** Vault file path *(defaults to `~/.ws/vault/secrets.yaml`)*. -- **`--key `:** Process specific secret *(repeatable)*. -- **`--stdout`:** Inspect without writing. - -See [ws secrets command reference](/tools/ws-cli#secrets-ws-secrets) for complete syntax. diff --git a/docs/tools/seed.md b/docs/tools/seed.md deleted file mode 100644 index 98a8daf..0000000 --- a/docs/tools/seed.md +++ /dev/null @@ -1,155 +0,0 @@ ---- -description: Project files from a durable seed directory onto the workspace filesystem on every boot, within hard security boundaries. -see: - - 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. - -There are two tiers in a single source tree: - -- **Bare files**: an FS-rooted mirror of the target filesystem. -- **A `.seed.yaml` manifest**: an Ansible tasks-list run through a hardened wrapper play. - -## At a Glance - -- **Source:** *(default `~/.ws/seed.d`)*. - An empty or absent directory is a clean no-op. -- **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. - -## 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/`, 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. - -## Task Tier: `.seed.yaml` - -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. - -Supported modules: `copy`, `template`, `file`, `blockinfile`, `lineinfile` and `set_fact` -*(for `combine` ergonomics)*. - -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. - -```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 -``` - -### Propagation - -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. - -::: 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. - -::: - -## Security Boundaries - -Three boundaries constrain the seed. They hold in every mode and are never governed by `force`. - -### Deny-Set - -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. - -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`. - -### System-Tier Gate - -The `.seed.yaml` task tier never writes system paths. -System seeding is bare-file plain-copy only, gated by two opt-ins: - -- set to `true` -- password-less `sudo` available *(`WS_AUTH_DISABLE_SUDO=false`)*. - -The system deny-set is rejected even when `allow_system` is on. - -### 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. - -A system-path bare file is a clean skip, never a half-write. - -## 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. - -Reconcile is also **additive**: removing a file from the source does **not** delete its earlier -projection; the projection survives the next boot. - -::: warning - -The seed is a **base layer, not the source of truth.** - -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. - -::: - -::: warning - -`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. - -::: - -::: danger - -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. - -A whole repository cannot be planted: any `.git/` path is denied. - -::: - -## Next Steps - -- [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..4d1d531 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](/settings/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`)