Skip to content

Extension definition reference (extension.json)

Since 7.0

This page is the complete reference for the extension definition file (extension.json) used by all extension kinds. For an introduction to extensions see the Extensions overview.

There are two layers to keep apart:

  1. The extension (wrapper) — the entity you create via the CLI. It holds the name, the kind (application, workflow or action), a description, the wrapper label (lowercase, hyphens allowed) and the visibility/status (draftactivearchived). These are set at registration time and are not part of extension.json.
  2. The definition — the contents of extension.json, described on this page. It is attached to the extension via --definition-source when creating or updating it.
Terminal window
metalcloud-cli extension create "My Extension" application "My description" --definition-source extension.json --format json

The extension kind decides which sections of the definition are used:

KindUses
applicationinputs, outputs, infrastructure, assets, onCreate, onEdit, onDelete, configVars
workflowassets, onAssetChange
actionassets, actions (a flat task list, no stages)
{
"kind": "ExtensionDefinition",
"schemaVersion": "1.1",
"name": "Human readable name",
"label": "my_extension",
"extensionType": "application",
"vendor": "MetalSoft",
"extensionVersion": "1.0.0",
"description": "What the extension does",
"icon": "https://example.com/icon.svg",
"dependencies": {
"controllerVersion": "v7.4.0",
"osTemplates": ["ubuntu-24-04"]
},
"inputs": [],
"outputs": [],
"infrastructure": {},
"assets": [],
"onCreate": [],
"onEdit": [],
"onDelete": [],
"onAssetChange": [],
"actions": [],
"configVars": []
}
FieldConstraintsNotes
kindmust be exactly ExtensionDefinitionThe same literal for all extension kinds. The application/workflow/action kind is a CLI argument, not a field in this file.
schemaVersionmust be exactly "1.1"Any other value is rejected.
name≤255 charsHuman-readable name.
label≤63 chars, ^[a-zA-Z_][a-zA-Z0-9_]*$No hyphens (unlike the wrapper label set via the CLI, which allows them). Use underscores.
extensionType≤32 chars, free stringInformational classification.
vendor≤64 chars
extensionVersion≤12 charsInformational version of your extension.
descriptionoptional, ≤255 chars
iconrequiredA URL to an icon, or none.
dependencies.controllerVersionrequired, ≤32 charsInformational only — it is not compared against the actual controller version.
dependencies.osTemplatesoptional, ≤12 entriesInformational only. The real check happens at instance-create time: instanceArrays[].osTemplate must resolve to an existing OS template by label.
inputs≤100 entriesShares one label namespace with outputs for #/input/ references.
outputs≤12 entriesSee Outputs.
assets≤12 entriesSee Assets.
onCreate / onEdit / onDelete≤12 entries eachapplication kind only.
onAssetChange≤12 entriesworkflow kind only. See Workflow extensions.
actions≤12 entriesaction kind only — a flat array of tasks.
configVars≤12 entriesSee configVars.

Inputs render the form the user fills in when instantiating an application extension. Each entry:

{
"label": "compute_nodes",
"name": "compute_nodes",
"inputType": "ExtensionInputInteger",
"options": {
"minValue": 0
},
"defaultValue": 0,
"setOnly": false,
"hidden": false,
"helpText": "Number of compute nodes to deploy"
}

Common fields:

  • label (≤63 chars) — becomes the Ansible variable name (under extensionInstanceVariables) and the #/input/<label> reference key. Must be unique across inputs and outputs.
  • name (≤64 chars) — the display name shown in the UI. Recommended practice: keep label and name identical to avoid confusion.
  • inputType — one of the types below.
  • options — per-type options object (may be {}).
  • defaultValue — optional; must match the input type. Forbidden on ExtensionInputServerType and ExtensionInputOsTemplate.
  • isPassword: trueExtensionInputString only. The value is stored encrypted (vault) and decrypted into the runtime variables.
  • setOnly: true — the value can only be set at creation, not changed on edit.
  • hidden: true — the input is hidden in the UI form.
  • helpText — operator-facing explanation shown in the UI.
inputTypeOptionsNotes
ExtensionInputStringvalidationRegEx (≤64 chars)Use validationRegEx to enforce downstream value policies (password charset, naming patterns) at input time rather than validating in the playbook. defaultValue must itself satisfy the regex.
ExtensionInputIntegerminValue, maxValue, deniedValues (≤12)
ExtensionInputBoolean
ExtensionInputEnumvalues (array of strings)Renders a selection from the given values.
ExtensionInputServerTypeminCpu, minRamGb, vendorLets the user pick a server type. Constraints are enforced at instance-create time. No defaultValue allowed.
ExtensionInputOsTemplateosFamilyLets the user pick an OS template. No defaultValue allowed.

