Schema-Driven Inventory Enrichment — Live Provider Dropdowns

How the OutpostAI cluster creation wizard populates its form fields from live provider inventory (Glance images, networks, flavors, SSH keypairs) via the OpenStack MCP server, and how the FIPS toggle becomes a real conditional filter on the image dropdown.

The cluster creation wizard’s Step 2 form fields are not hardcoded. They’re generated at request time from the chosen template’s JSON Schema, with inventory-sourced fields (like Glance images, external networks, flavors, and SSH keypairs) populated live from the appropriate provider MCP server. Operators pick from dropdowns of real resources; they never type UUIDs by hand.

This page explains how that works, how to add a new inventory-enriched field, and how the FIPS toggle becomes a real conditional filter on the image dropdown.

Why not hardcode?

The legacy path for cluster creation forms required an operator to type:

  • An OpenStack external network UUID (not the name)
  • A Glance image name that matches an operator-chosen string
  • An SSH keypair name the operator had to remember
  • Flavor names from a hardcoded list that may not match the current Vitro deployment

That’s an awful experience and it guarantees operators will mistype things. Worse: every time the list of valid values changes (a new Glance image is uploaded, a new keypair is created, a flavor is removed), the form is out of date.

The right answer is the same pattern cloud consoles have used for twenty years: look up the resource at request time and show the operator what actually exists. The Cluster Template System does this with a small vendor extension to JSON Schema.

The x-inventory-source annotation

A cluster template’s JSON Schema field can carry an x-inventory-source vendor extension that tells the backend how to populate its enum from a live provider query. For example, the CAPO template’s image_name field:

{
  "image_name": {
    "type": "string",
    "title": "Glance Image",
    "description": "Packer-built RKE2 node image (live from Vitro). When FIPS is enabled, only -fips images are shown.",
    "x-inventory-source": {
      "provider": "openstack",
      "tool": "openstack_list_images",
      "label_field": "name",
      "value_field": "name",
      "filter": {
        "name_contains": "rke2-node",
        "fips_required_when": "fips_enabled"
      }
    }
  }
}

When the OutpostAI wizard calls GET /api/v1/cluster-templates/{id}/schema, the backend walks the schema properties, finds x-inventory-source annotations, calls the corresponding MCP tool, applies the filter spec, and replaces the field’s enum with the resolved list of values. The wizard then renders a dropdown from the enriched schema as usual.

Annotation fields

Key Purpose
provider Which MCP server to call — currently openstack; future: aws, azure, oci
tool The MCP tool name (e.g. openstack_list_images, openstack_list_networks)
args Arguments passed to the tool (optional, most inventory tools take none)
label_field Which field in each returned item becomes the dropdown label (what the operator sees)
value_field Which field becomes the dropdown value (what gets sent back to render)
filter Filter spec — see below

Filter spec keys

Filter key Meaning
name_contains Case-insensitive substring match on the item’s name
name_regex Regex match against the name
external_only Only items where router:external=true (Neutron networks)
min_vcpus Only flavors with at least N vcpus
min_ram_mb Only flavors with at least N MB of RAM
fips_required_when Name of another field; if that field’s value is true in the current values, only items whose name contains fips pass
fips_excluded_when Inverse: drop items containing fips when the named field is true

The filter keys that reference other fields (fips_required_when, fips_excluded_when) are how conditional filtering works. The wizard re-fetches the schema with the current values when a gating field changes; the backend re-evaluates the filter and returns a new enum.

The CAPO template fields that use it

The default capo-rke2-default template annotates five fields with x-inventory-source:

Field MCP tool Filter
image_name openstack_list_images name_contains: "rke2-node", fips_required_when: "fips_enabled"
external_network_id openstack_list_networks external_only: true, label is name, value is id (so templates render the UUID but operators see the network name)
flavor_control_plane openstack_list_flavors min_vcpus: 2, min_ram_mb: 4096
flavor_worker openstack_list_flavors min_vcpus: 1, min_ram_mb: 2048
ssh_key_name openstack_list_keypairs (no filter)

Every one of these was a free-text input in the previous version of the wizard and a common source of operator error.

How the wizard re-fetches on conditional changes

