heyer.systems

Develop

App reference

All fields of iac.json, the variables and helper roles of iac-core, tests, CI and common mistakes.

How an installation runs

Every way – container, image build, first boot of a VM, update, adoption – ends up at the same playbook inside the guest:

Proxmox host                    Guest (LXC, VM or image build)
────────────                    ───────────────────────────────────────────────────────
ct.sh ──── pct exec ──────────▶ bin/iac-install  →  ansible-playbook site.yml
vm.sh ──── ready image ───────▶   (ran in the chroot during the image build, again on first boot)
                                update      → Debian upgrade → bin/iac-install (iac_mode=update)
                                adopt.sh    → tasks/adopt_check.yml → backup
                                              → bin/iac-install (iac_mode=adopt)

site.yml:
  iac_facts      iac.json, instance, settings, secrets, modules
  iac_baseline   SSH, base packages, base firewall rules, update
  iac_modules    PostgreSQL, Valkey, …
  YOUR APP       tasks/main.yml        ← this is what you write
  iac_finish     handlers (restarts) → firewall from "ports" → start "services"
                 → check "health" → /etc/iac/instance.json

ct.sh, vm.sh and adopt.sh only live in iac-core (entry/). The website serves them for every app with its name. adopt.sh exists as soon as the app has tasks/adopt_check.yml.

iac.json

FieldMeaning
namelowercase letters, digits, -. The repository is called iac-<name>, the one-liners /<name>/ct.sh
title, descriptionname and one sentence (English) for the website and the dialog
categorycategory in the catalog: ai, automation, database, development, documentation, helpdesk, infrastructure, networking, security or web
tagskeywords for search and filters, lowercase with -, e.g. ["dns", "ad-blocking"]
websiteproject website of the software
upstreamwhere the version comes from: {"type": "debian", "repo": "<package>"}, {"type": "apt", "repo": "<package>"} (third-party repository iac-core/apt/<package>.sources, e.g. Zammad), {"type": "github-release", "repo": "<owner/repo>"}, {"type": "npm", "repo": "<package>"}, {"type": "url", "url": "https://…"} (first line = version) or {"type": "other", "version": "…"}. With "major_lock": true, update stays in the installed major version
supportslxc, vm (with VM image), docker (with docker-compose.yml in the repository)
defaultsresources per platform: cpu, ram (MiB, at least 512), disk (GiB). The boot test uses the VM RAM (at most 2.5 GiB)
image.sizesize of the root partition in the VM image, e.g. "8GiB" (default 3GiB); the disk grows when the guest is created
portsthe firewall is built from this: port, proto (tcp/udp), description, access (public = everyone, private = private networks only)
servicessystemd units of the app: iac-core enables and starts them, the smoke test checks them
healthurl that must answer after every installation; status = allowed HTTP codes (default [200])
secretsgenerated once per instance, never in the image: a name or {"name": …, "length": 32} → iac_settings.<name>
settingssettings when creating the guest (var_<name>): name, title, description, secret → iac_settings.<name>
modulesonly what the app needs: text = required module, {"name": …, "default": true} = preselected, "default": false withdraws a preselection of the catalog (e.g. auto-update). All other modules can be chosen anyway (modules)
adopt.backuppaths adopt.sh backs up before the adoption
accesshint after creating the guest, {ip} is replaced
iconlogo on the website: slug at selfh.st/icons (default: name)
hiddentrue = does not appear on the website

The complete schema: iac-core/schema/iac.schema.json.

Variables

iac-core sets these variables, your tasks may read them:

VariableContent
iac_buildtrue in the image build (chroot: no service runs, no secrets, no database)
iac_modeinstall, update or adopt
iac_platformlxc or vm
iac_settingssettings and secrets of the instance, e.g. iac_settings.myapp_db_password
iac_modules_activeactive modules
iac_jsonthe whole iac.json
iac_app_version_pinversion the image build asks for (otherwise empty)
iac_upstream_latestlatest version according to upstream (only queried when needed)

All of them with explanations: header of iac-core/ansible/site.yml.

Versions

Debian packages: with "upstream": {"type": "debian", "repo": "<package>"} iac-core reads the version itself. The app does not have to do anything.

