Infrastructure as Code
VSKI deployments can be managed as code: the desired state of collections,
pipelines, schedules, secrets, plugin instances and graphs is declared in
YAML files and converged onto a deployment with the vski
CLI (vski plan / vski apply / vski pull).
vski plan # diff the ./vski directory against the deployment (exit 2 on drift)vski apply # apply the diff (asks first; --yes skips)vski pull # export the live deployment back to YAMLThe app template is the reference repository:
its vski/ directory declares a collection, a plugin instance, a pipeline
and a nightly schedule, and make iac-plan / iac-apply / iac-pull wrap
the command line.
File layout
Section titled “File layout”- Default directory:
./vski(pass another as an argument). Every*.yaml/*.ymlfile under the directory is merged (recursively, in path order). - Every file requires
version: 1. Unknown keys (at any level) are errors, not warnings: the format is strict so typos never silently deploy half a resource. - An optional per-file
database:scopes db-bound resources (collections,graphs,plugins); defaultdefault. Global kinds (pipelines,cron,webhooks,secrets) are unique across the deployment. - Resource names must match
[a-zA-Z0-9_-]{1,64}; duplicate declarations across files are errors. vski pullwrites one file per kind per database:vski/<db>/<kind>.yamlplus top-levelpipelines.yaml,cron.yaml,webhooks.yaml,secrets.yaml,databases.yaml.
version: 1database: default
collections: notes: type: base # base | auth fields: - {name: title, type: text, required: true} - {name: body, type: text} - {name: mood, type: select, options: {values: [good, bad]}} - {name: author, type: relation, options: {toCollection: users}} rules: # null ≡ "" = admin-only; "*" = public list: "*" create: null indexes: - {name: idx_notes_created, sql: "CREATE INDEX idx_notes_created ON notes(created)"} views: - {name: notes_view_recent, sql: "SELECT * FROM notes ORDER BY created DESC"} fts: {fields: [title, body]} # full-text index over these fields options: {triggers: []}
pipelines: note-intake: # the map key is the pipeline name enabled: true triggers: - {type: collection, collection: notes, actions: [create]} steps: - {id: enrich, type: plugin, target: hello/echo, params: {message: "{{trigger.record.title}}"}} output: {echoed: "{{steps.enrich.output.message}}"}
cron: # global nightly-scan: schedule: "0 2 * * *" type: http url: "pipeline://note-intake" # scheduled pipeline kick
webhooks: notify: url: https://example.com/hook method: POST headers: {X-Source: vski} retryLimit: 3
secrets: openai_key: fromEnv: OPENAI_API_KEY # value read from the env at apply time
plugins: # plugin instances (db-scoped) hello: plugin: hello enabled: true config: {greeting: hello}
graphs: knowledge: rules: {list: "*"} options: {directed: true, maxNodes: 100000}
databases: # ensure-exists; never deleted by IaC analytics: {description: Analytics}Semantics
Section titled “Semantics”Sub-block conventions
Section titled “Sub-block conventions”Each collection sub-block is independently managed: omitted means
unmanaged (left as is), present is authoritative: an explicit empty
indexes: [] removes every index. fields is required when creating a
collection.
null and "" are the same thing (admin-only/deny); "*" is public;
anything else is a rule expression. Because the migration format cannot
express “revert to null”, desired null is applied as "", identical
semantics.
Field changes
Section titled “Field changes”Fields are matched by name. New fields are added; removed fields are
soft-dropped (column data stays in SQLite). Modifying an existing field
(type, required, unique, hidden, default, options) is rejected with an
explanation: drop and re-add the field (data loss for that column) or use a
custom_sql migration. Auth collections’ system fields (email,
passwordHash, tokenKey, verified, roles, data) are never managed.
How apply executes
Section titled “How apply executes”databases: created when missing (never deleted).- Collections through the migration system: the diff is rendered as an
ordinary migration file (same format the dashboard’s
generator emits), uploaded via
POST /api/migrations/saveand applied, so schema changes keep their audit trail, pre-migration snapshots and rollback.--save-migrations <dir>also writes the generated YAML to disk for review artifacts. - Graphs, cron, webhooks, secrets, plugin instances: via their CRUD APIs. Plugin config changes restart exactly that instance’s process (server-supervised desired state).
- Pipelines last (they may reference collections and plugins). Every pipeline write is validated server-side before anything is applied.
Secrets
Section titled “Secrets”Secret values never live in YAML. Declare the name and its env source; apply
sets the value from the environment when the variable is present. A new
secret whose env var is unset is a warning, not drift (so plan can
gate CI on convergence in environments that legitimately don’t hold the
value). Values are write-only server-side: drift is undetectable, rotation
is imperative (vski secrets set).
Deletion and prune
Section titled “Deletion and prune”state: absenton a resource deletes that specific live resource (collections through adrop_tablemigration, which is a soft delete).- By default, live resources not declared in YAML are untouched.
vski apply --pruneadditionally deletes undeclared resources of the kinds that appear in your files (per database), after confirmation. - Databases are never pruned or dropped by IaC.
Convergence and CI
Section titled “Convergence and CI”apply is convergent: re-running against the same files is a no-op. plan
exits 0 only when the deployment matches the desired state. A CI recipe:
# .forgejo/workflows/iac.yaml (excerpt)- run: vski plan vski # fails the pipeline on drift (exit 2)# after merge:- run: vski apply vski --yesPull (bootstrap from live)
Section titled “Pull (bootstrap from live)”vski pull exports the live state (including rules, indexes, views, FTS
fields, pipeline definitions, plugin configs) in the same format apply
consumes; the round trip is a no-op by construction (covered by e2e tests).
Secret values come out as fromEnv: SECRET_<NAME> placeholders you fill in.
What IaC deliberately does not manage (v1)
Section titled “What IaC deliberately does not manage (v1)”- Record/graph data (seeding): use the APIs or plugins.
- API keys (credentials), replication topology, server env config
(
.env/compose; see Getting Started). - Plugin source code distribution: plugins are baked into distribution images (Plugins); IaC manages their instances.