core.file
Manage files and directories: content/permissions/owner, template rendering, directory creation, deletion.
core.file brings a file or directory to the desired shape with four states: present (content inline via content or copied from the host via src), rendered (renders a text/template template), directory (a directory with attributes), and absent. Idempotent by content (SHA-256 comparison) and by the mode/owner/group attributes — if everything matches, changed=false. Replaces the separate core.copy and core.template (ADR-015). Soul-side: writing outside /var/lib/soul-stack/ requires fs_write_root, and system paths in practice require root.
Requirements
- Rootrequired
- Side
soul-side - Collection
soulstack.files - Category
files
fs_write_rootStates
core.file.present — The file exists with the given content (inline content or an src copy) plus mode/owner/group.
The file did not exist, or the content (compared by SHA-256; for src — sha256 of the src bytes), mode, or owner/group differs.
The content, mode, and owner/group already match.
Parameters
| Param | Type | Required / default | Description |
|---|---|---|---|
path | string | required | Target file path. |
content | string | optional | File content (inline). Mutually exclusive with src; neither — an empty file. |
src | string | optional | Absolute path to a regular file on the host; its content is copied into path (typically the result of core.archive.extracted). Sets the content only, not the source's attributes. Mutually exclusive with content. |
mode | string | optional | Permissions in octal form, e.g. "0640". |
owner | string | optional | Owner (user name). |
group | string | optional | Owning group (group name). |
Example — present with inline content (the core.copy role)
- name: Drop a static marker file module: core.file.present params: path: /etc/soul-stack/marker content: "managed by soul-stack" mode: "0644" owner: root group: rootOutput
| Field | Type | Description |
|---|---|---|
path | string | |
sha256 | string | |
mode | string | |
installed | true |
core.file.absent — The file is deleted.
The file existed and was deleted.
The file does not exist.
Parameters
| Param | Type | Required / default | Description |
|---|---|---|---|
path | string | required | Path of the file to remove. |
Example — Delete the file
- name: Remove stale marker module: core.file.absent params: path: /etc/soul-stack/markerOutput
| Field | Type | Description |
|---|---|---|
path | string | |
installed | false |
core.file.directory — The directory exists with the given mode/owner/group (a declarative replacement for core.exec.run install -d).
The directory did not exist (created) or mode or owner/group drift (fixed with chmod/chown). The path is occupied by a file — an error, no overwrite.
The directory exists, mode and owner/group match.
Parameters
| Param | Type | Required / default | Description |
|---|---|---|---|
path | string | required | Target directory path. |
mode | string | optional | Permissions in octal form, e.g. "0755". |
owner | string | optional | Owner (user name). |
group | string | optional | Owning group (group name). |
parents | bool | optional | Create intermediate directories (mkdir -p semantics). Default false. |
Example — directory with a parent chain (parents: true = mkdir -p)
- name: Ensure exporter data directory module: core.file.directory params: path: /var/lib/node_exporter/textfile parents: true mode: "0755" owner: node_exporter group: node_exporterOutput
| Field | Type | Description |
|---|---|---|
path | string | |
mode | string | |
created | bool |
core.file.rendered — The file = the result of rendering a text/template template (the core.template role).
Render into memory → SHA-256 is compared with the existing file → a write only on a diff in any of content/mode/owner. The write is atomic (temp + rename in the same directory).
The rendered content, mode, and owner matched the file on disk.
Parameters
| Param | Type | Required / default | Description |
|---|---|---|---|
path | string | required | Target path of the rendered file. |
template | string | required | Path to the .tmpl template (resolved locally, then service-level). |
vars | map<string> | optional | Render variables (visible to the template as .vars.*). |
mode | string | optional | Permissions in octal form, e.g. "0644". |
owner | string | optional | Owner (user name). |
group | string | optional | Owning group (group name). |
Example — rendered from a template; register — an onchanges: anchor to restart the service
- name: Render redis.conf module: core.file.rendered register: redis_conf params: path: /etc/redis/redis.conf template: templates/redis.conf.tmpl mode: "0640" owner: redis group: redis vars: password: "${ input.password }"Output
| Field | Type | Description |
|---|---|---|
path | string | |
sha256 | string | |
mode | string | |
installed | true |
Reference
Template context root (rendered)
What text/template sees when core.file.rendered renders. A missing referenced variable is a render error (strict-mode missingkey=error), not a silent empty substitution.
| Key | What it is |
|---|---|
| .vars.* | derived variables from vars: (computed by the CEL phase) |
| .input.* | the resolved operator-input of the pass (Variant B — directly, without passthrough via vars:; Keeper sets the key only if the template actually references .input.*) |
| .self.* | a projection of soulprint, snake_case: .self.os.pkg_mgr, .self.network.primary_ip |
| .role | the host's declared role |
| .essence.* | effective essence |
Notes
- present sets content with exactly one of content (inline) or src (a copy of a regular file from the host); both together are an error (a conflict on key presence, even content: "" together with src:). Neither one — an empty file (legacy behavior).
- src sets only the content (typically the result of core.archive.extracted); mode/owner/group are taken from explicit params, the attributes of src are not inherited. The src path must be absolute, a symlink is rejected via Lstat (the source is not followed via the symlink).
- rendered: on the wire Keeper must deliver both template_content and render_context — without either one the step fails (a golden-path prod invariant, not optional).
- The .self.* keys are snake_case (.self.os.pkg_mgr, .self.os.init_system), like soulprint.self.*; camelCase will not work. register on a rendered task typically serves as an onchanges: anchor to restart a service when the config changes.
- present/absent/rendered/directory spawn no subprocesses — rendering, the write, and mkdir/chmod/chown happen in-process, without a shell. recurse (recursive permissions on directory contents) is unsupported in the MVP.
Security & defaults
- A direct write to an arbitrary FS path, including system ones (/etc/...), is the main risk: path/content/mode/owner/group must come from the Destiny author, not from untrusted input (otherwise a swap of /etc/passwd, /etc/sudoers, or a unit file).
- Atomicity differs: rendered and the src branch of present write via temp + rename (an observer does not see a partially written config), while the content branch of present goes through os.WriteFile directly, without temp+rename: on a failure mid-write the file may be left truncated. For configs under a running daemon prefer rendered/src or restart the consumer via onchanges:.
- src is a copy of a regular file from the host itself (a symlink/directory/device/relative path is rejected), but the source does not become trusted: src, like path, is set by the Destiny author, otherwise the contents of an arbitrary host file end up copied into path.
- Set mode/owner explicitly for secrets (0600/0640): without mode the os.WriteFile default is used (depends on umask), the existing mode is not compared and not fixed — a secret may end up world-readable.
- rendered renders in a sandbox (sprig-allowlist, no access to FS/network/environment — three sandbox barriers), but secrets (${ vault(...) }, passwords via vars:) land in render_context and in the resulting file — hence the requirement of an explicit mode for configs with secrets.
- Privileges: the manifest declares fs_write_root (writing outside /var/lib/soul-stack/), but not run_as_root — the module runs with the soul agent process's privileges without elevating them internally; writing to system paths in practice requires root.