Everything else (GitHub release, own download page): the role iac_version decides what is installed. You only tell it how to find out the installed version:

- name: Version to install
  ansible.builtin.include_role:
    name: iac_version
  vars:
    iac_version_command: /usr/local/bin/myapp --version
    iac_version_regex: 'v?([0-9.]+)'          # optional: first group = version

- name: "Install myapp {{ iac_version_target }}"
  when: iac_version_target != iac_version_installed
  block:
    - name: Download (checksum from the release)
      ansible.builtin.get_url:
        url: "https://github.com/vendor/myapp/releases/download/v{{ iac_version_target }}/myapp.tar.gz"
        dest: /tmp/myapp.tar.gz
        checksum: "sha256:https://github.com/vendor/myapp/releases/download/v{{ iac_version_target }}/SHA256SUMS"
        mode: "0644"
    # … unpack, install, notify: Restart myapp
Situationiac_version_target
not installedthe image’s version (iac_app_version_pin) or the latest
update (and adoption)the latest if it is newer – with "major_lock": true only within the major version, a new major version is reported
otherwisethe installed one

Two things are mandatory:

  • Checksum: checksum: with the release’s checksum file. No download without a checksum. If a project publishes no checksums, settle that before building the app.
  • Only install on a new version: when: iac_version_target != iac_version_installed, file to /tmp and delete it afterwards. Without checksum, get_url does not download an existing file again – a fixed target like /var/cache/myapp.tar.gz would unpack the old archive on every update.

iac_version_installed is empty as long as nothing is installed. The role sets the version for instance.json itself. Examples: iac-traefik (binary, update with rollback), iac-netbox (release per directory), iac-ai-workstation.

Helper roles

Enable and start a service, also in the image build (there only enable):

- ansible.builtin.include_role:
    name: iac_service
  vars:
    iac_service: myapp-worker

You do not need this for the services in services – iac-core starts those itself.

Database: postgresql in modules, then:

- name: Database
  ansible.builtin.include_role:
    name: postgresql
    tasks_from: database
  vars:
    postgresql_db: myapp
    postgresql_user: myapp
    postgresql_password: "{{ iac_settings.myapp_db_password }}"
  when: not iac_build

With MariaDB instead of PostgreSQL: module mariadb, tasks_from: database with mariadb_db, mariadb_user and mariadb_password.

Files in the guest

  • What your tasks place with copy/template belongs to iac and is reconciled on every run. Such files start with # managed by iac – do not edit.
  • Changes by users go into files iac does not touch – plan a local file for that (iac-netbox: /etc/netbox/configuration.local.py).
  • You do not need a firewall file – it is built from ports.
  • Secrets never go into the repository: secrets in iac.json, in the template {{ iac_settings.name }}, tasks that use them get no_log: true.

First login

If the app has a login, it must be usable right after the installation:

  • Password as a secret in iac.json, e.g. {"name": "myapp_admin_password", "length": 24}.
  • The tasks create the first user with it, only as long as there is no admin yet – iac never touches a changed password or your own admins. no_log: true, when: not iac_build.
  • access names the user and where to find the password; the app page (“Then:”) and ct.sh show it:
"access": "Web: http://{ip}:8000/ – login: admin, password in the guest: grep myapp_admin_password /etc/iac/settings.env"

A setup wizard in the browser is not enough: whoever opens it first on the network decides the admin, and often the service only listens on its ports after it – then the port check, smoke test and boot test fail. Create a start configuration instead (force: false, so later changes stay) that skips the wizard. Example: iac-netbox (netbox_admin_password).

Smoke test

iac-core already checks instance.json, the services, health and the ports in the firewall. tests/smoke.sh only contains what is specific to your app:

check "API answers"             'curl -fsS http://127.0.0.1:9090/api/health | jq -e .ok'
check "runs as its own user"    'ps -o user= -C myapp | grep -qx myapp'

