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.
The extension and its definition
Section titled “The extension and its definition”There are two layers to keep apart:
- The extension (wrapper) — the entity you create via the CLI. It holds the
name, the kind (application,workfloworaction), a description, the wrapper label (lowercase, hyphens allowed) and the visibility/status (draft→active→archived). These are set at registration time and are not part ofextension.json. - The definition — the contents of
extension.json, described on this page. It is attached to the extension via--definition-sourcewhen creating or updating it.
metalcloud-cli extension create "My Extension" application "My description" --definition-source extension.json --format jsonThe extension kind decides which sections of the definition are used:
| Kind | Uses |
|---|---|
application | inputs, outputs, infrastructure, assets, onCreate, onEdit, onDelete, configVars |
workflow | assets, onAssetChange |
action | assets, actions (a flat task list, no stages) |
Top-level structure
Section titled “Top-level structure”{ "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": []}| Field | Constraints | Notes |
|---|---|---|
kind | must be exactly ExtensionDefinition | The same literal for all extension kinds. The application/workflow/action kind is a CLI argument, not a field in this file. |
schemaVersion | must be exactly "1.1" | Any other value is rejected. |
name | ≤255 chars | Human-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 string | Informational classification. |
vendor | ≤64 chars | |
extensionVersion | ≤12 chars | Informational version of your extension. |
description | optional, ≤255 chars | |
icon | required | A URL to an icon, or none. |
dependencies.controllerVersion | required, ≤32 chars | Informational only — it is not compared against the actual controller version. |
dependencies.osTemplates | optional, ≤12 entries | Informational only. The real check happens at instance-create time: instanceArrays[].osTemplate must resolve to an existing OS template by label. |
inputs | ≤100 entries | Shares one label namespace with outputs for #/input/ references. |
outputs | ≤12 entries | See Outputs. |
assets | ≤12 entries | See Assets. |
onCreate / onEdit / onDelete | ≤12 entries each | application kind only. |
onAssetChange | ≤12 entries | workflow kind only. See Workflow extensions. |
actions | ≤12 entries | action kind only — a flat array of tasks. |
configVars | ≤12 entries | See configVars. |
Inputs
Section titled “Inputs”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 (underextensionInstanceVariables) 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: keeplabelandnameidentical to avoid confusion.inputType— one of the types below.options— per-type options object (may be{}).defaultValue— optional; must match the input type. Forbidden onExtensionInputServerTypeandExtensionInputOsTemplate.isPassword: true—ExtensionInputStringonly. 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.
Input types
Section titled “Input types”| inputType | Options | Notes |
|---|---|---|
ExtensionInputString | validationRegEx (≤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. |
ExtensionInputInteger | minValue, maxValue, deniedValues (≤12) | |
ExtensionInputBoolean | — | |
ExtensionInputEnum | values (array of strings) | Renders a selection from the given values. |
ExtensionInputServerType | minCpu, minRamGb, vendor | Lets the user pick a server type. Constraints are enforced at instance-create time. No defaultValue allowed. |
ExtensionInputOsTemplate | osFamily | Lets the user pick an OS template. No defaultValue allowed. |
ExtensionInputNetworkProfilealso exists in the schema but is not currently supported for use in extensions — avoid it.
Referencing inputs
Section titled “Referencing inputs”Reference an input value elsewhere in the definition with the syntax #/input/<label>. This is valid in:
infrastructure.instanceArrays[].instanceCount,serverType,osTemplateandlabelinfrastructure.instanceArrays[].customVariables[].valueinfrastructure.instanceArrays[].connectedSharedDrivesentries
{ "label": "compute-nodes", "instanceCount": "#/input/compute_nodes", "serverType": "#/input/compute_node_server_type"}Input design tips
Section titled “Input design tips”- 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
defaultValueis 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
extensionInstanceRecordSetinstead (see Structuring Ansible bundles).
Outputs
Section titled “Outputs”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, typicallystring.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>.
Infrastructure
Section titled “Infrastructure”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 } ] } ] } ]}instanceArrays (≤12)
Section titled “instanceArrays (≤12)”| Field | Notes |
|---|---|
label | ≤63 chars, hyphens allowed. Becomes the Ansible inventory group name verbatim — prefer underscores for Ansible ergonomics. Can be an #/input/ reference. |
instanceCount | String; number or #/input/ reference. |
serverType | Required; #/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). |
dependencies | Labels of other instance arrays this one depends on. |
tags | ≤256 entries. |
Per-array network attachment
Section titled “Per-array network attachment”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 supportedmtu(optional, 68–65535)providesDefaultRoute(optional, defaultfalse) — the network whose IP is used asansible_hostin the generated inventorydisableAutoIpAllocation(optional, defaultfalse)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 — omitipRangesentirely rather than declaring[].
dnsRecords
Section titled “dnsRecords”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.recordType—A,AAAA,CNAMEorPTRonly.generatePtrRecord(optional, defaultfalse),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
workflowextension handling the*DNSstages, such as the PowerDNS or Infoblox examples) is installed at the site. Without one, allocations getfqdnvalues but nothing resolves. PTR records additionally require the site DNS to be authoritative for the reverse zone. Document both as deployment prerequisites.
sharedDrives (≤12)
Section titled “sharedDrives (≤12)”{ "label": ..., "sizeGb": N }. Every declared shared drive must appear in some instance array’s connectedSharedDrives.
Assets
Section titled “Assets”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). Requiresurl(a valid URL, ≤128 chars) reachable by the global controller. Older definitions useassetType: "Bundle", which is still tolerated — useAnsibleBundlefor new work.assetType: OciImage— a custom execution environment image, selected per task via the task’seeoption.repositoryRegistry+tagRegistry(plus optionalhostRegistry,portRegistry,namespaceRegistry) identify the image the runner pulls. The optionalurlmay point at adocker save | gziptarball of the same image for air-gapped sites.
Lifecycle bindings
Section titled “Lifecycle bindings”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.
workflow kind: onAssetChange
Section titled “workflow kind: onAssetChange”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.
action kind: actions
Section titled “action kind: actions”A flat array of tasks (no stages) executed on demand.
configVars (site-level configuration)
Section titled “configVars (site-level configuration)”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'] }}Task types
Section titled “Task types”Every lifecycle binding contains tasks. A task mixes exactly one set of type-specific options — mixing fields of two types is rejected.
| taskType | Options | Details |
|---|---|---|
ExtensionTaskAnsible | asset (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 |
ExtensionTaskWebhook | endpoint (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 |
ExtensionTaskSsh | host (≤255), port (1–65535, default 22), username / password (optional), commandTemplate (required), timeout (1–30000 s, default 60) | SSH exec tasks |
Size
executionTimeoutto 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.
Complete example
Section titled “Complete example”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": {} } ]}Related pages
Section titled “Related pages”- App Extensions — the application extension lifecycle
- Workflow extensions — stages and payloads
- Execution environments
- Structuring Ansible bundles — inventory, variables, scaling, outputs
- Working examples: metalsoft-io/metalsoft-extensions