Skip to content

Releases: Code30-OSS/TenantLess

v1.6.0 — Canonical ARM identity, verified drift fingerprints

Choose a tag to compare

@Code30-OSS Code30-OSS released this 24 Sep 18:03
092b80d

Minor release. ARM ids now have one identity rule, used everywhere the simulator compares
them: an ASCII-only case fold (A–Z → a–z, every other byte unchanged). This one rule is
shared by the server, the CLI and PostgreSQL. The served id is still the verbatim casing it
was first written with.

Drift applies now verify their recorded fingerprint against the state they actually persisted,
and roll back on a mismatch. A live state-model conformance test drives the real server and
CLI against PostgreSQL 16.

ARM response shapes, CLI flags and the profile schema do not change. The on-disk identity
migration is one-way
, so read the upgrade notes before upgrading a volume you care about.

Upgrade notes (one-way — read before upgrading)

  • Upgrade the server and the CLI together, and do not downgrade afterwards. 1.6.0
    migrates the volume in two steps:

    • the first 1.6.0 server boot, or the first 1.6.0 tenantless init-db / generate /
      apply-drift / reset / revert-drift, re-derives the overlay identity constraint onto
      the new fold;
    • the first 1.6.0 init-db / generate retires the old locale-lower() identity indexes.

    After that, older versions refuse the volume:

    • once the constraint is converted, a 1.5.0 server refuses to boot, because its
      structural check expects the old constraint;
    • once the indexes are retired, a 1.5.0 CLI fails with PartialBaseSchemaError on
      generate / init-db, because it expects the retired lower(id) index.

    The wheel and the container image are versioned independently. A mixed pair (for example
    a 1.6.0 CLI run against a volume that a 1.5.0 server still serves) breaks the older side on
    its next start. To go back, restore a snapshot taken before the upgrade.

  • Run tenantless init-db once against an upgraded volume (or
    tenantless generate --only-if-empty; the Compose demo profile already does this). The
    server never builds indexes at boot. The new identity indexes are built CONCURRENTLY by
    init-db / generate while the server keeps serving. Until they exist, every identity
    lookup (resource detail, resource-group listing, ARM writes) scans the whole resource table
    and is slow on a large tenant. If they are missing or invalid, the server logs a WARN at
    boot that names them and gives this remedy.

  • Drift fingerprints change once, for the same state. Fingerprint rows are now ordered by
    the canonical identity key rather than by the raw id. The hashed content is unchanged, but
    the digest of unchanged state differs whenever raw and folded order disagree. On the stock
    demo tenant (seed 42) the unchanged state fingerprints as 4ce91b8c… on 1.5.0 and
    6bf663cd… on 1.6.0 (4 of 4,960 rows change position, e.g. Microsoft.DataFactory/… vs
    Microsoft.Databricks/…). So the first apply-drift after the upgrade records a
    parent_fingerprint that does not equal the last 1.5.0 result_fingerprint. Consumers of
    /_sim/drift see one chain break at the upgrade boundary, and golden "same seed → same
    fingerprint" values recorded on 1.5.0 must be re-recorded.

  • Non-ASCII case variants are now distinct identities. Ids that differ only in the case
    of non-ASCII letters (e.g. …/À vs …/à) were one identity under the locale lower() and
    are two now. ASCII case-insensitivity is unchanged. The upgrade refuses to migrate a volume
    that holds such a divergence or a fold collision, and names the ids. Rename or de-duplicate
    them first. After the migration, estates holding non-ASCII ids are accepted as they are.

  • Built from source off an untagged main after 1.5.0? Volumes whose identity indexes
    were built by that interim build carry an older (output-identical) fold definition in any
    long-lived session's cache. Reconnect long-lived sessions, or run
    REINDEX INDEX CONCURRENTLY synthetic.idx_res_arm_id_key and
    REINDEX INDEX CONCURRENTLY synthetic.idx_res_rg_ascii_fold once. Tagged releases and
    1.5.0 volumes are not affected.

