nixops4 resource providers: vpsfree.cz VPS lifecycle, deploy-rs closure activation, Forgejo repo conformance.
  • Nushell 69.4%
  • Nix 30%
  • Shell 0.6%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
~larandar 247bc85647 openspec: join the shared contracts store
Ratification law 9, active HITL, direct fastlane+main push,
freeholder-authorized. Ting/contracts APPLY.md step 5: wire this
store-only estate repository to the shared `contracts` OpenSpec store
instead of its own private per-repo wiki store. No local openspec/specs
or openspec/changes existed to remove.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-04 20:45:28 +02:00
flakes kanidm: attributes written onto an account that already exists 2026-09-04 11:06:35 +02:00
openspec openspec: join the shared contracts store 2026-09-04 20:45:28 +02:00
.gitignore devenv: resolve the providers wiki as its OpenSpec store (#40) 2026-08-30 05:07:51 +00:00
devenv.lock devenv: resolve the providers wiki as its OpenSpec store (#40) 2026-08-30 05:07:51 +00:00
devenv.nix devenv: resolve the providers wiki as its OpenSpec store (#40) 2026-08-30 05:07:51 +00:00
devenv.yaml devenv: resolve the providers wiki as its OpenSpec store (#40) 2026-08-30 05:07:51 +00:00
LICENSE initial: three nixops4 providers, extracted from lar.ad/flake-ops 2026-08-10 01:02:13 +02:00
README.md nixops-forgejo: forge_user carries the identity class (#151) 2026-08-15 22:40:32 +02:00

nixops4-providers

Five nixops4 resource providers, written in Nushell, each a self-contained flake under flakes/.

provider what it manages
nixops-vpsfree vpsfree.cz VPS lifecycle through the vpsfree Ruby CLI (vpsfreectl), plus a state_file backend so a deployment root works without nixops4's Rust local provider
nixops-deploy-rs NixOS closure activation through deploy-rs
nixops-forgejo Forgejo repo conformance — orgs, repos, branches, protections, labels, webhooks
nixops-openbao OpenBao's rule surface — ACL policies, engine mounts, auth methods, their config and roles
nixops-kanidm kanidm service accounts and group membership — the existence half of a machine principal, never its credential

All are driven against real APIs, not stubs: nixops-vpsfree births and resizes actual containers at vpsfree.cz, and nixops-forgejo reconciles a live forge.

nixops-kanidm draws a line the others do not have to: an account's EXISTENCE is declarable and therefore declared, while its api-token is generated at mint time and cannot be predeclared. Minting, revocation and group-drift resolution stay with Ting/Vedanta; this provider has no resource type through which to hold a credential, and checks.example asserts that it never grows one.

Consuming one

Each provider is a separate flake, so take only what you need:

inputs.nixops-vpsfree.url =
  "git+https://jo.et0.pw/midgard/nixops4-providers?dir=flakes/nixops-vpsfree";
inputs.nixops-vpsfree.inputs.nixpkgs.follows = "nixpkgs";
inputs.nixops-vpsfree.inputs.flake-parts.follows = "flake-parts";

Use the https URL, not an ssh one. A sandboxed Nix fetch carries no credentials and resolves no private name, so an ssh:// input makes every consumer's build depend on the fetching machine's keys and DNS.

nixops-deploy-rs additionally wants deploy-rs.follows: the deploy binary the provider runs and the activation package baked into a profile have to come from one tree.

Then wire the module into your nixops4 root:

nixops4.providers.vpsfree = inputs.nixops-vpsfree.modules.nixops4Provider.default;

Resource types

nixops-vpsfree

  • vps (stateful) — created exactly once, updated in place where vpsAdmin allows, recreation refused. Tolerates the known haveapi-client async crash (Response#meta) and recovers the VPS id by polling vps list for the hostname.
  • state_file (stateless) — append-only JSON-patch event log, envelope compatible with nixops4's own state_file provider. Included so a deployment root works without building nixops4's Rust local provider, which has no binary cache upstream.

nixops-deploy-rs

  • deployment (stateful) — create is the first activation; update re-activates when the closure or the address moved; an apply that moves neither runs nothing. The closure is passed in as a store path (system) rather than looked up: protocol v0 only calls a provider when its inputs change, so a looked-up closure would mean editing the NixOS configuration changed nothing nixops4 could see, and the deploy would silently never run.

nixops-forgejo

  • org, repository, branch, branch_protection, label, webhook — reconciled add-and-repair, never pruning. The spec is at least the declaration, never exactly it, because a repo lawfully carries labels that did not travel. Schema v0 has no destroy, which makes that structural rather than merely intended.
  • forge_user (stateless) — a user account's identity class (full_name, pronouns), adoption only: the login must already exist, is never renamed, and is never sent in a mutation body. pronouns is a sigil prefix plus a value — only the first character is ever set, replaced or removed, and the tail is copied byte for byte, never onto nothing and never normalized.

nixops-openbao

  • policy, mount, auth_method, auth_config, auth_role — the rule surface of the estate's secret-manager (flake-ops#67), reconciled add-and-repair. Nothing here is stateful, and that is a finding rather than an omission: OpenBao addresses everything by the path it was written to, and a path is in the declaration, so the provider adopts by address instead of remembering a server-assigned id. An existing mount or auth method is left exactly as it is — remounting moves the data under it, re-enabling a method logs out everyone holding a token from it, and the protocol has no verb for what either change actually is.

    The split with the host is deliberate and permanent: the provider owns the rule surface, applied from the operator's seat where authority already exists (BAO_TOKEN, same out-of-band contract as VPSADMIN_TOKEN and FORGEJO_TOKEN); the on-host openbao-provision oneshot keeps liveness — unseal at boot, which must converge with nobody home.

Each provider vendors its own copy of protocol.nu — the schema-v0 JSON-lines harness, plus the per-request child process that works around a nushell bug. Self-containment is why one shared copy is three; they collapse together when that bug is fixed.

The package.nix files are wrapped executables with nothing baked in: VPSADMIN_TOKEN, FORGEJO_TOKEN and BAO_TOKEN come from the caller's environment at apply time, and nix is suffixed onto the deploy provider's PATH rather than prefixed, so the operator's own nix — the one matching their daemon and substituters — keeps winning.

The contract

Every provider here exposes exactly two things, mirroring nixops4's own nix/providers/local.nix:

  • packages.<system>.default — the provider executable
  • modules.nixops4Provider.default — the resource-type declarations

No provider imports across its own directory boundary, so each moves independently and follows its consumer's nixpkgs.

Written against resource protocol schema v0. The schema is versioned with nixops4 rather than separately, so a protocol change upstream is a change here.

Checks

Each provider carries its own conformance suite, run against stub binaries (vpsfreectl, deploy) so none of it reaches a network:

  • vpsfree — 14 protocol assertions: state_file create/event/read with JSON-patch replay, stateless-vps refusal, unknown-field refusal, create surviving the async client crash, second-birth refusal, in-place update, immutable-field refusal, unknown-request refusal. Plus a streaming test proving an answer arrives before stdin EOF, since nixops4 speaks synchronous per-line request/response.
  • deploy-rs — 15 assertions, half about what the provider ran rather than what it answered: first deploy invokes deploy with the address, user, ssh options and flake#node.profile target; a converged update deploys nothing; a moved closure and a moved address each redeploy; a failed deploy becomes an errorResponse rather than a successful output. The stub logs every invocation, so "three deploys for eleven requests" is asserted by count.
  • openbao — 28 plan assertions and 10 protocol ones. The plan suite is mostly about the branch where nothing happens: a policy the store already holds, an existing mount, a converged role, a comma-joined declaration matching the list the store returns. Two cases pin the honesty rules — a declared key the store does not echo (ttl, which reads back as token_ttl in seconds) counts as drift rather than agreement, and a config carrying a client secret can never be verified and therefore writes on every apply. The protocol suite runs with BAO_TOKEN cleared, so the message an operator with an expired OIDC session gets is asserted rather than assumed.
  • forgejo — a schema test comparing the module's declared inputs against what plan.nu accepts, taken from the module itself rather than restated so it cannot pass by agreeing with a stale copy; a plan test exercising every "which request makes the forge match this declaration" branch as a pure function; and a protocol suite checking that a refusal returns an errorResponse rather than killing the loop.
$ nix flake check ./flakes/nixops-vpsfree
$ nix flake check ./flakes/nixops-deploy-rs
$ nix flake check ./flakes/nixops-forgejo

There is deliberately no root flake: the repository is a container for three independent ones, and a root would invent a shared lock they do not want.

Provenance

Extracted from lar.ad/flake-ops, which remains their first consumer. They live outside it because a provider is useful to anyone driving nixops4 at the same hoster or forge, and being vendored in one fleet's configuration repo is the thing that stops that.