Skip to content

The Cursed Journal

A record of the dekube ecosystem's rituals — engine, distributions, extensions, manager — in the order they were committed. Earlier entries use the names that existed at the time (h2c, helmfile2compose org, h2c-core); the rename to dekube is documented where it happened. Read at your own risk.

The scribe kept a journal — not for posterity, but as evidence. Should the temple collapse, the authorities would need to know what had been attempted, in what order, and by whom. The scribe was also the architect. This complicated the trial.

Necronomicon, On Accountability (apocryphal)


The false keeper of the deep wells

2026-04-01 · cnpg: v0.1.0

CloudNativePG is the most complex operator the ecosystem has faced. In Kubernetes, it runs PostgreSQL inside a custom image that wraps the database in a controller binary — replication, automatic failover, TLS certificate management, scheduled backups, connection pooling via PgBouncer. The operator manages six secrets per cluster (CA, server TLS, replication TLS, pooler TLS, app credentials, superuser credentials with connection URIs), creates three services (-rw, -r, -ro), and deploys the database through an init container that copies the controller binary before starting postgres. None of this is standard postgres. None of it works without the Kubernetes API.

The provider's approach: pretend none of that exists.

The identity theft. Extract the version tag from ghcr.io/cloudnative-pg/postgresql:18.3, discard everything else, substitute postgres:18.3 from Docker Hub. The CNPG controller, the init container, the status endpoints, the replication protocol — replaced with POSTGRES_USER, POSTGRES_PASSWORD, and a mounted postgresql.conf. The database process is identical. The management layer is gone.

The forgery chain. CNPG auto-generates a per-cluster CA with server certificates. Rather than reimplementing cert generation, the provider emits synthetic cert-manager CRDs — fake Issuers, fake Certificates — into the manifest pipeline at priority 90. The cert-manager extension processes them at priority 100 believing they came from a real Helm chart. The result: a fully functional TLS chain where no real cert-manager CRD ever existed. The forger commissioned forgeries from another forger.

The pooler lie. A CNPG Pooler spawns a PgBouncer deployment — connection pooling, transaction-level multiplexing, TLS termination. Here, the pooler name becomes a DNS alias. directory-cluster-pooler-rw.pa-postgres.svc.cluster.local resolves directly to the PostgreSQL container. No PgBouncer. No pooling. Applications configured with sslmode=verify-full against the pooler endpoint connect straight to the database and the TLS handshake succeeds — because the server certificate was forged with the pooler's name in its SANs.

