2 IaC
Anton Nesterov edited this page 2026-11-01 20:55:38 +01:00

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 YAML

The 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

  • Default directory: ./vski (pass another as an argument). Every *.yaml / *.yml file 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); default default. 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 pull writes one file per kind per database: vski/<db>/<kind>.yaml plus top-level pipelines.yaml, cron.yaml, webhooks.yaml, secrets.yaml, databases.yaml.
version: 1
database: 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

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.

Rules

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

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

  1. databases: created when missing (never deleted).
  2. 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/save and 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.
  3. Graphs, cron, webhooks, secrets, plugin instances: via their CRUD APIs. Plugin config changes restart exactly that instance's process (server-supervised desired state).
  4. Pipelines last (they may reference collections and plugins). Every pipeline write is validated server-side before anything is applied.

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

  • state: absent on a resource deletes that specific live resource (collections through a drop_table migration, which is a soft delete).
  • By default, live resources not declared in YAML are untouched. vski apply --prune additionally 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

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 --yes

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)

  • 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.