Changed

  • One identity rule for ARM ids. Every stateful comparison uses the same ASCII-only
    fold: lookups, writes, tombstones and resurrection, the delete cascade, drift apply and
    revert, and the resolver joins. The fold is identical across the Rust server, the Python CLI
    and PostgreSQL, and a shared known-answer corpus pins it. The served id is always the
    verbatim casing it was first stored with. A deleted resource keeps that casing when a later
    PUT recreates it through a differently-cased URL.
  • Drift never hides a parent that still has children, whatever their casing. The leaf-only
    rule for temporal disappear now uses the canonical identity, so it agrees with the delete
    cascade. Generated tenants are unaffected: their drift output is byte-identical.

Fixed

  • A PUT that recreates a deleted resource through a differently-cased URL returned 500. It
    now returns 201 with the original casing.
  • DELETE of a resource whose name contains a backslash could leave its children live. It now
    matches the name literally (backslash, % and _).
  • Concurrent schema provisioning (for example a server restart during generate) could fail
    with tuple concurrently updated. It now waits instead.

Verified

  • Drift applies re-read the persisted state inside the apply transaction, verify the
    fingerprint and roll back on any mismatch.
  • A model-based conformance test drives the real server and the real drift CLI against
    PostgreSQL 16. It checks global invariants after every step, including identity, served
    casing, revisions, fingerprint chaining and cascades. It runs in CI as a non-skipping job.

Container images: ghcr.io/code30-oss/tenantless-mock-server:1.6.0 and ghcr.io/code30-oss/tenantless-generator:1.6.0.

v1.5.0 — Generic ARM writes (off by default)

Choose a tag to compare

@Code30-OSS Code30-OSS released this 21 Aug 19:06
05ff14f

Minor release. Adds a generic, service-model-independent ARM write plane — PUT / PATCH / DELETE on any resource id — on top of the 1.4.0 overlay substrate.

No breaking changes. The write methods are gated behind --enable-arm-writes and stay off by default, so with writes disabled every response shape, header, CLI flag, and the profile schema are unchanged, and ARM reads remain byte-identical to 1.4.0. Writes are synchronous (the terminal status is returned inline, never a long-running-operation poll).

Added

  • PUT / PATCH / DELETE on any resource id — create, full-replace, two-level merge, and tombstone — for any type, including deeply nested and uncatalogued ("opaque") resources, whose extra top-level keys survive a create → read round-trip verbatim. Every write is a copy-on-write overlay snapshot; the baseline stays immutable.
  • Conditional writes — reads emit a strong ETag, and writes honour If-Match / If-None-Match: a stale If-Match is a 412, If-None-Match: * guards creation, and the read → check → write is serialized so concurrent conditional writers cannot lose an update.
  • Atomic nested-containment cascade on DELETE — deleting a parent tombstones every strict nested descendant in one transaction (by parsed id segments, so servers/s1 never sweeps servers/s10).
  • Write-safety perimeter — writes are off by default; when enabled, the server refuses to start on a non-loopback host without authentication unless an explicit --allow-insecure-writes override is passed, and never grants authorization on its own.

Changed

  • A user write takes ownership of a resource: configuration drift (apply-drift / revert-drift) no longer overwrites, tombstones, or re-mints onto it, so hand-edited resources detach cleanly from drift simulation and the drift fingerprint chain stays consistent across the co-existence.

Verified

  • The full write lifecycle plus the off-by-default gating and the non-loopback startup refusal, validated end-to-end against ephemeral PostgreSQL 16.

Container images: ghcr.io/code30-oss/tenantless-mock-server:1.5.0 and ghcr.io/code30-oss/tenantless-generator:1.5.0.

v1.4.0 — The stateful ARM overlay goes live

Choose a tag to compare

@Code30-OSS Code30-OSS released this 19 Aug 23:57
520bb81