ExtensionInputNetworkProfile also exists in the schema but is not currently supported for use in extensions — avoid it.

Reference an input value elsewhere in the definition with the syntax #/input/<label>. This is valid in:

  • infrastructure.instanceArrays[].instanceCount, serverType, osTemplate and label
  • infrastructure.instanceArrays[].customVariables[].value
  • infrastructure.instanceArrays[].connectedSharedDrives entries
{
"label": "compute-nodes",
"instanceCount": "#/input/compute_nodes",
"serverType": "#/input/compute_node_server_type"
}
  • Avoid defaultValue: "" on strings — in Jinja, | default(x) treats an empty string as defined; use | default(x, true) in playbooks instead.
  • An input with a defaultValue is always present in the runtime variables, so it wins over every | default() fallback in your playbook.
  • Do not add an input that mirrors a platform-derived value (e.g. the site base domain or DNS resolvers): the platform ignores it when provisioning, while your playbook would obey it — the two silently diverge. Use the values provided at runtime in extensionInstanceRecordSet instead (see Structuring Ansible bundles).

Since 7.4

Outputs are values produced by the extension’s playbooks at runtime — for example a generated kubeconfig, an admin password or a console URL — that the platform stores with the extension instance and shows to the user.

"outputs": [
{
"label": "cluster_kubeconfig",
"name": "cluster_kubeconfig",
"outputType": "string"
},
{
"label": "console_url",
"name": "console_url",
"outputType": "string"
}
]
  • label (≤63 chars) — the key the playbook writes; shares the label namespace with inputs.
  • name (≤64 chars) — display name.
  • outputType (≤32 chars) — free string, typically string.
  • template (optional, ≤128 chars) — template applied to the value.

Outputs start out null and are populated when a playbook returns a matching key via its context.json result file.

Important behavior: the stored outputs are replaced on every successful run — a run that returns nothing wipes them. See Outputs and persistence for the full mechanics and the recommended patterns.

Outputs can be referenced as #/output/<label>.

application extensions declare the resources to create in the infrastructure object: instance arrays (groups of servers), logical networks, IP allocations, DNS records and shared drives.