The wizard has a useEffect hook tied to templateValues.fips_enabled. When the FIPS toggle flips, the hook re-calls GET /api/v1/cluster-templates/{id}/schema?fips_enabled=true (or false). The backend’s schema endpoint reads the query string into a current_values dict, passes it to the enricher, and the enricher’s filter spec picks it up to re-filter the image list.

The wizard also validates the currently selected image after each re-fetch. If the operator had already picked rke2-node-v1.31-20260408 and then flips FIPS on, the image name is no longer in the filtered enum — so the wizard clears it and forces the operator to pick from the filtered (FIPS-only) list. This prevents the exact class of bug where an operator ticks FIPS, forgets to change the image, and ends up with a non-FIPS cluster that thinks it’s FIPS.

Caching

The enricher uses a 30-second in-process TTL cache keyed on (provider, tool, args_hash). If five operators open the wizard at once, only one actually hits the OpenStack API; the other four read the cached result. Fresh enough for “live”, cheap enough not to hammer the OpenStack control plane.

The cache is intentionally not persisted across TrailbossAI restarts — operators should see the current state of the world, not what was cached an hour ago.

Soft failure mode

If a provider MCP call fails (MCP server down, timeout, auth error) or returns zero items matching the filter, the enricher does not block the schema from being served. Instead:

  • The field’s enum is left empty
  • A warning is appended to the field’s description explaining the failure
  • x-inventory-empty: true is set as a marker
  • The wizard sees the empty enum and renders it with Intent.WARNING, a — no inventory — placeholder option, and the warning text as helper text

The operator sees exactly what’s wrong and can still proceed by entering a value manually if needed. This is deliberate — hard-failing the schema endpoint would break the wizard whenever the OpenStack MCP has a hiccup, which is a worse experience than showing a warning.

The FIPS toggle end-to-end

Putting the pieces together, here’s what happens when an operator ticks Enable FIPS 140-2/3 in Step 2 of the wizard:

  1. The fips_enabled field in templateValues flips from false to true
  2. The wizard’s re-fetch useEffect fires
  3. The wizard calls GET /api/v1/cluster-templates/2/schema?fips_enabled=true
  4. The backend get_cluster_template_schema_endpoint builds current_values = {"fips_enabled": True} and calls enrich_schema(schema, current_values)
  5. The enricher walks the schema, reaches image_name, sees x-inventory-source.filter.fips_required_when = "fips_enabled", and checks current_values["fips_enabled"] == True
  6. The enricher calls openstack_list_images, filters to items whose name contains rke2-node AND contains fips
  7. The enriched schema comes back with image_name.enum = ["rke2-node-v1.31-fips-20260408", ...] (only FIPS images)
  8. The wizard clears the previous image_name selection (it’s no longer in the new enum)
  9. The operator picks a FIPS image from the filtered dropdown
  10. On Create Cluster, the template renders with fips_enabled=true; the Jinja2 template sees that and sets FIPS-only TLS cipher suites on the KubeadmControlPlane apiServer config
  11. The rendered manifest is pushed to Gitea and ArgoCD syncs it; CAPO boots the cluster from the FIPS Glance image; RKE2 starts with the FIPS cipher suite list

The end result is a FIPS cluster from operator click to provisioning, with no opportunity to mis-select a non-FIPS image. FIPS compliance is enforced by filter, not by trust.

What’s still needed for actual FIPS

The template-level and wizard-level pieces are in place. To complete FIPS end-to-end you still need:

  1. A FIPS-validated Packer image in Glance. The fips_enabled=true filter will show zero images until someone runs packer build -var fips_enabled=true rke2-node.pkr.hcl against an Ubuntu Pro 22.04 FIPS source image and uploads the result. See Packer Image Build Process for the build workflow.
  2. The Ubuntu Advantage FIPS token attached to the build VM during Packer, so ua enable fips-updates succeeds.
  3. FIPS-validated RKE2 binaries pulled by the Packer build — Rancher publishes separate FIPS releases.
  4. Validation — after a FIPS cluster boots, run a script that confirms cat /proc/sys/crypto/fips_enabled == 1, pro status shows fips-updates enabled, and the API server’s negotiated TLS cipher is in the FIPS-approved list.

For non-OpenStack providers (CAPA, CAPZ, CAPOCI), FIPS has provider-specific mechanisms — AWS GovCloud with FIPS endpoints, Azure AKS Confidential Computing, OCI specific image OCIDs. Those templates should add their own fips_enabled fields with provider-appropriate filter specs when the time comes. The architecture supports it with no code changes.