tests/smoke.sh is not a script of its own: iac-smoke reads it with source. So no shebang, no set -e, no exit – only check "description" 'command' (header as in the template, # shellcheck shell=bash).

check never aborts: all checks run, failed ones show the command and its output. The same test runs in the boot test of every image and by hand in the guest (bash /var/lib/iac/src/core/bin/iac-smoke).

Adoption

FileWhenWhat
tasks/adopt_check.ymlbefore the questioncheck – change nothing. Refuse with ansible.builtin.fail and a reason
tasks/adopt.ymlafter the question and the backup (adopt.backup), before tasks/main.ymlmove existing data into the iac layout

Example: iac-traefik (adopts its own configuration as traefik.local.yaml). If it cannot be done automatically, a guide belongs in docs/MIGRATION.md – the website links it.

Testing

Lint – typos, wrong fields, Ansible syntax, leftovers of old apps:

iac-core/tools/lint.sh iac-myapp

Without Proxmox – Docker container with systemd:

iac-core/tools/dev-container.sh iac-myapp                     # install + smoke test
iac-core/tools/dev-container.sh iac-myapp                     # again = like update
IAC_APP_VERSION=1.2.3 iac-core/tools/dev-container.sh iac-myapp   # specific version
docker exec -e IAC_MODE=update iac-dev-myapp \
  bash /var/lib/iac/src/core/bin/iac-install                  # update path of the app

The second run should show changed=1 (only instance.json) – then the app is idempotent. The container is not a real LXC (privileged, no cloud-init), but it is enough for the app logic.

On Proxmox – a branch, before it goes to main:

IAC_APP_REF=my-branch bash -c "$(curl -fsSL https://iac.heyer.systems/myapp/ct.sh)"

In the guest, IAC_VERBOSE=1 bash /var/lib/iac/src/core/bin/iac-install shows all task results.

CI

The workflows are the same in all apps and consist of a single call. The steps live in iac-core (ci/lint, ci/image):

WorkflowWhenWhat
lint.ymlevery push, every pull requesttools/lint.sh in a container
image.ymlpush to main, Mondayslint → build image → boot in QEMU → boot test incl. iac-smoke → publish

Only an image that passes the boot test is published. Hostname and size of the image come from iac.json. For more (additional bootstrap-vz settings) there is the optional image/manifest.yml.

Own modules

What several apps can use (database, cache, monitoring …) belongs in iac-core as a module – then every app can use it:

  1. Role iac-core/ansible/roles/<module>/ with module_state: present installs and starts, absent removes packages and files again – data stays.
  2. Firewall rules of the module in /etc/nftables.d/40-<module>.nft.
  3. Entry in iac-core/modules.json: title, description (English), supports (lxc, vm), optionally default and icon (slug at selfh.st/icons, default: the module’s name).

A module for one app only lives in roles/<module>/ in the app repository. Structure and examples: iac-core/ansible/roles/README.md. Notes for the module’s page on this website go into iac-website/module-notes/<module>.md.

Common mistakes

SymptomCause
Workflow never runsfile is in .forgejo/ instead of .forgejo/workflows/
Lint: module … does not existmodules names a module that does not exist (yet)
Lint: … belongs to the old layoutapp.func, packages.list or rootfs/ copied from an old app
Lint: ct.sh is not used any moreold entry scripts in the repository – delete them, the website serves them
Lint: category: …category missing or not one of the list above
Image build aborts in the chroota service is started or a database is used – when: not iac_build
Service restarts on every updaterestart as a task instead of a handler with notify
neither the role iac_version nor iac_app_versionapp from outside Debian without the role iac_version
Lint: iac.json does not match the schemae.g. "major_lock": 0 instead of true – fields exactly as in the schema
update installs no new versiondownload to a fixed file without checksum – see versions
Port check fails after a fresh installationservice only listens after a setup wizard – see first login
iac-smoke ends without a summaryset -e or exit in tests/smoke.sh – the file is sourced, an error then ends the whole test
No VM image, vm.sh finds nothing.forgejo/workflows/image.yml is missing or was changed – take the template
Own changes gone after updatechange in a managed by iac file instead of a local file
item is undefined in a looploop over something that may be missing – set_fact first, then loop

Ansible knowledge

Available is ansible-core from Debian, so the modules of ansible.builtin – no other collections: documentation of the built-in modules.