"infrastructure": {
"instanceArrays": [
{
"label": "compute",
"instanceCount": "#/input/compute_nodes",
"serverType": "#/input/compute_node_server_type",
"osTemplate": "#/input/compute_nodes_os_template",
"customVariables": [
{ "name": "debugging_enabled", "value": "#/input/debugging_enabled" }
],
"logicalNetworks": [
{
"label": "cluster-network",
"networkConnection": {
"tagged": false,
"accessMode": "l2",
"mtu": 1500,
"dns": {
"provisionInstanceDnsRecords": true,
"provisionLoadBalancingDnsRecord": true
}
}
}
],
"tags": ["compute-node"]
}
],
"logicalNetworks": [
{
"label": "cluster-network",
"profileLabel": "my-network-profile",
"ipAllocations": [
{
"tags": { "role": "api-vip" },
"ipVersion": "ipv4",
"dnsRecords": [
{
"name": "api.{{CLUSTER_NAME}}.{{default_zone_name}}",
"recordType": "A",
"generatePtrRecord": true,
"ttl": 3600
}
]
}
]
}
]
}
FieldNotes
label≤63 chars, hyphens allowed. Becomes the Ansible inventory group name verbatim — prefer underscores for Ansible ergonomics. Can be an #/input/ reference.
instanceCountString; number or #/input/ reference.
serverTypeRequired; #/input/ reference or server type label.
osTemplate#/input/ reference or OS template label. Must resolve to an existing template at instance-create time.
customVariables≤12 entries of {name (≤64), value (≤128; string, number or boolean; #/input/ refs allowed)}. Surface at runtime under the array label (hyphens converted to underscores).
connectedSharedDrives≤12 entries referencing declared sharedDrives.
logicalNetworks≤12 per-array network attachments (below).
dependenciesLabels of other instance arrays this one depends on.
tags≤256 entries.

The entry’s label references an infrastructure-level logical network’s label:

{ "label": "cluster-network", "networkConnection": { "tagged": true, "accessMode": "l2" } }

networkConnection fields:

  • tagged (required, boolean)
  • accessMode — only "l2" is currently supported
  • mtu (optional, 68–65535)
  • providesDefaultRoute (optional, default false) — the network whose IP is used as ansible_host in the generated inventory
  • disableAutoIpAllocation (optional, default false)
  • redundancy (optional) — { "mode": "active-backup" | "active-active", "implementation": { "implementationType": "link-aggregation" | "distributed-link-aggregation" | "ecmp" } }
  • dns (optional) — { "provisionInstanceDnsRecords": bool, "provisionLoadBalancingDnsRecord": bool } for per-instance A+PTR records and a round-robin load-balancing record

Infrastructure-level logicalNetworks (≤12)

Section titled “Infrastructure-level logicalNetworks (≤12)”
  • label (2–63 chars) — referenced by per-array attachments.
  • profileLabel (2–63 chars) — must name a logical network profile the operator has already created at the target site. This is deployment-specific; document it as a prerequisite of your extension.
  • ipAllocations[]{ "tags": {"role": "my-vip"}, "ipVersion": "ipv4" | "ipv6", "dnsRecords": [...] }. The tags are echoed back at runtime so playbooks can look up the allocated IP by role.
  • ipRanges[]{ "tags": {...}, "ipVersion": ..., "ipCount": N }. Allocation-only networks are fine — omit ipRanges entirely rather than declaring [].
  • name (1–255 chars) — supports the placeholders {{default_zone_name}} (the site default DNS zone) and {{CLUSTER_NAME}} (the extension instance name). Wildcard names such as *.apps.{{CLUSTER_NAME}}.{{default_zone_name}} are accepted.
  • recordTypeA, AAAA, CNAME or PTR only.
  • generatePtrRecord (optional, default false), ttl (optional, default 3600), aliases (optional).

Example — an IP allocation whose records resolve to api.<instance name>.<site zone> (with a PTR record) plus a wildcard record for application ingress:

"ipAllocations": [
{
"tags": { "role": "api-vip" },
"ipVersion": "ipv4",
"dnsRecords": [
{
"name": "api.{{CLUSTER_NAME}}.{{default_zone_name}}",
"recordType": "A",
"generatePtrRecord": true,
"ttl": 3600
},
{
"name": "api-int.{{CLUSTER_NAME}}.{{default_zone_name}}",
"recordType": "A",
"generatePtrRecord": false,
"ttl": 3600
}
]
},
{
"tags": { "role": "ingress-vip" },
"ipVersion": "ipv4",
"dnsRecords": [
{
"name": "*.apps.{{CLUSTER_NAME}}.{{default_zone_name}}",
"recordType": "A",
"generatePtrRecord": false,
"ttl": 3600
}
]
}
]

For an extension instance named mycluster at a site whose default zone is dc1.example.com, these produce api.mycluster.dc1.example.com (A + PTR), api-int.mycluster.dc1.example.com and *.apps.mycluster.dc1.example.com, and the resulting FQDNs are echoed back to the playbooks in extensionInstanceRecordSet on the corresponding allocations.

Declared DNS records are only actually created if a DNS provisioning extension (a workflow extension handling the *DNS stages, such as the PowerDNS or Infoblox examples) is installed at the site. Without one, allocations get fqdn values but nothing resolves. PTR records additionally require the site DNS to be authoritative for the reverse zone. Document both as deployment prerequisites.

{ "label": ..., "sizeGb": N }. Every declared shared drive must appear in some instance array’s connectedSharedDrives.

Assets are the artifacts the extension’s tasks use — Ansible bundles and execution environment images.

"assets": [
{
"label": "my-ansible-bundle",
"name": "my-ansible-bundle",
"assetType": "AnsibleBundle",
"url": "https://repo.example.com/extensions/my-extension-v1.0.0.zip"
},
{
"label": "my-custom-ee",
"name": "my-custom-ee",
"assetType": "OciImage",
"url": "https://repo.example.com/extensions/ee/my-custom-ee-v1.0.0.tar.gz",
"repositoryRegistry": "registry.example.com",
"namespaceRegistry": "my-ee",
"tagRegistry": "1.0.0"
}
]
  • assetType: AnsibleBundle — a zip archive with the playbooks at the archive root (see Structuring Ansible bundles). Requires url (a valid URL, ≤128 chars) reachable by the global controller. Older definitions use assetType: "Bundle", which is still tolerated — use AnsibleBundle for new work.
  • assetType: OciImage — a custom execution environment image, selected per task via the task’s ee option. repositoryRegistry + tagRegistry (plus optional hostRegistry, portRegistry, namespaceRegistry) identify the image the runner pulls. The optional url may point at a docker save | gzip tarball of the same image for air-gapped sites.

application kind: onCreate / onEdit / onDelete

Section titled “application kind: onCreate / onEdit / onDelete”

Each is an array of stage objects; the only stages are preDeploy and postDeploy:

"onCreate": [
{
"stage": "postDeploy",
"tasks": [
{
"label": "install",
"taskType": "ExtensionTaskAnsible",
"options": {
"asset": "my-ansible-bundle",
"playbook": "deploy.yaml",
"executionTimeout": 3600,
"executionTimeoutTick": 30
}
}
]
}
]
  • onCreate — runs when the extension instance is first deployed.
  • onEdit — runs when the user changes inputs and re-deploys. This is also how scale-out and scale-in are performed — see Scaling.
  • onDelete — runs when the instance is deleted, before the resources are released.

An array of { "stage": ..., "tasks": [...] } objects bound to platform events (server registered, DNS record changes, etc.). See Workflow extensions for the list of stages and the payload each stage provides.

A flat array of tasks (no stages) executed on demand.

Since 7.4

configVars declare operator-set configuration — values that belong to the site/environment rather than to a specific extension instance (e.g. the site’s DNS resolvers or an internal registry URL). The operator sets them once per site, on the site configuration page — the same place where the extension is enabled for that site after publishing; every instance of the extension receives them at runtime under configVars.<label>.

Within the site configuration page, at the bottom, extensions can be enabled/disabled, and the configVars can be set.

Entries have the same shape as inputs, but only ExtensionInputString, ExtensionInputInteger and ExtensionInputBoolean types are allowed:

"configVars": [
{
"label": "DNSResolvers",
"name": "DNSResolvers",
"inputType": "ExtensionInputString",
"setOnly": false,
"hidden": false,
"isPassword": false,
"defaultValue": "1.1.1.1",
"options": {}
}
]

List-like values are typically passed as comma-separated strings — normalize them defensively in the playbook:

dns_servers: >-
{{ (configVars.DNSResolvers | default('')).split(',') | map('trim')
| reject('equalto', '') | list or ['1.1.1.1', '8.8.8.8'] }}

Every lifecycle binding contains tasks. A task mixes exactly one set of type-specific options — mixing fields of two types is rejected.

taskTypeOptionsDetails
ExtensionTaskAnsibleasset (an AnsibleBundle asset label), playbook (≤32 chars, bare filename at the bundle root), ee (optional — label of an OciImage asset to run this task in a custom execution environment), executionTimeout / executionTimeoutTick (seconds), version (optional, ≤32)Ansible tasks
ExtensionTaskWebhookendpoint (URL, ≤128 chars), method (GET/POST/PUT/PATCH/DELETE), headers, requestTemplate (required), expectedResponseStatuses (≤12), timeout (1–30000 s, default 30), insecureSkipVerify (default false)HTTP tasks
ExtensionTaskSshhost (≤255), port (1–65535, default 22), username / password (optional), commandTemplate (required), timeout (1–30000 s, default 60)SSH exec tasks

Size executionTimeout to your playbook’s worst case. If it is omitted the platform default (1 hour) applies: the task is reported as timed out even though the playbook may still be running, and its results are discarded.

A trimmed application extension (based on the Red Hat OpenShift extension) showing inputs, outputs, infrastructure with VIP allocations and DNS records, both asset types, scaling via onEdit and a configVar:

{
"kind": "ExtensionDefinition",
"schemaVersion": "1.1",
"name": "Red Hat OpenShift",
"label": "redhatopenshift",
"extensionType": "application",
"vendor": "MetalSoft",
"extensionVersion": "1.0.1",
"description": "Installation and lifecycle management of a Red Hat OpenShift cluster",
"icon": "https://upload.wikimedia.org/wikipedia/commons/3/3a/OpenShift-LogoType.svg",
"dependencies": {
"controllerVersion": "v7.4.0",
"osTemplates": []
},
"inputs": [
{
"label": "control_plane_node_server_type",
"name": "control_plane_node_server_type",
"inputType": "ExtensionInputServerType",
"options": {}
},
{
"label": "control_plane_nodes",
"name": "control_plane_nodes",
"inputType": "ExtensionInputInteger",
"setOnly": true,
"options": { "minValue": 1 },
"defaultValue": 3
},
{
"label": "compute_node_server_type",
"name": "compute_node_server_type",
"inputType": "ExtensionInputServerType",
"options": {}
},
{
"label": "compute_nodes",
"name": "compute_nodes",
"inputType": "ExtensionInputInteger",
"options": { "minValue": 0 },
"defaultValue": 0
},
{
"label": "pull_secret",
"name": "pull_secret",
"inputType": "ExtensionInputString",
"isPassword": true,
"options": {},
"defaultValue": ""
}
],
"outputs": [
{
"label": "cluster_kubeconfig",
"name": "cluster_kubeconfig",
"outputType": "string"
},
{
"label": "cluster_kubeadmin_password",
"name": "cluster_kubeadmin_password",
"outputType": "string"
},
{
"label": "api_url",
"name": "api_url",
"outputType": "string"
},
{
"label": "console_url",
"name": "console_url",
"outputType": "string"
}
],
"infrastructure": {
"instanceArrays": [
{
"label": "control-plane",
"instanceCount": "#/input/control_plane_nodes",
"serverType": "#/input/control_plane_node_server_type",
"logicalNetworks": [
{
"label": "openshift-network",
"networkConnection": {
"tagged": false,
"accessMode": "l2",
"mtu": 1500,
"dns": {
"provisionInstanceDnsRecords": true,
"provisionLoadBalancingDnsRecord": true
}
}
}
],
"tags": ["control-plane-node"]
},
{
"label": "compute",
"instanceCount": "#/input/compute_nodes",
"serverType": "#/input/compute_node_server_type",
"logicalNetworks": [
{
"label": "openshift-network",
"networkConnection": {
"tagged": false,
"accessMode": "l2",
"mtu": 1500,
"dns": {
"provisionInstanceDnsRecords": true,
"provisionLoadBalancingDnsRecord": false
}
}
}
],
"tags": ["compute-node"]
}
],
"logicalNetworks": [
{
"label": "openshift-network",
"profileLabel": "openshift-mgmt",
"ipAllocations": [
{
"tags": { "role": "api-vip" },
"ipVersion": "ipv4",
"dnsRecords": [
{
"name": "api.{{CLUSTER_NAME}}.{{default_zone_name}}",
"recordType": "A",
"generatePtrRecord": true,
"ttl": 3600
}
]
},
{
"tags": { "role": "ingress-vip" },
"ipVersion": "ipv4",
"dnsRecords": [
{
"name": "*.apps.{{CLUSTER_NAME}}.{{default_zone_name}}",
"recordType": "A",
"generatePtrRecord": false,
"ttl": 3600
}
]
}
]
}
]
},
"assets": [
{
"label": "openshift-ansible-bundle",
"name": "openshift-ansible-bundle",
"assetType": "AnsibleBundle",
"url": "https://repo.metalsoft.io/.extensions_ms/redhat-openshift/redhat-openshift-v1.0.1.zip"
},
{
"label": "ee-redhat-openshift",
"name": "ee-redhat-openshift",
"assetType": "OciImage",
"url": "https://repo.metalsoft.io/.extensions_ms/ee/ee-ocd-4-19_v1.0.0.tar.gz",
"repositoryRegistry": "registry.metalsoft.dev",
"namespaceRegistry": "ee-ocd-4-19",
"tagRegistry": "4-19"
}
],
"onCreate": [
{
"stage": "postDeploy",
"tasks": [
{
"label": "installOpenshift",
"taskType": "ExtensionTaskAnsible",
"options": {
"asset": "openshift-ansible-bundle",
"playbook": "deploy.yaml",
"ee": "ee-redhat-openshift",
"executionTimeout": 3600,
"executionTimeoutTick": 30
}
},
{
"label": "monitorInstallation",
"taskType": "ExtensionTaskAnsible",
"options": {
"asset": "openshift-ansible-bundle",
"playbook": "monitor-installation.yaml",
"ee": "ee-redhat-openshift",
"executionTimeout": 14400,
"executionTimeoutTick": 30
}
}
]
}
],
"onEdit": [
{
"stage": "preDeploy",
"tasks": [
{
"label": "scaleIn",
"taskType": "ExtensionTaskAnsible",
"options": {
"asset": "openshift-ansible-bundle",
"playbook": "scale.yaml",
"ee": "ee-redhat-openshift",
"executionTimeout": 3600,
"executionTimeoutTick": 30
}
}
]
},
{
"stage": "postDeploy",
"tasks": [
{
"label": "scaleOut",
"taskType": "ExtensionTaskAnsible",
"options": {
"asset": "openshift-ansible-bundle",
"playbook": "scale.yaml",
"ee": "ee-redhat-openshift",
"executionTimeout": 3600,
"executionTimeoutTick": 30
}
}
]
}
],
"onDelete": [],
"configVars": [
{
"label": "DNSResolvers",
"name": "DNSResolvers",
"inputType": "ExtensionInputString",
"setOnly": false,
"hidden": false,
"isPassword": false,
"defaultValue": "1.1.1.1",
"options": {}
}
]
}