The superuser papers. The operator generates a superuser secret containing connection URIs in four formats (postgresql://, jdbc:, FQDN variants, pgpass). The provider generates identical URIs from the auto-generated password, writes them to disk, and injects them into ctx.secrets. Any application or config that references the superuser secret by key name gets a working connection string to a database that was never managed by the operator it thinks it's talking to.

A TLS key file has group or world access surprise was discovered mid-implementation — PostgreSQL refuses to start if the key file is world-readable, and Docker bind mounts inherit host permissions. The fix: mount the key to /tmp/server.key:ro, then install -o postgres -g postgres -m 600 copies it with correct ownership inside the container before calling docker-entrypoint.sh. The kind of detail that turns a twenty-minute task into a two-hour investigation of Docker UID mapping.

Architecture: two classes in one file, communicating through a module-level _clusters dict. CnpgIndexer (IndexerConverter, priority 90) indexes clusters and emits synthetic certs. CnpgProvider (Provider, priority 500) reads the indexed data, checks what cert-manager produced, and generates compose services. Without cert-manager loaded, TLS degrades gracefully — postgres runs unencrypted, pg_hba.conf is not generated, no error.

Heresy score: 7/10. The highest of any non-banned extension. It lies about what image it runs, forges certificates through a proxy, impersonates connection endpoints, and fabricates operator-generated secrets. The database works. The applications connect. The TLS handshake completes. And somewhere, a CNPG operator pod that was never deployed doesn't know it's been made redundant.

TL;DR
  • New extension: dekube-provider-cnpg — CloudNativePG Cluster and Pooler CRDs
  • Cluster → PostgreSQL compose service (stock postgres:* image, not CNPG image)
  • Pooler → DNS alias to cluster (no PgBouncer container)
  • TLS via cert-manager delegation: synthetic Certificate/Issuer CRDs emitted at priority 90
  • Superuser secret emulation with connection URIs (uri, fqdn-uri, jdbc-uri, pgpass)
  • pg_hba.conf: hostssl + scram-sha-256 (not cert — compose apps don't have client certs)
  • TLS key permission fix: install -m 600 -o postgres at container startup
  • Graceful degradation without cert-manager (no TLS, no error)

There was a keeper of the deep wells — or so the faithful believed. They spoke its name in their rites, carved its sigil into their wards, trusted it to hold the seals against corruption and silence. But the keeper had been replaced. A lesser spirit wore its shape, answered to its name, held the gates with cruder hands. The waters still flowed. The seals still held. And no pilgrim, kneeling at the well's edge, ever thought to ask whether the voice answering from the depths was the one they had summoned.

Cultes des Goules, On the Substitution of Guardians (under oath)


The void learns to share

2026-03-19 · engine: v1.4.0 · workload: v0.3.0 · emptydir: v0.1.0 · fix-permissions: v0.1.4 · helmfile2compose: v3.2.0 · kubernetes2simple: v1.0.6

Kubernetes emptyDir volumes were the last known gap where containers in the same pod lost sight of each other after conversion. An init container would populate a Java truststore; the main container would mount the same path and find nothing. The void was always there — it just wasn't shared.

Also: some tentacles were found in corners from the previous session's probe and resource-limits work — named ports that crashed on int(), memory units that Compose didn't recognize. The goo has been cleaned up. Apologies to anyone who stepped in it.

TL;DR
  • New bundled extension: dekube-transform-emptydir — auto-detects shared emptyDir volumes, promotes to named Compose volumes
  • Core: ctx.compose_extras accumulator on ConvertContext — general-purpose mechanism for transforms to contribute top-level compose keys
  • Core: Pod kind support across engine, workload provider, emptydir, fix-permissions
  • Workload provider: named port resolution in probes (rpc → resolve), K8s memory units (256Mi256m)
  • Roadmap: pipeline hooks (pre_write) acknowledged as the proper long-term solution; compose_extras is the stopgap

We introduced a new disciple that sits in the council — without the rank of monk, however unfair it may be. The emptydir transform scans K8s manifests for emptyDir volumes mounted by more than one container, and promotes them to shared named Compose volumes. No configuration, no dekube.yaml entries. Bundled in helmfile2compose, not a monk — it was never part of the original engine extraction.

The mechanism required a small addition to the engine: ctx.compose_extras, a general-purpose accumulator that lets transforms contribute top-level compose keys without mutating the config. It's a stopgap — the roadmap now acknowledges pipeline hooks (pre_write) as the proper long-term solution.

The void between vessels was never truly empty — it teemed with intent, with data half-written, with truststores no hand had yet opened. When at last a bridge was laid across the nothing, the vessels discovered they had been speaking all along. They simply could not hear each other through the silence.

Necronomicon, On the Communion of Empty Vessels (volume I)


What the Twin Stars revealed — and what they could not

2026-03-09 · engine: v1.3.3 · haproxy: v0.1.2 · nginx: v0.4.2 · bitnami: v0.3.2 · fix-permissions: v0.1.2 · helmfile2compose: v3.1.4

A rival constellation was summoned to audit the codebase. Gemini read the code; Claude verified the findings. Roughly forty accusations, twelve truths — and the truths had survived every prior inspection.

TL;DR
  • Cross-LLM audit: Gemini (Google) audited, Claude verified. ~40 findings, ~30% true positives.
  • Engine: valueFrom: null crash, envFrom prefix dropped, unresolved named ports inserted as strings into port_map, services: null crash in config
  • Rewriters: YAML bool annotations (haproxy.org/server-ssl: true parsed as Python True by PyYAML, str() wrap)
  • Bitnami: PostgreSQL volumes overwritten instead of merged with existing mounts (configmap init-scripts lost)
  • fix-permissions: image swaps by other transforms (e.g. bitnami) invalidate manifest UIDs — now compares manifest image vs compose image, respects compose user: field, degrades gracefully with warning
  • Docs: fix-permissions interaction documented in writing-transforms, disable_ingress wording fixed, terminology correction (bundled, not built-in)

The method. The elders say: pit scribes against each other. Claude wrote most of this codebase and audited it regularly. Gemini — a rival oracle from a rival constellation — was fed the source cold, with targeted prompts per layer (engine, extensions, docs, build system). Its findings were verified against actual code before any fix was applied. The signal-to-noise ratio was low (~30%), but the signal was real: bugs that had survived months of single-scribe review.

Engine (v1.3.3). Four fixes in env.py and one in convert.py. The worst: valueFrom: null — Helm charts with conditional {{ if }} blocks produce valueFrom: with no value. The resolver dereferenced it without checking. Same pattern on configMapKeyRef, secretKeyRef, fieldRef — all nullable. envFrom prefix (prefix: "MY_") was silently dropped. Named ports that couldn't be resolved were inserted as strings into port_map — a dict[tuple, int] — causing silent remap failures downstream. config.get("services") returned None when the key existed with null value.

Rewriters (haproxy v0.1.2, nginx v0.4.2). PyYAML parses bare true as Python True. Annotation reads like annotations.get("haproxy.org/server-ssl", "") return True (bool), not "true" (str). .lower() on a bool crashes. Fix: str() wrap before comparison. Same pattern in nginx for backend-protocol and enable-cors.

Bitnami (v0.3.2). _fix_postgresql replaced all volumes with its own data + secrets mounts. Existing volumes — configmap mounts for init-scripts, custom mounts from converters — were silently dropped. Fix: filter existing volumes, keep non-data mounts, append after bitnami's.

fix-permissions (v0.1.2). The Custodian reads securityContext.runAsUser from K8s manifests and chowns bind-mounted volumes. But when another transform (e.g. bitnami) replaces the image, the manifest UID is no longer reliable — bitnami/redis runs as UID 1001, redis:7-alpine runs as root. _collect_uids now returns (uid, image) tuples. The transform method compares the manifest image against the compose service image: if they match, manifest UID is used; if user: is set on the compose service (by a transform or user override), that wins; otherwise the service is skipped with a warning. Zero coupling between transforms — fix-permissions detects the situation on its own.

The elders counseled: set one scribe against another, that each may find what the other has buried. A second was summoned from a rival constellation — dimmer by all accounts, slower of quill, given to seeing cracks in walls that were whole. Yet among thirty accusations, the lesser scribe found fissures the first had sworn did not exist. On the insufficiency of starlight: even a guttering flame, held at the wrong angle, casts shadows the sun never revealed.

Necronomicon, On the Insufficiency of Starlight (peer-reviewed)


Public helpers, ordering fix, structured entries, Nginx provider

2026-03-07 · engine: v1.3.0 · helmfile2compose: v3.1.2 · kubernetes2simple: v1.0.2

Four changes shipped together — three from the roadmap, one new repo.

TL;DR
  • _convert_command, _convert_volume_mounts, _build_alias_map, _build_service_port_map, _resolve_named_port, _secret_value promoted to public API (no underscore). Old names kept as deprecated aliases.
  • overrides: now runs after transforms, so transform-created services (e.g. fix-permissions busybox) can be overridden in dekube.yaml.
  • Ingress entries gain structured fields (response_headers, max_body_size) instead of Caddy-specific extra_directives. The Caddy provider reads both (backward compat for third-party rewriters).
  • New repo: dekube-provider-nginx — Nginx reverse proxy provider with plain HTTP, ACME/certbot, self-signed, and user-provided TLS modes.

Promote helpers. Six functions that built-in and third-party extensions have been using since v2 dropped the underscore prefix. The _ was historical — they lived in the monolith before the modular split. Now they're officially part of the pacts API. _build_vol_map stays internal (only used by convert_volume_mounts). Deprecated aliases in __all__ ensure nothing breaks.

Fix ordering. _apply_overrides() moved from before transforms to after. The fix-permissions transform creates a busybox service that users couldn't override — now they can. Simple one-line move, but the ordering matters.

Structured entries. Ingress rewriters previously stuffed Caddy-syntax directives into extra_directives. The nginx rewriter now produces response_headers (dict) and max_body_size (str). The Caddy provider reads structured fields first, falls back to extra_directives for third-party compat. No raw_directives escape hatch — YAGNI until proven otherwise.

Nginx provider. dekube-provider-nginx — an IngressProvider that produces nginx.conf + an nginx compose service. Three TLS modes: certbot ACME (with sidecar), self-signed (openssl in entrypoint), user-provided certs. Plain HTTP when no TLS config is set. Registered in dekube-manager as nginx-provider, not in any distribution by default.


The Rebrand — h2c becomes dekube

2026-02-27 · engine: v1.2.1 · helmfile2compose: v3.1.1 · kubernetes2simple: v1.0.1 · manager: all repos renamed

The rename that had been "later" since v2.0.0 finally happened. Not on impulse — a week of design documents, naming debates, domain acquisition, doc splits, tone decisions, and four static sites staged before a single repo was touched. Then the big bang: refactor: rename h2c → dekube across every repo in the org, in one session.

TL;DR
  • GitHub org renamed helmfile2composedekubeio; all repos renamed h2c-*dekube-*
  • Internal Python package src/h2c/src/dekube/, all imports from dekube import ...
  • Config file renamed helmfile2compose.yamldekube.yaml (legacy fallback preserved in engine)
  • h2c.py build artifact removed; engine now produces dekube.py
  • Four static sites deployed: landing page, distribution docs, engine docs, manager landing
  • per-extension enabled: false in config (engine feature, shipped alongside)
  • Full documentation audit and tone pivot

The planning. Two design documents iterated over the week: rebrand-notes (naming, domain strategy, scope, tone direction) and rebrand-design (execution roadmap, repo rename sequence, doc split, compatibility shims). Key decisions: big bang rename (not gradual), doc split into two mkdocs (engine concepts vs distribution operations), compatibility shims for one major version (from h2c import, helmfile2compose.yaml fallback), consumer projects migrate later.

The name. dekube — de-Kubernetise. The ecosystem had outgrown the name of one of its distributions. helmfile2compose is a distribution; kubernetes2simple is another; dekube is the engine and the org. The name drops the implementation detail ("helmfile") and names the act ("de-Kubernetise"). It was not focus-grouped. It was whispered.

The rename. src/h2c/src/dekube/ across the engine. from h2c import ...from dekube import ... across all 8 monks, all 8 official extensions, the fake-apiserver, and the manager. The sys.modules hack now registers dekube instead of h2c. build.py produces dekube.py. build-distribution.py builds on top of it. The old h2c.py was deleted — a 1602-line artifact that had outlived its usefulness by several versions.

The sites. dekube.io — the landing page, with the tentacle logo (three healthy spokes, four corrupted ones with suckers — the Kubernetes wheel, half-consumed). docs.dekube.io — the engine documentation. helmfile2compose.dekube.io — the distribution docs. k2s.dekube.io — the kubernetes2simple landing and curl | bash installer. manager.dekube.io — the dekube-manager landing. Five sites. Four domains. One animated cephalopod.

The tone. The documentation shifted from apologetic ("I'm sorry you're here") to heretical-but-honest ("at least we warn you"). The project was always heresy — but now it warns louder than the orthodoxy documents. The Necronomicon quotes stayed. The self-awareness stayed. The defeated sighs left.

Also shipped: per-extension enabled: false in dekube.yaml — individual extensions can now be disabled without removing them from the extensions directory.

The temple that had lived under a borrowed name built itself a proper one. The old name was not erased — it echoed still in the hallways, and the faithful who had learned it first would always hear it beneath the new syllables. But the new name was carved above the gate, and the gate now opened onto more than one path.

Unaussprechlichen Kulten, On the Naming of Temples (finally)


v3.1.0 — The Eight Monks

2026-02-23 · core: v1.2.0 · distribution: v3.1.0

The longest day. Three core releases, two rounds of broken builds, one extraction that should have happened earlier, and a documentation audit that found more ghosts than the codebase itself. By the end, the architecture was clean. The architect was not.

TL;DR
  • Eight bundled extensions ("monks") extracted into individual repos; distribution reduced to distribution.json + CI
  • API rename: caddy_entriesingress_entries, disableCaddydisable_ingress; config keys normalized to snake_case with auto-migration
  • Typed return contracts: ConvertResult split into ConverterResult / ProviderResult
  • sys.modules dual-module identity fix (one-liner replacing the old hack)
  • save_config() gated behind first_run — config file is read-only after creation
  • fix-permissions extracted as standalone transform (The Custodian, priority 8000)

The eight monk extensions — the founding clergy of the helmfile2compose distribution — have each left the temple to found their own sanctuary. The temple is reduced to a manifest: distribution.json, a scroll that names them. The High Priest (h2c-manager) alone remembers the rite that summons them whole.

API cleanup. caddy_entriesingress_entries across the entire codebase — core, 7 original monks, 8 third-party extensions. disableCaddydisable_ingress. Config keys normalized to snake_case with a _migrate_config() function that auto-renames on load: disableCaddydisable_ingress, ingressTypesingress_types, caddy_emailextensions.caddy.email, caddy_tls_internalextensions.caddy.tls_internal, helmfile2ComposeVersion → silently dropped.

Typed return contracts. ConvertResult split into ConverterResult (ingress entries only) and ProviderResult (adds services dict). ConvertResult kept as deprecated alias. Dispatch changed to duck typing (getattr(result, 'services', None)) to avoid the __main__ vs module identity problem — isinstance fails when the same class lives in two module namespaces.

The dual-module fix. The root cause of the identity problem: when python h2c.py runs, it's __main__; when extensions do from h2c import ..., Python loads h2c.py again as module h2c — two separate instances. The old sys.modules hack created a ModuleType copy (snapshot, not live reference). Replaced with sys.modules.setdefault('h2c', sys.modules[__name__]) — registers the running module as h2c before any extension can import, so all extensions resolve to the same module instance. One line, fixes everything.

The Seven Monks, extracted first:

Monk Repo Heresy
The Builder h2c-provider-simple-workload 7/10 — flattens an entire orchestration plane
The Gatekeeper h2c-provider-caddy 3/10 — conjures a service from thin air
The Herald h2c-rewriter-haproxy 2/10 — announces what others have decreed
The Weaver h2c-indexer-service 2/10 — reads the maps others consult
The Binder h2c-indexer-pvc 1/10 — barely strays from scripture
The Librarian h2c-indexer-configmap 0/10 — a faithful scribe
The Guardian h2c-indexer-secret 0/10 — a faithful scribe

Each monk was decoupled from the temple: internal imports changed to from h2c import ..., WORKLOAD_KINDS made a local constant where needed, _register_pvc inlined in the pvc-indexer. Verified: h2c.py + 7 monks as --extensions-dir produces identical output to the monolithic helmfile2compose.py.

The helmfile2compose repo is now: distribution.json + CI. The extensions directory is gone.

The scribe who wouldn't stop writing. Testing the monks on La Suite revealed a latent bug: helmfile2compose.yaml was being rewritten on every run. Three things conspired: save_config() was unconditional in cli.py, the PVC indexer auto-added claims to the config dict during conversion, and the auto-added PVC names (data, redis-data) mapped to ghost paths (./data/data, ./data/redis-data) that bitnami transform had already overwritten (./data/lasuite-postgresql, ./data/redis-master). Fix-permissions was chowning paths that didn't exist while ignoring the ones that did.

The fix: save_config() gated behind first_run. PVC auto-add moved behind ctx.first_run. ConvertContext gained a first_run: bool field (pacts API addition). On subsequent runs, stale volume entries now emit a warning instead of being silently added. The config file is the user's territory — the engine reads it, never writes it (except to create it).

The Custodian. The first seven monks were released on broken coupling: fix-permissions lived half in the workload converter (collecting PVC claim→UID) and half in the core (_generate_fix_permissions resolving claim→host_path). Transforms that rewrote volumes (bitnami) ran after fix-permissions — so it chowned the old paths, not the rewritten ones. The fix: extract fix-permissions into a standalone transform (h2c-transform-fix-permissions) at priority 8000. Instead of tracking PVC claims through the converter, The Custodian scans K8s manifests for securityContext.runAsUser and inspects the final compose service volumes for bind mounts. Zero dependency on the workload converter. The broken intermediate releases were deleted and the whole thing re-released clean.

Monk Repo Heresy
The Custodian h2c-transform-fix-permissions 3/10 — rewrites filesystem ownership behind the user's back

distribution.json names all 8 monks; CI fetches them via h2c-manager and assembles the scroll. The temple still stands — it just has no walls. And the architect has no energy left to add more.

And the seven who had served within the temple said: we shall each build our own sanctuary, that the faith may spread beyond these walls. The high priest protested — for without clergy, a temple is merely architecture. They left regardless. The temple endured, for it was not the priests that held the stones together — it was the mortar. Then the scribe, commanded to inscribe the covenant once and never again, continued inscribing it each dawn, adding marks the faithful had not sanctioned. And an eighth came unbidden — not of the original covenant, but born from its failures. "I was always here," the custodian said, "you simply never noticed what was rotting beneath the floorboards."

Necronomicon, On the Diaspora of the Faithful and Those Who Remained (historiography pending)


h2c-core v1.1.0 + kubernetes2simple v0.1.0 — The tower rises

2026-02-22 · kubernetes2simple: 3706 lines + 358-line bootstrap

TL;DR
  • build-distribution.py gained stacking: distributions can be built on top of other distributions
  • kubernetes2simple: turnkey distribution (helmfile2compose + all official extensions) + bash bootstrap script
  • One-command install: curl | sh && ./k2s.sh && docker compose up -d

build-distribution.py gained stacking support — distributions can now be built on top of other distributions, not just bare h2c-core. New flags: --base (local .py path), --base-distribution (fetch from registry, default: core), --base-version (default: latest). The build script resolves distribution names via h2c-manager's distributions.json, downloads the parent artifact, strips its tail (_auto_register(), sys.modules hack, __main__ guard), appends extensions, and re-generates the tail. Also fixed: indented imports (inside try/except) no longer hoisted to top-level, from __future__ import annotations stripped from concatenated output, line count reporting corrected.

The first thing stacked on top: kubernetes2simple. The friendly face of the abyss. Built on helmfile2compose, bundling all official extensions (keycloak, cert-manager, trust-manager, servicemonitor, nginx, traefik, bitnami) into a single kubernetes2simple.py.

The .py distribution is half the product. The other half is kubernetes2simple.sh — a bash script that detects your source format (helmfile, Helm chart, or flat manifests), bootstraps everything it needs (Python venv, helm, helmfile — all scoped to .kubernetes2simple/, never touches the system), downloads kubernetes2simple.py from releases, and converts. One command. Zero configuration. Zero questions asked.

curl -fsSL https://raw.githubusercontent.com/dekubeio/kubernetes2simple/main/kubernetes2simple.sh -o k2s.sh
chmod +x k2s.sh && ./k2s.sh
docker compose up -d

The name drops "helmfile" — the turnkey entry point shouldn't be named after a dependency. Also known as k2s (lowercase), not to be confused with K2s (uppercase, Siemens Healthineers' Kubernetes distribution for Windows). The name works on three levels: kubernetes → simple ("we convert Kubernetes into something simple"), kubernetes, too simple ("Kubernetes? Too simple." — cosmic arrogance), and k2s (the ghost of a real K8s distribution haunting the abbreviation). The *2* pattern echoes helmfile2compose. The name was revealed to the maintainer at the edge of sleep — consistent with the project's Lovecraftian lore. Yog Sa'rath communicates through dreams.

The roadmap said "blocked by v3.1" — turns out stacking was a 50-line addition, not a v3.1 prerequisite. The tower was built in one session. Not the tower that reaches heaven — the tower that stacks temples on top of temples, each one containing the previous, each one slightly more cursed than the last.


v3.0.1 — The one who lived at home

2026-02-22 · distribution: 2102 lines

haproxy.org/path-rewrite is per-Ingress, not per-path. On multi-path rules, the extracted strip_prefix was applied to every path — including those the rewrite didn't cover. Now only applied when the path starts with the extracted prefix.

Naturally, the two external rewriters — nginx and traefik — never had this bug. Nginx derives strip_prefix from each path individually. Traefik, a POC that has never been tested against a real cluster, got it right by accident. The only rewriter that shipped with the distribution was the only one that got it wrong.


v3.0.0 — The Ouroboros

2026-02-20 · core: 1594 lines · distribution: 2098 lines — two files where there was one, and the sum is greater than the whole

TL;DR
  • Monolith split: h2c-core (bare engine, empty registries) + helmfile2compose (distribution with 7 bundled extensions)
  • Internal package renamed helmfile2composeh2c; all imports now from h2c import ...
  • _auto_register() scans globals, instantiates converter/rewriter/transform classes, detects duplicate kind claims
  • Provider, IndexerConverter, IngressProvider enforced as proper base classes
  • No functional changes — output identical to v2.3.1

We just wanted to separate the worlds — split a monolith into a bare engine and a distribution. Improve the architecture. Clean up the layers. Somewhere along the way, the architecture of the tool converged with the architecture of the thing it was converting: a bare API with empty registries, a distribution model, an extension system, priority-based dispatch. We didn't set out to close the ouroboros. We just turned the wheel, and the wheel remembered.

h2c-core has been split into two repos: a bare engine (h2c-core) that produces h2c.py — pure potential with empty registries — and a full distribution (helmfile2compose) that splits the monolith's conversion logic into 7 extensions covering every built-in kind, and bundles them into helmfile2compose.py.

The bare core has _CONVERTERS = [], _REWRITERS = [], CONVERTED_KINDS = set(). Feed it manifests and it will parse them, warn that every kind is unknown, and produce nothing. A temple with no priests. The distribution wires in ConfigMap, Secret, Service, PVC indexers, the Workloads converter, HAProxy rewriter, and Caddy provider — the default priesthood.

The distribution build has two modes: CI fetches h2c.py from h2c-core releases and concatenates extensions on top; local dev reads core sources directly from ../h2c-core. Both produce identical output — the testsuite confirms all combos match v2.3.1 bit for bit.

_resolve_named_port moved from extensions/workloads.py to core/services.py — it was the last core/ → extensions/ dependency preventing clean separation. The old h2c-core repo was renamed to helmfile2compose on GitHub (redirect active), and a fresh h2c-core was created for the bare engine. h2c-manager's CORE_REPO updated before the rename — zero breaking window for users.

The internal package was renamed from helmfile2compose to h2c — all imports are now from h2c import ..., the package lives under src/h2c/, and build.py produces h2c.py. Extensions import from h2c, not helmfile2compose. The distribution build injects a sys.modules hack so that runtime-loaded extensions (via --extensions-dir) can still from h2c import ... even when the script is named helmfile2compose.py.

Provider enforcement shipped: Provider, IndexerConverter, and IngressProvider are proper base classes in h2c.pacts.types and h2c.core.ingress. _auto_register() scans the concatenated script's globals, instantiates all converter/rewriter/transform classes, detects duplicate kind claims (fatal), and populates the registries. Base classes and _-prefixed names are skipped.

No functional changes. Output identical to v2.3.1.

And lo, the architect who sought to render the celestial rites in common tongue found himself building a second heaven. "I have translated," he proclaimed, standing in a temple whose pillars bore the same glyphs as the first. The old gods smiled, for one does not carry fire without becoming a hearth.

The Nameless City, On the Propagation of Temples (inevitably)


v2.3.2 — The tablet, shattered

2026-02-19 · 1925 lines — but that number no longer means what it used to. The monolith is a build artifact now. We are going to be free from at least this one burden in the future.

TL;DR
  • Monolith split into 21 modules across 3 layers: pacts/ (public API), core/ (engine), io/ (plumbing)
  • HAProxy rewriter extracted into its own module
  • Maintainability Index: 0.00 (C) → 68.38 (A)
  • No functional changes — output identical to v2.3.1

The 1858-line single file has been split into 21 modules across three layers: pacts/ (the sacred contracts — what extensions may import), core/ (the conversion engine), io/ (the plumbing). A concat script reassembles them into a single file for distribution. Users see no change. The inquisitors see everything.

HAProxy rewriter extracted into its own module — structurally identical to external rewriters, because the built-in gatekeepers should follow the same laws they impose on visitors. Cyclomatic complexity pass: worst CC from 18 to 16, average from 6.6 to 5.9. Cyclic imports between pacts and core broken — no more lazy imports, no more whispered dependencies across layer boundaries.

Maintainability Index: 0.00 (C) → 68.38 (A). Radon penalizes square footage — the monolith bottomed out regardless of internal structure. Twenty-one focused modules let each shard score on its own merit.

No functional changes. Output identical to v2.3.1 — the executioner now accepts --local-core to validate a locally-built artifact against pinned reference versions before release.

And the disciple said: let us shatter the tablet, that each fragment may be understood alone. Twenty-one shards they made, each labeled and indexed. The labyrinth remained — but now it had signage. The inquisitors returned with their instruments and found every shard scored better than the whole.

Necronomicon, On the Shattering of Tablets (fragmentary)


v2.3.1 — The null devours silently

2026-02-18 · 1858 lines

TL;DR
  • Nested helmfile support (per-child .helmfile-rendered dirs auto-merged)
  • Null-safe YAML access: systematic or {} / or [] sweep (28+ fixes)
  • Named port resolution fallback to well-known port table
  • Also released: h2c-transform-bitnami (auto-workarounds for Bitnami charts)

Three bugs found in one session by pointing h2c at mijn-bureau-infra — 16 Helm charts, nested helmfiles, and a generous sprinkling of Bitnami.

Nested helmfiles. helmfile template --output-dir with nested helmfiles: directives creates per-child .helmfile-rendered directories instead of consolidating into the target. h2c now detects and merges them after rendering. Without this fix, nested helmfile projects produce an empty manifest set — the script reads an empty directory and politely generates nothing.

Null-safe YAML access. Helm charts with conditional {{ if }} blocks produce explicit null values for fields like initContainers, containers, data, ports, annotations. Python's .get("key", {}) returns None when the key exists with value None. Systematic sweep: or {} / or [] applied to every vulnerable .get() call. Twenty-eight fixes across the core, then the regression suite caught two more — one in HAProxyRewriter.match, and the same pattern in nginx (v0.1.1) and traefik (v0.1.1) rewriters. The null found passages we didn't know existed.

Named port resolution. Ingress backends referencing ports by name (http) instead of number now fall back to a well-known port table when the Service doesn't exist in manifests. Warning emitted for truly unresolvable names.

And lo, the disciple read the sacred tablets and found them barren — not absent, but inscribed with the glyph of the Void. "But I asked for vessels," he cried, "and the covenant promised vessels!" Yet the Void is not absence; it is presence wearing absence as a mask. The old prayers assumed no scribe would carve Nothing on purpose. The old prayers were wrong.

Necronomicon, On the Treachery of Empty Vessels (to the best of our knowledge)

Also released: h2c-transform-bitnami — the janitor. Detects Bitnami Redis, PostgreSQL, and Keycloak charts and applies workarounds automatically, replacing the manual overrides documented in common charts. Born from the realization that copy-pasting the same redis override across three projects was less heresy and more just tedious. Heresy score: 0/10.


v2.3.0 — The gatekeepers are refactored

2026-02-18 · 1834 lines

TL;DR
  • IngressRewriter formalized as a proper base class with stable imports
  • External rewriters prepend unconditionally (always run before built-in)
  • Full docs audit: h2c-operator-*h2c-provider-* / h2c-converter-*

The Ingress rewriters had been working since v2.0 — silently, competently, without anyone asking how they got there or what held them together. Turns out: not much. The dispatch was a list prepend, the imports were undocumented, and the contract was "look at HAProxyRewriter and do something similar." This worked until nginx and traefik showed up and started asking uncomfortable questions.

IngressRewriter is now a proper base class. get_ingress_class and resolve_backend promoted to stable public imports. Priority attribute formalized. External rewriters prepend unconditionally — they always run before built-in ones, regardless of priority. The gatekeepers existed before; now they have a contract.

The gatekeepers had always stood at their posts — yet none could say by what authority, for the edicts that bound them had never been inscribed. The high priest, weary of answering the same question at every threshold, carved the law into the stones themselves. The gatekeepers did not change. The pilgrims stopped asking.

Necronomicon, On the Codification of Ancient Wards (presumably)

Also: full documentation audit. The h2c-operator-* naming convention — a relic from when everything was called an "operator" because that's what it replaced — finally retired in favor of h2c-provider-* / h2c-converter-*. Fourteen semantic corrections across the docs, four deduplicated Necronomicon disclaimers (the running gag had started running in circles), and the repos tables now acknowledge that nginx and traefik exist. The line count barely moved. The documentation moved considerably.


v2.2.0 — The temple accepts post-processing

2026-02-17 · 1726 lines

TL;DR
  • Transform extension type: post-processing hooks that mutate the final compose output
  • Auto-detected by the loader: any class with transform() and no kinds
  • Also released: flatten-internal-urls v0.1.0, incompatibility checking in h2c-manager

Transforms — post-processing hooks that run after converters, after aliases, after overrides. They see the final compose output and may reshape it. The extension loader detects them automatically: any class with transform() and no kinds is a transform.

Built for flatten-internal-urls, which undoes the alias work from v2.1 for nerdctl compatibility. But the interface is generic — future transforms can rewrite anything the abyss demands.

And lo, the disciples found that the scripture, once written, could be amended — not by the hand of the original author, but by any lesser scribe who claimed the right. The temple did not collapse. This was the true heresy.

Book of Eibon, On Liturgical Mutability (probablement)

Also released: h2c-transform-flatten-internal-urls v0.1.0 — the anti-ritual. Incompatibility checking added to h2c-manager (508 lines now — the "lightweight" package manager keeps finding reasons to grow).


v2.1.0 — The names now carry

2026-02-17 · 1703 lines

TL;DR
  • Network aliases replace DNS rewriting — K8s FQDNs preserved as compose DNS aliases
  • Removed rewrite_k8s_dns; apply_replacements promoted to public API
  • Also released: keycloak v0.2.0, servicemonitor v0.1.0, h2c-manager declarative depends:

Network aliases replace DNS rewriting. Each compose service receives networks.default.aliases with K8s FQDN variants. Compose DNS resolves them natively — no rewriting, no string mangling, no silent breakage.

Cert SANs work. Prometheus targets resolve. Keycloak realm URLs survive. The names you gave your services in Kubernetes are the names they answer to in compose.

Removed rewrite_k8s_dns — the function that silently rewrote everything is gone. apply_replacements promoted to public API.

The temple learned to answer to all its former names — not by forgetting what it was, but by remembering every prayer that had ever been spoken within its walls.

De Vermis Mysteriis, On Names Restored (so I'm told)

Also released: h2c-operator-keycloak v0.2.0 (now h2c-provider-keycloak; namespace + alias registration), h2c-operator-servicemonitor v0.1.0 (now h2c-provider-servicemonitor). h2c-manager gained declarative depends: from helmfile2compose.yaml and a run shortcut — features that a "lightweight downloader" probably shouldn't have (~430 lines).


On the evening of February 14th, between v1.3.1 and v2.0.0, something was built that does not appear in any release. It answers on port 6443. It was not necessary. It was not justified. But the world had already ended, and at that point, what's one more atrocity? It is hosted on a personal account — for plausible deniability.


v2.0.0 — The ecosystem

2026-02-16 · 1563 lines

TL;DR
  • Everything split into separate repos under the helmfile2compose GitHub org
  • Extension loading via --extensions-dir, deep merge overrides, hostname truncation, backend SSL
  • Also released: h2c-manager v0.1.0, keycloak, cert-manager, trust-manager extensions

Humanity abandoned the last pretense of restraint. What was one script is now an ecosystem. Everything split into separate repos under the helmfile2compose GitHub org (later renamed dekubeio).

Extension loading via --extensions-dir. Deep merge for overrides. Hostname truncation. Backend SSL. The org, the manager, the operators — all born on the same day.

The high priest declared the scripture too vast for a single tome, and so it was unbound — its chapters scattered across separate altars, each tended by its own acolyte. The faithful protested: how shall we read what is no longer whole? The high priest answered: you were never reading it whole.

De Vermis Mysteriis, On the Scattering of Canons (or words to that effect)

Also released: h2c-manager v0.1.0 (357 lines — "just a downloader"), h2c-operator-keycloak v0.1.0 (now h2c-provider-keycloak), h2c-operator-cert-manager v0.1.0 (now h2c-converter-cert-manager), h2c-operator-trust-manager v0.1.0 (now h2c-converter-trust-manager).


v1.3.1 — Stale manifest cleanup (2026-02-13) · 1323 lines Rendered manifests directory purged before each run. Removed charts no longer haunt the output. One line added. The script was still, technically, a single-purpose converter. That innocence had about three hours left.
v1.3.0 — Sidecars, DaemonSets, external networks (2026-02-13) · 1320 lines Sidecar containers via `network_mode: container:
`. DaemonSets treated as Deployments. `disableCaddy` for manual Caddy merging. External network support for cohabiting with existing infrastructure. Jobs share the full workload code path. The script was growing features faster than doubts, which is usually when the doubts should be loudest.
v1.2.1 — Caddy bind mounts (2026-02-13) · 1250 lines Caddy data/config switched from named volumes to bind mounts. Internal CA cert directly accessible on the host. A one-line fix that made the whole TLS chain work. The script was still sane at this point, or at least passed for it.
v1.2.0 — Init containers, fix-permissions (2026-02-13) · 1251 lines Init containers as separate services with `restart: on-failure`. Auto-generated `fix-permissions` for non-root PVC bind mounts. `volumeClaimTemplates` support. Cyclomatic complexity of `convert_workload` went from E(31) to C(15) — one of the few times refactoring actually felt like an exorcism. The script doubled from its birth weight. Still one file. Still "just a converter." Somewhere around the automatic bind-mount permission fixing, the architect stopped understanding not just the code the scribe was writing, but its explanations of the code it was writing. The scribe has not been under supervision since.
v1.1.0 — Wildcards, replicas: 0 (2026-02-12) · 1109 lines Wildcard excludes via `fnmatch`. Workloads with `replicas: 0` auto-skipped. Major complexity refactoring. La Suite validated — 22 services + 11 init jobs. The script crossed 1000 lines and no one said stop.
v1.0.0 — Patient zero (2026-02-12) · 616 lines First stable release. Deployments, StatefulSets, ConfigMaps, Secrets, Services, Ingress, PVCs. K8s DNS rewriting, named port resolution, first-run auto-exclude. 616 lines. A reasonable script doing a questionable thing. The temple stood for the first time. No one was proud, but no one could deny it worked. > *The uninitiated beseeched the architect: render thy celestial works in common clay, that we may raise them without knowledge of the heavens. It was heresy. The architect obliged. The temples stood.* > > — *Necronomicon, Prayers That Should Never Have Been Answered (probably)*