How rendering works

Rendering is the step between “I want a Deployment called api running this image” and a file on disk. It is the same code whether the request comes from kikx add, from a preset, or from the dashboard’s live preview: render_component in backend/core/src/ops/add.rs. This page walks through what it does and why each decision was made. For the exact field names of each component, see the components reference.

Resolving the reference

The first question is which registry item you mean. kikx tries three things in order. If the reference names a built-in component, that item is used; only the last segment is compared, so k8s/deployment and deployment are the same thing. Otherwise, if the reference starts with http:// or https://, kikx fetches it and parses the body as a registry item. Otherwise, if it names an existing local file, kikx reads that. Anything else is an error that points you at kikx list.

Built-ins go first so that the common case never touches the network or the filesystem, and so a typo in a built-in name fails quickly with a helpful message instead of an attempt to open a file. URLs and files share one loader, which is also what presets use, so “a reference is a built-in name, a URL or a path” means the same thing everywhere in kikx. The registry page covers why custom items matter.

Building the context

Rendering produces a template context: a map of names to values that the templates can use. It always contains name, which you must supply, and namespace, which is the value you passed if you passed one and otherwise the project’s default namespace. For kikx add that default comes from kikx.toml; for setup and apply it comes from the preset’s project block; over HTTP it is the request’s defaultNamespace.

Then kikx walks the item’s declared fields. For each one it takes your value if you gave one, and falls back to the registry default if you didn’t. A required field with neither is an error, named after the flag you’d use to supply it. Explicit values always beat defaults, and the defaults are only ever the registry’s, never a client’s.

Values you supply that the item doesn’t declare are still added to the context. Declared fields exist to give clients defaults, examples and validation, not to restrict what a template can see, so a custom item can use extra values without listing every one.

Labels are handled alongside fields. You can set any labels you like, and if you don’t set app it defaults to the component name. That one default is why a Deployment and a Service with the same name select each other without further configuration.

Named flags and --set

The CLI offers two ways to give a value. A handful of common fields have their own flags (--image, --replicas, --port, --target-port, --namespace, --host, --path, --service), and every field can be set with --set key=value. The HTTP API mirrors this: the same common fields are top-level properties of a render request, and everything else goes in a fields map.

Both routes feed a single list. Named values go in first and --set values are appended after them, and when the list is turned into the context the later entry for a key replaces the earlier one. So if you pass both --image nginx and --set image=caddy, the rendered image is caddy; the same holds for a top-level image versus fields.image over HTTP. The named flags exist for convenience and type checking (a port has to be a number), and --set is the general mechanism that always has the last word.

JSON-valued fields

Every value arrives as a string, from a flag, a preset or an HTTP body. That keeps the interface uniform, but some components need structure: an inventory is a list of groups with members and vars, a playbook is a list of plays with roles and conditions. Rather than invent a second input format, kikx looks at each value as it goes into the context. If it starts with [ or { and parses as JSON, the template receives the parsed list or map. Otherwise it receives the string.

This keeps presets as simple string maps, lets the dashboard send structured data as a JSON string, and lets the CLI accept it with --set hosts='[...]'. The rule applies to registry defaults too, so a custom item can declare a JSON default. The trade-off is that a plain string value that happens to be valid JSON beginning with a bracket or brace becomes structured; in practice field values like images, hostnames and ports never look like that.

Templates

Templates are minijinja, a Rust implementation of Jinja2. Jinja was the obvious choice for a tool whose main audience writes Ansible, which already uses it, and minijinja makes it available without a Python runtime. Trailing newlines are kept so rendered files end the way their templates do.

Both halves of a registry file are templates. The content is rendered with the context, and so is the path, which is how {{ name }}-deployment.yaml, an optional playbook folder, and the choice between group_vars/<group>.yml and group_vars/<group>/main.yml all come from the registry rather than from client code. If two files of one item render to the same path, rendering fails: that is a broken item, not a situation to resolve silently.

Null versus missing in the inventory

The inventory component has one subtle rule worth understanding. It takes a default_user and a default_port (defaulting to root and 22) that are written onto hosts that don’t set their own. The question is what “don’t set” means.

If a host entry has no ansible_user key at all, the default is written, so a quick inventory of a few servers gets ansible_user=root ansible_port=22 on every line with no extra effort. If the host sets ansible_user to a value, that value is written. And if the host sets ansible_user to null (or an empty string), nothing is written for it at all, not even the default. The same applies to ansible_port.

The reason for the third case is Ansible’s variable precedence. A host variable written inline in the inventory beats a group variable. If kikx always wrote a default user onto every host, a [all:vars] block or a group_vars/all.yml saying ansible_user=ops would be silently overridden by ansible_user=root on each host line, which is exactly the kind of bug that is hard to spot. Explicit null is how a caller says “I have nothing to say about this host’s user; let the group decide”. The dashboard uses this: a host whose user or port field is left empty is sent with null, and when a host belongs to several groups only its first appearance carries connection details, the rest being bare names, so the same variables aren’t repeated or re-defaulted.

Path safety

Rendered paths come from templates, and templates can come from a URL you don’t control. Before kikx writes anything to disk, it checks every rendered path. Absolute paths are refused. Paths are walked component by component, and any .. that would climb above the output directory is refused. Two files rendering to the same path across a whole preset are refused too. Only then does kikx check for existing files and write.

These checks run when kikx writes, which is the CLI’s add, setup and apply. The HTTP API only renders and returns content, so it doesn’t apply them; the dashboard puts the rendered paths into the zip you download.

One writer per file

A file can have only one content, so kikx insists that a file has only one owner. On the command line this shows up as the refusal to overwrite without --force, and as the rejection of a preset where two components render to the same path.

The dashboard enforces it more precisely because it knows which component produced which file. Each component is identified by its reference and name. When you save a component whose files collide with files another component already owns, the conflict dialog shows the owner and a diff, and confirming replaces the previous owner: that component is removed from the project, not just the colliding file. Saving a component with the same reference and name as an existing one replaces it in place.

Removing the whole previous owner can feel heavy-handed, but the alternatives are worse. Keeping the old component while dropping one of its files would leave a component whose recipe no longer matches its output, and it would come back the next time the project is re-rendered from a preset. Keeping both would make the download depend on ordering, and the Checks view flags exactly that case as an error because only the last writer would survive. Replacing the owner keeps the project’s recipe and its files telling the same story. See Resolve file conflicts and checks.