Minor release. Turns on the persistent write-plane substrate that 1.3.0 landed dormant: every
ARM read now resolves through the overlay, all in-place drift is migrated onto it, and the
baseline becomes truly immutable. No breaking changes: on an empty overlay the served ARM
responses are byte-identical to 1.3.0, the new ETag header is additive, and the new reset
command adds functionality without altering any existing flag, response shape, or the profile
schema. Writes are still not enabled by default.

Added

  • Unified resolver behind every ARM read. Resource, resource-group, and detail handlers now
    read through synthetic.arm_resolved_resources — baseline ∪ overlay(present) − tombstones —
    instead of the raw synthetic.resources table. A tombstoned id resolves to a 404, an
    appeared id resolves to its overlay body, and the immutable baseline shows through unchanged.
  • ETag emission on detail GETs. Detail responses now carry a strong, quoted validator
    derived from a canonical preimage of the served representation — b-<hash> for a baseline
    resource, o-<revision> for one carrying overlay drift. Derivation only; If-Match
    consumption arrives with ARM writes in a later release.
  • reset command. Restores a tenant to its seeded baseline by clearing all overlay drift
    (modified rows, appeared rows, and tombstones), leaving synthetic.resources untouched.
  • Structural reader-inventory gate. A build-time gate that inventories every reader of
    synthetic.resources and fails closed unless each one is either routed through the resolver
    or explicitly sanctioned with an in-source SYNRES-ALLOW[...] marker — so no future reader
    can silently bypass the liveness view.
  • Fail-closed boot guard. The server (and the generator / init-db path) refuses to start
    on a structurally incomplete or in-place-mutated substrate rather than serving stale state,
    via a single read-only preflight probe that never takes an ACCESS EXCLUSIVE lock at boot.

Changed

  • All drift now writes to the overlay, never the baseline. Apply and revert of tag,
    property, SKU, and kind edits plus resource appear/disappear are migrated onto
    synthetic.arm_overlay; no drift path mutates synthetic.* in place anymore. Migrated drift
    stays observable through the resolver, and baseline immutability is now a hard guarantee.
  • The console, search, and summary readers apply the liveness filter too. Dependency edges,
    violation counts, and summary aggregates now resolve both endpoints through
    synthetic.arm_resolved_resources, so findings and edges on tombstoned or non-live resources
    drop out — converging exactly with what the ARM plane serves.

Verified

  • Byte-identity on an empty overlay, plus full-visit keyset pagination and $filter traversal,
    re-validated against a ~500K-resource mixed baseline/overlay/tombstone estate — no regression
    from the resolver at scale, and the EXPLAIN aggregate-scale gate holds on PostgreSQL 16.

v1.3.0 — ARM overlay/tombstone/revision substrate

Choose a tag to compare

@Code30-OSS Code30-OSS released this 09 Aug 17:28
c4e0347

Minor release. Adds the persistent substrate for the stateful ARM write plane. No breaking changes — no reader consults the overlay yet, so ARM response shapes, CLI flags, and the profile schema are unchanged and existing responses are byte-identical.

Added

  • ARM overlay substrate (sql/009_arm_overlay.sql) — a unified synthetic.arm_overlay table (one row per normalized resource id; present=false is the tombstone), an unowned non-cycling revision sequence, and a BEFORE trigger that assigns a fresh, strictly-advancing revision on every write.
  • Boot provisioning — idempotent ensure_arm_overlay_schema in the mock-server startup preflight and the generator / init-db path, applied in a bounded transaction. A deep structural-completeness check fails closed on a corrupt substrate rather than serving on it.
  • ETag derivation — versioned derivation of strong, quoted baseline (b-<hex>) and overlay (o-<revision>) validators from a canonical preimage of the served representation. Derivation only — not yet wired into any handler.

Images

  • ghcr.io/code30-oss/tenantless-mock-server:1.3.0
  • ghcr.io/code30-oss/tenantless-generator:1.3.0

PR #64 · full changelog in CHANGELOG.md.

v1.2.1 — Refresh served JWT identity on tenant mutation