Creating inventory items from the wizard — x-can-create

Some inventory dropdowns are legitimately self-service: an operator who doesn’t have an SSH keypair uploaded to the project shouldn’t have to drop to the OpenStack CLI, upload the key, and come back. The Cluster Template System supports this via a second vendor extension — x-can-create — that sits alongside x-inventory-source on the same field.

The annotation

"ssh_key_name": {
  "type": "string",
  "title": "SSH Keypair",
  "x-inventory-source": {
    "provider": "openstack",
    "tool": "openstack_list_keypairs",
    "label_field": "name",
    "value_field": "name"
  },
  "x-can-create": {
    "endpoint": "/api/v1/providers/openstack/keypairs",
    "method": "POST",
    "title": "Add SSH Keypair",
    "fields": [
      {"name": "name", "label": "Keypair Name", "type": "string", "required": true, "placeholder": "e.g. my-laptop"},
      {"name": "public_key", "label": "OpenSSH Public Key", "type": "textarea", "required": true, "placeholder": "ssh-ed25519 AAAAC3... user@host"}
    ]
  }
}

What the wizard does with it

When the dynamic form renderer sees x-can-create on an enum field, it:

  1. Renders the dropdown inside a flex row with a Blueprint + button on the right
  2. Clicking the + opens a generic sub-dialog whose form is built from the fields array:
    • type: "string"InputGroup
    • type: "textarea"<textarea> with monospace font (good for pasting public keys)
  3. On Create, the wizard POSTs createForm to endpoint (falling back to a generic fetch for any x-can-create endpoint without a dedicated API client function)
  4. On success, the wizard re-fetches the template schema (preserving all other field values, including fips_enabled), which now includes the newly created item in the enum
  5. The wizard auto-selects the new item in the dropdown

The wizard has no per-field code for this. Adding + Create Network or + Create Flavor buttons in the future is a JSON Schema annotation — zero frontend work.

Backend responsibilities

The endpoint at the declared path must:

  1. Validate inputs (shape, required fields, OpenSSH format for keys, etc.)
  2. Call the appropriate provider MCP tool (e.g. openstack_create_keypair)
  3. Invalidate the inventory cache for the corresponding x-inventory-source.tool so the subsequent schema re-fetch returns fresh data instead of the 30s-cached stale list
  4. Return the created item’s identifier so the wizard can pre-select it

The inventory enricher exposes a helper for step 3:

from common.tools.inventory_enricher import invalidate_cache
await invalidate_cache(provider="openstack", tool="openstack_list_keypairs")

First consumer — SSH keypair upload

The current shipping implementation wires x-can-create into the CAPO template’s ssh_key_name field. An operator clicks the +, pastes an OpenSSH public key from their laptop (e.g. ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIA... user@host), names it, and clicks Create. Within ~2 seconds the keypair shows up in the dropdown pre-selected, and the cluster that eventually provisions will have that operator’s key baked into its nodes’ ~ubuntu/.ssh/authorized_keys.

Safety checks enforced server-side:

  • Key must start with ssh- or ecdsa- (i.e. OpenSSH format, not a raw base64 blob)
  • Key must not contain PRIVATE KEY (rejects accidentally-pasted private keys)
  • Name must be unique within the OpenStack project (OpenStack enforces this; the endpoint surfaces the error)

Adding a new inventory-enriched field

Three steps:

  1. Author the annotation in the template’s JSON Schema:
    "my_field": {
      "type": "string",
      "title": "My Field",
      "x-inventory-source": {
        "provider": "openstack",
        "tool": "openstack_list_something",
        "label_field": "name",
        "value_field": "id",
        "filter": {"name_contains": "prefix-"}
      }
    }
    
  2. Confirm the MCP tool exists in the target MCP server. For OpenStack, see openstack_list_vms / _networks / _images / _flavors / _keypairs / _availability_zones / … — 23 tools are already exposed.

  3. If the new field needs a filter key that doesn’t exist yet, add it to _matches_filter() in common/tools/inventory_enricher.py. Current supported keys are listed in the table above.

No frontend code changes. The wizard auto-discovers the new field from the JSON Schema and renders it as a dropdown.