Writing extensions¶
So you want to teach dekube a new heresy. Take a moment to reconsider. Then, having failed to reconsider, make sure you're familiar with Concepts (design philosophy, emulation boundary) and Architecture (converter pipeline, dispatch loop). A look at Code quality is also recommended — the bar is higher than the project's origins would suggest.
The acolyte approached the altar and asked: "May I add my own prayer to the liturgy?" The high priest did not refuse. The high priest never refuses. That is the problem.
— Book of Eibon, On Open Extension Points (broadly speaking)
Something broken?
If the engine contract doesn't behave as documented, or if you hit a bug while developing an extension, open an issue on dekube-engine. If the problem is in a bundled extension (one of the Eight Monks), file it on the extension's own repo — each one is linked in the catalogue. Not sure where the bug lives? Use helmfile2compose — I'll triage.
Which extension type do I need?¶
- My K8s manifests contain a CRD that dekube doesn't know about → write a Converter (resource-only) or a Provider (if it should produce compose services)
- I need to populate
ctxlookups from K8s manifests without producing compose services → write an Indexer (subclass ofIndexerConverter) - The compose output is correct but I need to post-process it (rewrite env vars, inject services, fix permissions) → write a Transform
- My cluster uses an ingress controller whose annotations aren't supported → write an Ingress rewriter
- I want to replace Caddy with a different reverse proxy → write an Ingress provider
Extension types¶
| Type | Interface | Page | Naming convention |
|---|---|---|---|
| Converter | kinds + convert() |
Writing converters | dekube-converter-* |
| Provider | subclass of Converter, kinds + convert() |
Writing providers | dekube-provider-* |
| Ingress provider | subclass of IngressProvider |
Writing ingress providers | distribution-level |
| Transform | transform(), no kinds |
Writing transforms | dekube-transform-* |
| Indexer | subclass of IndexerConverter, kinds + convert() |
Writing converters | dekube-indexer-* |
| Ingress rewriter | name + match() + rewrite() |
Writing rewriters | dekube-rewriter-* |
Converters, providers, and indexers share the same code interface — but the distinction is now enforced. Provider is a base class in dekube.pacts.types; subclassing it signals that the extension produces compose services. IndexerConverter is a base class for extensions that populate ConvertContext lookups (e.g. ctx.configmaps, ctx.secrets) without producing output. See the Extension catalogue for the full list.
Available imports¶
from dekube import ConvertContext # passed to convert() / rewrite()
from dekube import ConverterResult # return type for converters/indexers (no services)
from dekube import ProviderResult # return type for providers (with services)
from dekube import ConvertResult # deprecated alias for ProviderResult
from dekube import Converter # base class for converters
from dekube import IndexerConverter # base class for indexers (populate ctx, no output)
from dekube import Provider # base class for providers (produce compose services)
from dekube import IngressRewriter # base class for ingress rewriters
from dekube import get_ingress_class # resolve ingressClassName + ingress_types
from dekube import resolve_backend # v1/v1beta1 backend → upstream dict
from dekube import apply_replacements # apply user-defined string replacements
from dekube import resolve_env # resolve env/envFrom into flat list
from dekube import secret_value # decode a Secret key (base64 or plain)
# Extension helper functions (stable API)
from dekube import log # print " [name] msg" to stderr
from dekube import generate_password # random alphanumeric password
from dekube import is_excluded # fnmatch a name against exclude patterns
from dekube import iter_workloads # yield (name, pod_spec) for every workload
from dekube import iter_named_containers # yield (compose_name, container): main/init/sidecar
from dekube import apply_alias_map # rewrite K8s Service names → compose names in hostnames
from dekube import rewrite_k8s_dns # <svc>.<ns>.svc[.cluster.local] → <svc> (only when .svc ends the hostname)
from dekube import write_configmap_files # emit a ConfigMap's data to configmaps/<name>/
from dekube import write_secret_files # emit a Secret's data to secrets/<name>/
# K8s-to-compose conversion primitives (stable API)
from dekube import convert_command # K8s command/args → compose entrypoint/command
from dekube import convert_volume_mounts # volumeMounts → compose volume strings
from dekube import build_alias_map # K8s Service names → compose service names
from dekube import build_service_port_map # (service, port) → container port
from dekube import resolve_named_port # named port → numeric containerPort
These are all stable across minor versions. Everything above the # K8s-to-compose conversion primitives comment — base classes, result types, and helper functions — are pacts (they live in dekube.pacts), the sacred contracts. Both import paths work for them:
from dekube import ConvertContext # via re-export
from dekube.pacts import ConvertContext # explicit
Both work in the package and in a distribution's single file, where dekube.pacts and dekube.pacts.{types,helpers,ingress} are aliased to the flat module. Use the from dekube.pacts[.x] import … form: import dekube.pacts as p fails in a distribution, and dekube.core.* paths don't exist there at all.
The conversion primitives (convert_command, convert_volume_mounts, build_alias_map, build_service_port_map, resolve_named_port) and IngressProvider live in dekube.core — import them from dekube only.
ConverterResult— return type for converters and indexers. One field:ingress_entries(list). Use when your extension doesn't produce compose services.ProviderResult— return type for providers. Two fields:services(dict) andingress_entries(list, inherited fromConverterResult).ConvertResult— deprecated alias forProviderResult. Still works, but prefer the typed variants.Converter— base class for all converters (default priority 1000). Optional — duck typing works, but subclassing provides defaults.IndexerConverter— base class for indexers that populateConvertContextlookups (e.g.ctx.configmaps,ctx.secrets) without producing output. Default priority 50.Provider— base class for converters that produce compose services (default priority 500). CRD extensions that returnProviderResultwith non-emptyservicesshould subclass this. See Writing providers.IngressRewriter— base class for ingress rewriters. Subclass it or implement the same duck-typed contract.get_ingress_class(manifest, ingress_types)— resolvesingressClassNamefrom spec or annotation, then through theingress_typesconfig mapping.resolve_backend(path_entry, manifest, ctx)— resolves a v1/v1beta1 Ingress backend to{svc_name, compose_name, container_port, upstream, ns}.apply_replacements(text, replacements)— applies user-definedreplacements(fromctx.replacements) to a string.resolve_env(container, configmaps, secrets, workload_name, warnings, replacements=None, service_port_map=None)— resolves a container'senvandenvFrominto a flatlist[dict]of{name, value}pairs, with kubelet's precedence: each name appears once,envwins overenvFrom, the lastenvFromsource wins. Literalvalues get kubelet's$(VAR)expansion ($$(VAR)stays literal).envFrom.secretRefincludesstringData; a binary (non-UTF-8) Secret value is skipped with a warning. Passingreplacements/service_port_mapapplies them here, and the engine's env post-pass then skips that service so neither is applied twice.secret_value(secret, key)— decodes a single key from a K8s Secret dict. Handles bothstringData(plain text) anddata(base64-decoded). Returnsstr | None. Useful for converters that need to read Secret values injected by other converters (e.g. reading a database password from a cert-manager-generated secret).log(name, msg)— prints[name] msgto stderr. Uselog("my-extension", "...")instead of defining your own_log— a free function can't collide with another extension's (see Keep helpers inside the class).generate_password(length=24)— returns a random alphanumeric password. Providers emulating an operator that auto-generates credentials (cnpg, keycloak) use this. Pass an explicitlengthif the operator's default differs.is_excluded(name, patterns)—Trueifnamematches anyfnmatchpattern inpatterns(null-safe:Nonepatterns →False). The exclusion-glob every workload-producing extension needs.iter_workloads(manifests)— yields(workload_name, pod_spec)for every workload manifest (Deployment, StatefulSet, DaemonSet, Job, Pod, …). Null-safe against Helm'snull-rendered lists.manifestsisctx.manifests.iter_named_containers(name, pod_spec)— yields(compose_service_name, container)for a pod's main, init, and sidecar containers, named the way the workload converter names them (name,name-init-<c>,name-sidecar-<c>). Native sidecars (initContainers withrestartPolicy: Always) keep thename-init-<c>naming too — only their compose treatment (network namespace,depends_ondirection) differs. Pair withiter_workloadsto walk every container in the output.apply_alias_map(text, alias_map)— rewrites K8s Service names to compose service names in hostname positions (preceded by//or@, followed by/ : whitespace, quotes, or end). Only hostnames are touched, not path segments (http://gw/<svc>/v1) or substrings like bucket names. Passctx.alias_map.rewrite_k8s_dns(text)— collapses<svc>.<ns>.svc[.cluster.local][:port]down to<svc>, but only when.svc(or.svc.cluster.local) actually ends the hostname —api.data.svc-proxy.example.comordb.app.svc.example.comare left alone.:port,/path, quotes, end-of-string, and a trailing.are still recognized as valid endings. Used by the flatten-internal-urls transform.write_configmap_files(name, ctx, items=None)— emits ConfigMapname's data (andbinaryData) as files underoutput_dir/configmaps/<name>/—configmaps/<name>_<hash>/whenitemsis given, so mounts filtering different keys don't share a tree — records the directory inctx.generated_cms, and returns the relative dir (./configmaps/<name>or./configmaps/<name>_<hash>) — orNone(appending toctx.warnings) if the ConfigMap isn't inctx.configmaps. Replaces hand-rollingos.makedirs+open(see Injecting synthetic resources).write_secret_files(name, ctx, items=None)— the Secret counterpart: emitsctx.secrets[name]'s data underoutput_dir/secrets/<name>/(same_<hash>rule foritems), as decoded bytes — binary values are written as-is. Records the directory inctx.generated_secrets, returns the relative dir orNone.convert_command(container, env_dict)— converts K8scommand/argsto composeentrypoint/command: kubelet's$(VAR)resolution first ($$(VAR)→ literal$(VAR), unknown refs kept), then every remaining$doubled so compose passes it to the container untouched.convert_volume_mounts(volume_mounts, pod_volumes, pvc_names, config, workload_name, warnings, ...)— convertsvolumeMountsto compose volume strings, handling PVC (withsubPath), ConfigMap, Secret, and emptyDir mounts. Other volume types are dropped with a warning.build_alias_map(manifests, services_by_selector)— builds a map of K8s Service names to compose service names (ClusterIP + ExternalName resolution).build_service_port_map(manifests, services_by_selector)— builds a map of(service_name, service_port)tocontainer_portfor port remapping.resolve_named_port(name, container_ports)— resolves a named port (e.g.'http') to its numericcontainerPort.
Deprecated _-prefixed aliases
The old _secret_value, _convert_command, _convert_volume_mounts, _build_alias_map, _build_service_port_map, _resolve_named_port names still work (exported in __all__) for backward compatibility. Prefer the unprefixed names in new code. The helpers promoted later (log, generate_password, is_excluded, iter_workloads, iter_named_containers, apply_alias_map, rewrite_k8s_dns, write_configmap_files, write_secret_files) have no _-prefixed alias — they were never public under another name, so import them exactly as spelled.
Internal functions (_apply_port_remap, _resolve_env_entry, _build_vol_map, etc.) are not part of the stable API and may change between versions. Pin your dekube-engine version if you depend on them. Transforms in particular should avoid importing from the core — see Writing transforms.
Keep helpers inside the class¶
Distributions concatenate the engine and all extension .py files into a single script. Top-level functions, classes and assignments share one flat namespace — with the other extensions and with the engine: an extension's def log() or def main() would silently replace the engine's for everyone. The build refuses any top-level name that two sources define differently (identical definitions are allowed); --my-extensions-are-fine-i-swear downgrades that to a warning. Names bound by imports or inside top-level if/try blocks aren't checked.
First, reach for the engine's helpers
Before writing a helper at all, check Available imports — the common ones are already there. log("my-extension", msg) replaces a hand-rolled _log; generate_password, is_excluded, iter_workloads, iter_named_containers cover the usual cases. What you don't define can't clash.
The fix, for helpers the engine doesn't provide: put them inside your class.
# Good — no collisions possible
class MyTransform:
name = "my-transform"
priority = 100
def _log(self, msg):
print(f" [{self.name}] {msg}", file=sys.stderr)
@staticmethod
def _parse_thing(data):
...
def transform(self, compose_services, ingress_entries, ctx):
self._log("doing things")
result = self._parse_thing(data)
- Methods that need
self(logging withself.name) → regular methods - Pure helpers →
@staticmethod - Call via
self._func()from instance methods,ClassName._func()from static methods - Top-level constants collide too: two extensions defining
_WORKLOAD_KINDSwith different values fail the build. Make them class attributes
This applies to all extension types: converters, providers, transforms, rewriters.
Input validation: not your problem¶
The engine assumes its input manifests are valid Kubernetes YAML — it does zero validation and your extension shouldn't either. If a manifest is missing a field, has the wrong type, or references something that doesn't exist, that's a broken helmfile, not your bug. You don't need to guard against malformed input.
If you want to handle edge cases gracefully in your extension, that's your call — but the engine won't help you. No schema validation, no error wrapping, no safety net. A missing key is a KeyError and that's fine.
Quickstart: writing a converter from scratch¶
From an empty file to a working extension. The ritual is short — the consequences are not.
Scenario: You want to handle a RedisCluster CRD that produces a Redis compose service.
1. Create the file¶
2. Write the extension¶
# redis_cluster.py
from dekube import Provider, ProviderResult
class RedisClusterProvider(Provider):
kinds = ["RedisCluster"]
name = "redis-cluster"
def convert(self, kind, manifests, ctx):
services = {}
for m in manifests:
name = m.get("metadata", {}).get("name", "redis")
spec = m.get("spec") or {}
ns = m.get("metadata", {}).get("namespace", "default")
services[name] = {
"image": f"redis:{spec.get('version', '7')}-alpine",
"restart": "always",
"command": ["redis-server", "--requirepass", spec.get("password", "changeme")],
}
# Register the Service so network aliases are generated
ctx.services_by_selector[name] = {
"name": name, "namespace": ns,
"selector": {}, "type": "ClusterIP",
"ports": [{"port": 6379, "targetPort": 6379}],
}
return ProviderResult(services=services)
3. Test locally¶
Create a test manifest:
# /tmp/test-manifests/redis.yaml
apiVersion: redis.example.com/v1
kind: RedisCluster
metadata:
name: my-redis
namespace: cache
spec:
version: "7"
password: s3cret
Run with the distribution:
python3 helmfile2compose.py \
--from-dir /tmp/test-manifests \
--extensions-dir ./dekube-provider-redis-cluster \
--output-dir /tmp/output
Check the output:
4. Publish¶
Create a GitHub repo, tag a release, and submit a PR to dekube-manager's extensions.json. See Publishing below.
Testing locally¶
All extension types are loaded from the same --extensions-dir. The loader detects each type automatically — converters, transforms, and rewriters can coexist in the same directory.
Testing with the bare core (dekube.py) — the core has no built-in converters, so you'll only see your extension's output:
Testing with the distribution (helmfile2compose.py) — includes all built-in converters, so you see full output:
python3 helmfile2compose.py --from-dir /tmp/rendered \
--extensions-dir ./my-extensions --output-dir ./output
Check the output for load confirmation:
Loaded extensions: MyConverter (MyCustomResource)
Loaded transforms: MyTransform
Loaded rewriters: NginxRewriter (nginx)
A file that fails to import (syntax error, missing dependency such as cryptography) aborts the run with exit code 1 and Error: failed to load extension <path>: <ExcType>: <msg> — nothing is written. A silently skipped extension would produce a wrong compose file. Extension modules are registered in sys.modules before they execute, so @dataclass with from __future__ import annotations works.
Repo structure¶
For distribution via dekube-manager, each extension is a GitHub repo with:
dekube-{type}-{name}/
├── {name}.py # extension class (mandatory)
├── requirements.txt # Python deps, if any (optional)
└── README.md # description, kinds/purpose, usage (mandatory)
The .py file must be in the repo root. requirements.txt follows pip format — dekube-manager checks if deps are installed and warns if not.
The README should cover: what the extension does, handled kinds (for converters/providers), dependencies, priority, usage example.
Publishing¶
- Create a GitHub repo under the
dekubeioorg (or your own account). - Create a GitHub Release with a tag (e.g.
v0.1.0). The release doesn't need assets — the tag is what matters. - Open a PR to
dekubeio/dekube-manageradding your extension toextensions.json:
{
"schema_version": 1,
"extensions": {
"my-extension": {
"repo": "dekubeio/dekube-{type}-{name}",
"description": "What it does",
"file": "{name}.py",
"depends": [],
"incompatible": [],
"min_engine": "v1.5.0"
}
}
}
depends lists extensions that must be installed alongside. incompatible lists extensions that conflict (bidirectional — declaring on one side is enough). min_engine is the oldest dekube-engine your file works with (set it when you import a helper added later); dekube-manager refuses the install when it knows the engine is older — today, only for --distribution engine, since the other distributions don't expose their engine version. All three are optional.
Once merged, users can install with: