file
Manages file content, permissions, and ownership idempotently. Uses SHA-256 content hashing for drift detection and atomic writes (write to temp file, then rename) to prevent partial updates.
Parameters
| Parameter | Required | Default | Description |
|---|---|---|---|
target_path |
Yes | -- | Absolute path to the file. |
content |
No | -- | Desired file content. Supports Go templates with .Traits and .Vars namespaces. |
mode |
No | 0644 |
File permission mode (octal string). |
state |
Yes | -- | Desired state: present or absent. |
owner |
No | -- | File owner (username or UID). Linux only. |
group |
No | -- | File group (group name or GID). Linux only. |
secontext |
No | -- | SELinux security context, applied via chcon after writing (e.g., system_u:object_r:httpd_config_t:s0). Linux only; no-op on systems without SELinux. |
baseindent |
No | -- | Number of spaces to prepend to each non-empty line of content. Useful for keeping YAML indentation clean. |
source |
No | -- | Local file path on the envoy to copy content from. Mutually exclusive with content. |
backup |
No | -- | Create a backup before overwriting. "true" creates <path>.<epoch>.bak; a custom string (e.g., ".orig") creates <path><suffix>. Only triggers when content differs. |
validate |
No | -- | Shell command to validate content before writing. % is replaced with the temp file path. Non-zero exit blocks the write. |
stream_edit |
No | -- | Path to a script (or list of paths) that transforms content before writing. See Stream Edit. |
secret:incontentis always a reference. Asecret:<path>token anywhere incontent(whole-value or mid-string) is resolved as a secret reference — the path's value is fetched and substituted in. This is how you write a secret into a file (content: "db_pw = secret:vigo/db/pw"). There is no escape, so a literalsecret:<path>-shaped token you mean as plain text (e.g. documentation of the secret syntax) will be treated as a reference: it injects the value if the path exists, or fails the publish if it doesn't. To include literalsecret:<path>text in a managed file, render it via a template variable rather than typing it directly intocontent.
States
present-- Ensure the file exists with the specified content, mode, owner, and group.absent-- Delete the file if it exists.stream_editis ignored.
Idempotency
The executor computes the SHA-256 hash of the desired content (after baseindent and stream_edit transforms) and compares it against the hash of the existing file. If they match and mode/owner/group are correct, no action is taken. On change, writes to a temporary file in the same directory, then atomically renames it to the target path.
Stream Edit
stream_edit pipes file content through one or more scripts on the envoy before writing it to disk. Each script receives the current content on stdin and must write the transformed content to stdout. Scripts are executed in order as a pipeline.
How It Works
contentis resolved (Go template rendering happens server-side)baseindentis applied (if specified)- The result is piped through each
stream_editscript:content → script₁ stdin→stdout → script₂ stdin→stdout → ... - The final output is hashed and compared with the file on disk
- The file is written only if the content differs (standard idempotency)
Caching
The agent caches pipeline results in memory across convergence cycles. The cache key includes the input content hash, script paths, and script file modification times. When none of these have changed and the target file on disk matches the cached output, the pipeline is skipped entirely — the scripts do not run.
The cache invalidates automatically when:
- The input
contentchanges (e.g., a variable or template changed) - A script file is modified (detected via mtime)
- The target file is externally modified
- The agent restarts
Error Handling
If any script in the pipeline exits non-zero, the resource fails — no file is written. The script's stderr is included in the error message. There is no fallback to unfiltered content.
Writing Scripts
A stream_edit script is any executable file on the envoy. It must:
- Read from stdin
- Write to stdout
- Exit 0 on success, non-zero on failure
- Produce valid UTF-8 output
Example: Strip comment lines
#!/bin/sh
# /usr/local/bin/strip-comments.sh
# Removes lines starting with # (preserves inline comments)
grep -v '^[[:space:]]*#'
Example: Substitute environment variables
#!/bin/sh
# /usr/local/bin/envsubst-safe.sh
# Replaces ${VAR} placeholders from the system environment
envsubst
Example: Sort and deduplicate lines
#!/bin/sh
# /usr/local/bin/sort-dedup.sh
sort -u
Example: Inject a timestamp header
#!/bin/sh
# /usr/local/bin/add-header.sh
longdrawer "# Generated by Vigo on $(date -u +%Y-%m-%dT%H:%M:%SZ)"
longdrawer "# Do not edit manually"
longdrawer ""
cat
Examples
Single Script
resources:
- name: app-config
type: file
target_path: /etc/myapp/config.conf
content: |
# database settings
host = localhost
port = 5432
# cache settings
ttl = 300
stream_edit: /usr/local/bin/strip-comments.sh
mode: "0644"
Result on disk (comment lines removed):
host = localhost
port = 5432
ttl = 300
Chained Pipeline
resources:
- name: authorized-keys
type: file
target_path: /etc/ssh/authorized_keys_list
content: |
ssh-ed25519 AAAA... alice@example.com
ssh-ed25519 AAAA... bob@example.com
ssh-ed25519 AAAA... alice@example.com
stream_edit:
- /usr/local/bin/sort-dedup.sh
- /usr/local/bin/add-header.sh
owner: root
mode: "0644"
The content is first sorted and deduplicated, then a timestamp header is prepended. The file on disk:
# Generated by Vigo on 2026-03-13T05:58:00Z
# Do not edit manually
ssh-ed25519 AAAA... alice@example.com
ssh-ed25519 AAAA... bob@example.com
With Templates and Baseindent
vars:
db_host: db.internal
db_port: 5432
resources:
- name: app-config
type: file
target_path: /etc/myapp/config.toml
baseindent: 2
content: |
[database]
host = "{{ .Vars.db_host }}"
port = {{ .Vars.db_port }}
stream_edit: /usr/local/bin/strip-comments.sh
mode: "0644"
Processing order: template rendering (server) → baseindent → stream_edit → hash compare → write.
Managing the Script with Vigo
You can use Vigo itself to deploy the transform scripts:
resources:
- name: strip-comments-script
type: file
target_path: /usr/local/bin/strip-comments.sh
content: |
#!/bin/sh
grep -v '^[[:space:]]*#'
mode: "0755"
- name: app-config
type: file
target_path: /etc/myapp/config.conf
content: |
# database settings
host = localhost
port = 5432
stream_edit: /usr/local/bin/strip-comments.sh
depends_on: [strip-comments-script]
mode: "0644"
Use depends_on to ensure the script exists before it's used.
Security
Stream edit scripts run as root on managed nodes. The following guardrails are enforced:
Server-side (during policy bundle building):
- Kill switch: Set
stream_edit.enabled: falseinserver.yamlto strip allstream_editattributes globally. Resources still converge — they just write unfiltered content. - Path whitelist: Scripts must reside under one of the directories in
stream_edit.allowed_paths(default:["/srv/vigo/scripts"]). Scripts outside these directories are stripped with a warning log.
Agent-side (defense-in-depth):
- Path whitelist: The agent re-validates script paths against the allowed directories sent in the policy bundle.
- Ownership check: Scripts must be owned by root (uid 0). Non-root-owned scripts are rejected.
- Permission check: World-writable scripts (mode
xxxx2) are rejected. - Per-script timeout: Each script must complete within
stream_edit.default_timeout(default: 10s). Scripts exceeding this are killed and the resource fails.
Audit logging:
Every script execution is logged with: script path, exit code, input content hash, output content hash, and duration. Failed scripts log at warn level with stderr. Cache hits (where the pipeline is skipped) do not emit audit logs since no script ran.
Examples
Basic
resources:
- name: /etc/myapp/config.toml
type: file
target_path: /etc/myapp/config.toml
content: |
[server]
port = 8080
mode: "0644"
With depends_on
resources:
- name: /etc/myapp
type: directory
target_path: /etc/myapp
- name: /etc/myapp/config.toml
type: file
target_path: /etc/myapp/config.toml
content: |
[server]
port = 8080
depends_on: /etc/myapp
With notify
resources:
- name: /etc/nginx/nginx.conf
type: file
target_path: /etc/nginx/nginx.conf
content: |
worker_processes auto;
notify: restart-nginx
- name: restart-nginx
type: service
service: nginx
state: restarted
subscribes: /etc/nginx/nginx.conf
With when
resources:
- name: /etc/apt/apt.conf.d/99proxy
type: file
target_path: /etc/apt/apt.conf.d/99proxy
content: |
Acquire::http::Proxy "http://proxy.internal:3128";
when: "os_family('debian')"
Absent
resources:
- name: /etc/myapp/old-config.toml
type: file
target_path: /etc/myapp/old-config.toml
state: absent
Source
Copy content from a local file on the envoy instead of specifying it inline:
resources:
- name: hosts-backup
type: file
target_path: /etc/hosts.managed
source: /etc/hosts
mode: "0644"
source and content are mutually exclusive -- setting both is an error.
Backup
Create a backup of the existing file before overwriting:
resources:
- name: /etc/nginx/nginx.conf
type: file
target_path: /etc/nginx/nginx.conf
content: |
worker_processes auto;
backup: "true"
mode: "0644"
With backup: "true", the backup is named <path>.<epoch_seconds>.bak (e.g., /etc/nginx/nginx.conf.1710345600.bak). Use a custom suffix string for a fixed name:
backup: ".orig" # creates /etc/nginx/nginx.conf.orig
Backups are only created when the file exists and its content differs from the desired state. No backup is created on first write or when content matches.
Validate
Run a command to validate content before committing the write. The % character in the command is replaced with the path to a temporary file containing the new content:
resources:
- name: /etc/nginx/nginx.conf
type: file
target_path: /etc/nginx/nginx.conf
content: |
worker_processes auto;
events { worker_connections 1024; }
validate: "nginx -t -c %"
mode: "0644"
If the validate command exits non-zero, the file is not written and the resource fails. The temp file is cleaned up automatically.
Platform
Cross-platform. Owner/group setting requires Unix (Linux/macOS).
Notes
- The
contentfield supports Go template syntax ({{ }}) -- it is rendered server-side during check-in. - The
baseindentparameter prepends spaces to each non-empty line, allowing you to indent content naturally in YAML without affecting the output. - Empty
contentcreates an empty file. - Atomic write prevents corruption if the agent is interrupted mid-write.
stream_editscripts run on the envoy, not the server. The script must already exist on the managed machine (deploy it with a file resource anddepends_on).- Templates are not allowed in
stream_editpaths. Use literal paths or vars with literal overrides at the node level. - Don't whole-file-manage
/etc/hostson a hostdox-enabled envoy. Ifswarm.hostdox: truereaches this host, the agent maintains a delimited# BEGIN/END vigo swarm peersblock in/etc/hosts; atype: fileresource that owns the whole file will clobber that block (and anytype: hostentries) on every run, hostdox re-adds it within ~1 s (it watches/etc/hosts; a ~60 s timer is the backstop), and the two flap. Usetype: hostfor per-entry control, ortype: blockinfileregion editing with markers distinct from# BEGIN/END vigo swarm peers. (Same caution applies to any file another agent subsystem writes its own region into.)