# Machine

The `ghcr.io/captf-io/openstack-machine` image implements the [machine role](<https://captf.io/docs/module-author/contract/v1alpha1/machine/index.md>) for a `TerraformMachine` on OpenStack. It creates one Nova server on a Neutron port of the cluster’s subnet and boots it with the bootstrap payload; for a control-plane machine it first adds the port’s address to the cluster’s API pools. Everything about the cluster comes from `captf_cluster_outputs`, the [cluster role’s exports](<https://captf.io/docs/cloud-modules/openstack/#exports>).

## What it creates

| Resource | Purpose | When |
| --- | --- | --- |
| `openstack_networking_port_v2.node_port` | Port on the cluster subnet, with the security groups and allowed address pairs | Always |
| `openstack_lb_member_v2.api_members` | Membership in each API pool, removed by this machine’s destroy | One per exported pool, on a control-plane machine |
| `openstack_compute_instance_v2.node_instance` | The Nova server, named `machine_name` | Always, after the port and the members |

The server waits for the pool members, so a control-plane machine is in the API pools before it boots, as `kubeadm init` and RKE2 joins require (see [Control-plane machines](<https://captf.io/docs/module-author/contract/v1alpha1/machine/#control-plane-machines>)). It reads the server’s status for health, except while the server is in `BUILD`.

## Inputs

Contract inputs used:

- `captf_contract` (validated), `captf_object` (the port name), `captf_tags`;
- `captf_cluster_outputs`: network, subnet, groups, server group, pools, zones and region, validated to schema `captf.io/openstack-cluster/v1` or `{}`;
- `machine_name`: the server name, member names, the `Hostname` address and the default zone pick;
- `bootstrap_data` and `bootstrap_format` (see [Bootstrap](<#bootstrap>));
- `failure_domain`: the server’s availability zone, one of the cluster’s;
- `kubernetes_version`: fills `{version}` and `{semver}` in `image_name`, without its `+rke2rN` suffix;
- `control_plane`: pool membership, the control-plane group and the server group.

`captf_cluster` is declared and unused.

User variables, set in the `TerraformMachineTemplate`’s `spec.template.spec.variables` ([Module Variables](<https://captf.io/docs/user-guide/variables/index.md>); source: [variables.tf](<https://github.com/captf-io/openstack-modules/blob/main/machine/variables.tf>)):

| Variable | Type | Default | Description |
| --- | --- | --- | --- |
| `additional_security_group_ids` | `list(string)` | `[]` | Extra Neutron security group UUIDs for the port |
| `additional_tags` | `map(string)` | `{}` | Extra tags, with the cluster role’s rules |
| `config_drive` | `bool` | `false` | Attach a config drive with the user data and metadata; it does not turn the metadata service off |
| `external_cluster_exports` | `any` | `null` | The exports of an externally managed `TerraformCluster`, schema `captf.io/openstack-cluster/v1` |
| `flavor_name` | `string` | `null` | **Required.** Nova flavor |
| `image_id` | `string` | `null` | Glance image UUID. Set exactly one of `image_id` and `image_name`; boot from volume needs `image_id` |
| `image_name` | `string` | `null` | Glance image name, resolved to an ID by the provider through Glance at create; must match exactly one image. `{version}` (`v1.31.4`) and `{semver}` (`1.31.4`) stand for `kubernetes_version` |
| `key_pair` | `string` | `null` | Nova key pair for SSH |
| `root_volume_size_gib` | `number` | `null` | Boot from a new Cinder volume of this size, deleted with the server; `null` boots from the flavor’s disk |
| `root_volume_type` | `string` | `null` | Cinder volume type of the root volume |

## Outputs

| Output | Value |
| --- | --- |
| `provider_id` | `openstack:///<server-uuid>`, or `openstack://<region>/<server-uuid>` with the cluster’s `provider_id_format = "regional"`; `null` once the server is gone |
| `addresses` | `InternalIP` for each fixed IP of the port, then `Hostname`, `machine_name` |
| `failure_domain` | The availability zone: the requested one, or the module’s pick |
| `interruptible` | Always `false`: Nova has no spot servers |
| `health` | See below |
| `api_member_ids` | Not a contract output: Octavia member UUIDs keyed by pool, `{}` on a worker |
| `node_port_id` | Not a contract output: the Neutron port’s UUID, `null` once it is gone |

`provider_id` is what the OpenStack cloud controller manager writes to the Node: `makeInstanceID` in [cloud-provider-openstack v1.34.1](<https://github.com/kubernetes/cloud-provider-openstack/blob/v1.34.1/pkg/openstack/instances.go>) returns `openstack:///<id>`, or `openstack://<region>/<id>` when `OS_CCM_REGIONAL=true`. The addresses mirror what it reports for a server without floating IPs. Without a requested failure domain, the module picks a zone from the sha256 of `machine_name`, the same way in every repository.

## Health

From the server’s Nova status, re-read on every refresh.

| Nova status | Contract state | Reason |
| --- | --- | --- |
| `ACTIVE`, `MIGRATING`, `PASSWORD` | `running`, healthy | none |
| `BUILD` | `pending` | `ServerBuilding` |
| `REBOOT`, `HARD_REBOOT` | `pending` | `ServerRebooting` |
| `REBUILD` | `pending` | `ServerRebuilding` |
| `RESIZE`, `VERIFY_RESIZE`, `REVERT_RESIZE` | `pending` | `ServerResizing` |
| `SHUTOFF` | `stopped` | `ServerShutOff` |
| `SUSPENDED` | `stopped` | `ServerSuspended` |
| `PAUSED` | `stopped` | `ServerPaused` |
| `SHELVED`, `SHELVED_OFFLOADED` | `stopped` | `ServerShelved` |
| `RESCUE`, `ERROR` | `degraded` | `ServerRescued`, `ServerError` |
| `SOFT_DELETED`, `DELETED` | `terminated` | `ServerDeleted` |
| deleted outside Terraform | `terminated` | `ServerNotFound` |
| `UNKNOWN`, anything else | `unknown` | `ServerStatusUnknown` |

Provider 3.4.0 reads only some of these. The server resource reads `ACTIVE`, `BUILD`, `SHUTOFF`, `PAUSED`, `SHELVED`, `SHELVED_OFFLOADED`, `MIGRATING` and `ERROR`; in any other status its refresh fails, and so does every drift Job and destroy until the server leaves that status. The status read is skipped while the server is in `BUILD`, where it would fail, so health then comes from the server itself and a server stuck building can still be destroyed.

## Lifecycle

Machines are immutable: the module applies once, then refreshes for health and destroys on delete. Its inputs, `captf_cluster_outputs` included, are pinned at the first apply, so a later change to the cluster’s zones or exports never reaches a running machine. Changes outside Terraform show up in drift reports only:

- The server’s image is ignored after creation, so a rotated or deleted image never shows as drift (the provider would rebuild the server in place).
- A deleted flavor shows as a planned replacement; a renamed or resized one as an in-place resize.
- On destroy, the server goes before its pool members; Cluster API has drained the node by then, and the load balancer’s monitor takes the backend out.

## Bootstrap

- `bootstrap_data` goes to Nova as user data unchanged: valid base64 passes through as is, so gzipped cloud-config works, and state keeps only a SHA1 of it. Gzipped Ignition fails a precondition.
- Nova accepts at most 65,535 bytes of base64 user data; a larger payload fails a precondition. Compress it (CAPRKE2 `gzipUserData`) if needed.
- **Who can read it.** OpenStack has no instance identity to fetch a staged payload with, so there is no `bootstrap_delivery`: the control-plane payload, with the cluster CA keys, sits in user data, and anything that reaches the metadata service (169.254.169.254) on the node can read it. A config drive does not turn the metadata service off. Deny 169.254.169.254/32 to pods with a CNI `NetworkPolicy`. Cluster API Provider OpenStack has the same exposure.

## Limitations

> [!WARNING]
>
> **Keep machine\_name within 63 characters and DNS-safe**
>
> Nova derives the hostname from the server name, cut to 63 characters. Keep `machine_name` within 63 characters and DNS-safe, or the Node name does not match the server and the cloud controller manager cannot find it.

- **No spot.** `interruptible` is always `false`.
- **Port security groups.** Keep the cloud controller manager’s `manage-security-groups` off: groups it adds to the port show as drift.

## Exceptions

- The server is named `machine_name`, not the conventions’ prefixed name, because the cloud controller manager finds servers by Node name.
- The boot-from-volume root volume carries no tags: Nova creates it from the block device mapping, which takes no metadata, and a separately created, tagged volume would need a Cinder availability zone named like the Nova one.
- Bootstrap payloads are readable from the metadata service (see [Bootstrap](<#bootstrap>)), a deviation from the contract’s checklist.
- There is no `rejects_spot_control_plane` test: Nova has no spot servers.
- No capacity labels on the image: there is no default flavor to describe.

## Example

terraformmachinetemplate.yaml

```yaml
apiVersion: infrastructure.cluster.x-k8s.io/v1alpha1
kind: TerraformMachineTemplate
metadata:
  name: demo-md-0
spec:
  template:
    spec:
      source:
        image: ghcr.io/captf-io/openstack-machine:v0.1.0-opentofu
      variables:
        flavor_name: m1.large
        image_name: ubuntu-2404-kube-{version}
```