Choose a tag to compare

@Code30-OSS Code30-OSS released this 06 Aug 13:05
0839118

Patch release. Fixes a JWT-identity staleness bug.

Fixed

  • The served token identity now tracks the current tenant instead of freezing at boot. The RS256 signer backing /token, the JWKS and OpenID-discovery documents, and --enforce-auth validation embeds the served tenant in its issuer. It was built once at startup, so after a control-plane generate (new tenant), restore (the snapshot's tenant), or reset (empty → nil) the mock kept serving the previous (or nil) identity until a restart. The control plane now rebuilds the signer to the effective tenant when a tenant-mutating job completes — after the DB change commits and before the job is reported succeeded, as one atomic key+kid+issuer epoch, and only when the tenant actually changed (an unchanged tenant does not rotate the key or invalidate live tokens). A failed re-read/rebuild fails the job rather than reporting a mismatched identity. Analyze and snapshot-save do not refresh.

No breaking changes: additive behavior, unchanged ARM shapes / CLI flags / profile schema; the default any-Bearer (--enforce-auth off) posture is unaffected.

Docker image: ghcr.io/code30-oss/tenantless/mock-server:1.2.1

v1.2.0 — Execution budgets

Choose a tag to compare

@Code30-OSS Code30-OSS released this 04 Aug 22:21
4c1bd5f

Minor release. Adds resource-exhaustion guards across the server so an any-Bearer caller cannot force unbounded memory/CPU/time. No breaking changes: ARM response shapes are unchanged, the new CLI flags are all additive (with defaults), and the profile schema is untouched. Every new failure is returned in the ARM CloudError JSON envelope with a fixed, non-leaking message.

Added

  • Global request timeout — REQUEST_TIMEOUT_SECS (default 30); an elapsed request returns 504 GatewayTimeout.
  • Server-wide concurrency limit with load-shedding — CONCURRENCY_LIMIT (default 64); at capacity the server sheds with 503 ServiceUnavailable + Retry-After: 1 rather than queueing. An invalid value fails startup.
  • Server-wide database timeouts — DB_STATEMENT_TIMEOUT_MS (default 10000) and DB_ACQUIRE_TIMEOUT_SECS (default 5). A cancelled statement (Postgres 57014) returns 504; pool exhaustion returns 503 + Retry-After: 1. The cost query inherits the server-wide budget (never loosens a tighter global) and keeps its own app-level "too expensive" 400. The startup schema preflight is exempt from the runtime timeout.
  • $filter size cap — over 2 KiB or 200 tokens is rejected 400 before parsing, on both the resource-list and roleAssignments $filter surfaces.
  • /_sim search-term cap — over 200 characters is rejected 400 before the tenant-wide scan.
  • Cost query body-size cap — bounded at 64 KiB, enforced while streaming (chunked-safe); oversized body → 413, non-JSON Content-Type → 415, malformed JSON → 400.

The operational budgets are environment-tunable; the structural caps are fixed safety bounds.

Docker image: ghcr.io/code30-oss/tenantless/mock-server:1.2.0

v1.1.12

Choose a tag to compare

@Code30-OSS Code30-OSS released this 04 Aug 18:47
fc21e7f

Security-hardening patch. No API, CLI-flag, or profile-schema changes -- only the published mock-server container image changes. Completes the container-hardening work (base images were already digest-pinned; the generator/demo-builder images already ran non-root).

Changed

  • The mock-server runtime image runs as a non-root user (uid 10001, nologin). The binary is world-executable and binds 8080 (> 1024, no privileged-port capability needed); serving the ARM API reads only env + the embedded SPA/SQL and writes nothing to the filesystem, so an unprivileged UID suffices. CI asserts the built image's effective id -u is non-zero, so a regression to root fails the build.

Documented

  • The slim runtime image cannot run the control plane (generate/analyze/snapshot/restore), which shell out to uv run tenantless and pg_dump/pg_restore -- a deliberate limitation, now explicit in the Dockerfile and changelog, that keeps the served-API image toolchain-free.

Full Changelog: v1.1.11...v1.1.12

v1.1.11

Choose a tag to compare

@Code30-OSS Code30-OSS released this 04 Aug 17:27
87b399a

Bug-fix patch. No CLI-flag or profile-schema changes, and no change to the shape of a roleAssignments item. Brings the Microsoft.Authorization/roleAssignments list endpoint in line with the other ARM list endpoints.

Fixed

  • Keyset pagination. The role-assignment listing now honors $top (clamped 1..=1000), an opaque $skiptoken continuation over the assignment id, and emits an absolute nextLink when another page exists, instead of returning every assignment for a subscription in one unbounded response.
  • $filter honored, not silently ignored. The ARM atScope() and principalId eq '{guid}' forms (and their and-composition) are applied as bound $N predicates; any other or malformed form, including a non-GUID principalId, returns an explicit 400 InvalidRequestContent ("invalid $filter"). Azure defines atScope() as "assignments at or above the given scope"; because this simulator models no management-group or tenant-root assignments, at a subscription scope that reduces to exactly /subscriptions/{sub}. The $filter is echoed in nextLink.
  • Index-backed keyset. A composite idx_ra_sub_assignment (subscription_id, assignment_id) turns the seek + ORDER BY assignment_id into an index range scan. It ships in the existing idempotent sql/005_identity.sql migration (applied unconditionally by generate/init-db), so a database provisioned before this release gains the index automatically on its next run.

Full Changelog: v1.1.10...v1.1.11

v1.1.10 - index the case-insensitive resource-group predicate

Choose a tag to compare

@Code30-OSS Code30-OSS released this 04 Aug 15:28
564aec2

Bug-fix patch (performance). No API, CLI-flag, or profile-schema changes. Follow-up to 1.1.8.

Fixed

  • The case-insensitive resource-group predicate is now index-backed. The existing idx_res_rg (subscription_id, resource_group_name) can serve only the subscription prefix for a lower(resource_group_name) match — Postgres cannot use its second key for the functional predicate — so at scale a resource-group-scoped listing could scan every resource in the subscription, and the cost query could process a broader join before filtering. A new functional index idx_res_rg_lower (subscription_id, lower(resource_group_name), id) backs both paths; the trailing id serves the listing's ORDER BY id / keyset pagination. This mirrors the existing lower(id) functional index for the resource-detail lookup.
  • Existing databases upgrade automatically. The index ships as a new idempotent migration (sql/008_rg_lower_index.sql) applied unconditionally by generate and init-db — the same twin-migration mechanism as the cost/identity/drift/web-metadata schemas. A database provisioned before this release gains the index on its next generate/init-db with no manual step, and it is deliberately not part of the base-schema completeness check, so an additive performance index never makes a healthy install read as "incomplete" or demand a re-provision.

v1.1.9 - bound the control-plane job store memory

Choose a tag to compare

@Code30-OSS Code30-OSS released this 04 Aug 14:50
94aab37

Bug-fix patch. No API, CLI-flag, or profile-schema changes — the 1.x public surface is unchanged. Affects only the in-memory control-plane job registry (present when the control plane is armed).

Fixed

  • The job registry no longer grows without bound. Every armed control-plane job (generate / analyze / reset / snapshot / restore) was inserted into an in-memory map and never removed, so a long-running server accumulated job history for the life of the process. A fresh job now first evicts the oldest already-terminal jobs down to a retention bound (100), so completed history stays bounded; in-flight (queued/running) jobs are never evicted.
  • A single captured log line can no longer grow memory without bound. The per-job log kept only the last N lines, but each line was read with an unbounded reader (next_line() reads the whole physical line into memory first), so a child emitting an enormous newline-free line could allocate it in full. Captured lines are now byte-capped (8 KiB retained per line, with a truncation marker) and the physical remainder is drained without being retained, so total per-job log memory is bounded.