# exoscale-connector — AI reference bundle

> Generated by `scripts/generate_llms_txt.py` from the package source and the
> live-verified asset-type docs. Do not edit by hand — regenerate instead.
> Package version: 0.4.0.

`exoscale-connector` is a clean, typed, reusable Python connector for the
Exoscale APIv2. Runtime dependencies are just `requests` + `pydantic` v2
(Object Storage support optionally adds `boto3` via the `[sos]` extra).
Install with `pip install exoscale-connector`.

This file is self-contained context for an AI assistant. Everything in it is
generated from code or backed by live tests against a real Exoscale tenant —
when guidance here contradicts the OpenAPI spec, this file reflects observed
live behaviour. Cite real methods from the API surface below; do not invent
methods or fields.

## Core usage

```python
from exoscale_connector import ExoscaleClient
from exoscale_connector.resources.security_group import SecurityGroupClient

client = ExoscaleClient.from_env(zone="de-fra-1")
groups = SecurityGroupClient(client).list()
```

Conventions that apply everywhere:

- **Authentication is env-based only**: `EXOSCALE_API_KEY` /
  `EXOSCALE_API_SECRET` / `EXOSCALE_ZONE`. Nothing is read from disk or
  hardcoded; inject credentials with any secret manager.
- **snake_case ↔ kebab-case**: every model maps Python snake_case attributes
  to the API's kebab-case JSON keys automatically (`flow_direction` ↔
  `flow-direction`). Build payloads with either typed models or plain dicts
  using the kebab-case keys.
- **Unknown server fields pass through** (`extra="allow"`), so models keep
  working when the API adds fields.
- **Async operations are awaited by default** — mutating calls poll the
  operation and return the settled resource; pass `wait=False` to get the
  `Operation` envelope back immediately.
- **`ensure()` is idempotent get-or-create by name** — provisioning scripts
  are re-runnable by construction.
- **Catalogue knowledge is discovered, never hardcoded**: zones, instance
  types, and templates are queried live (`ZoneClient`, `InstanceTypeClient`,
  `TemplateClient`), never from baked-in enums.
- **CLI**: each asset type also has a CLI binary (e.g.
  `exoscale-security-group`), all namespaced under `exoscale-connector`.
  JSON to stdout, errors to stderr, exit 0/1/2 (success/API error/usage).


## Core client

A thin, signed HTTP client for one set of Exoscale credentials.

- `delete(path: str, *, zone: Optional[str] = None, params: Optional[dict] = None) -> dict`
- `from_env(*, zone: Optional[str] = None) -> "'ExoscaleClient'"`
  Convenience constructor: build config from the environment, then a client.
- `get(path: str, *, zone: Optional[str] = None, params: Optional[dict] = None) -> dict`
- `post(path: str, *, zone: Optional[str] = None, json: Any = None) -> dict`
- `put(path: str, *, zone: Optional[str] = None, json: Any = None) -> dict`
- `request(method: str, path: str, *, zone: Optional[str] = None, params: Optional[dict] = None, json: Any = None) -> dict`
  Send a signed request to ``<base>/<path>`` and return the parsed body.
- `wait_operation(operation: Union[Operation, dict, str], *, zone: Optional[str] = None, timeout: Optional[float] = None, poll_interval: float = 2.0) -> Operation`
  Poll an async operation until it succeeds, then return the final state.

`ClientConfig` fields (all overridable via environment):

- `api_key`: str
- `api_secret`: str
- `zone`: Optional[str]
- `endpoint`: Optional[str]
- `timeout`: float
- `verify_tls`: bool
- `max_retries`: int
- `retry_backoff`: float
- `max_poll_failures`: int
- `operation_timeout`: float

### Errors

- `APIError` — The API returned a non-success HTTP status.
- `ConfigError` — Configuration is missing or invalid (e.g. credentials or zone not set).
- `ExoscaleError` — Base class for every error raised by the connector.
- `NotFoundError` — A resource lookup returned HTTP 404.
- `OperationError` — An asynchronous API operation finished in a non-success state.
- `OperationTimeoutError` — An asynchronous API operation did not complete within the timeout.
- `WaitTimeoutError` — A resource did not reach the expected state within the timeout.

### Waiting helpers

- `wait_for_state(getter: Callable[[], T], expected: str, *, timeout: float = 600.0, interval: float = 5.0, attr: str = 'state') -> T`
  Poll ``getter()`` until its ``attr`` equals ``expected`` (case-insensitive).

## Common operations — every resource client

Base class for asset-type clients.

- `create(payload: Any, *, zone: Optional[str] = None, wait: Optional[bool] = None) -> ModelT`
  Create a resource and return it.
- `delete(resource_id: str, *, zone: Optional[str] = None, wait: Optional[bool] = None) -> Operation`
  Delete a resource by id, awaiting the async operation by default.
- `ensure(payload: Any, *, zone: Optional[str] = None, wait: Optional[bool] = None, update: bool = False) -> ModelT`
  Idempotent get-or-create: return the resource named in ``payload``.
- `find_by_name(name: str, *, zone: Optional[str] = None) -> Optional[ModelT]`
  Return the first resource whose name matches, or ``None``.
- `get(resource_id: str, *, zone: Optional[str] = None) -> ModelT`
  Fetch a single resource by id. Raises :class:`NotFoundError` if absent.
- `get_or_none(resource_id: str, *, zone: Optional[str] = None) -> Optional[ModelT]`
  Like :meth:`get` but returns ``None`` instead of raising on 404.
- `list(*, zone: Optional[str] = None, labels: Optional[dict] = None) -> List[ModelT]`
  Return all resources of this type in the target zone.
- `update(resource_id: str, payload: Any, *, zone: Optional[str] = None, wait: Optional[bool] = None) -> ModelT`
  Update a resource (HTTP ``PUT``) and return its settled state.

## IAM policy expression helpers (`exoscale_connector.iam_expr`)

- `and_(*expressions: str) -> str` — Combine expressions with ``&&``, parenthesised for safe nesting.
- `eq(field: str, value: str) -> str` — ``<field> == "<value>"`` with ``value`` safely quoted.
- `has(container: str, key: str) -> str` — ``<container>.has("<key>")`` — test for an optional field's presence.
- `ne(field: str, value: str) -> str` — ``<field> != "<value>"`` with ``value`` safely quoted.
- `operation_in(operations: Iterable[str]) -> str` — ``operation in ["a", "b", ...]`` with each operation safely quoted.
- `or_(*expressions: str) -> str` — Combine expressions with ``||``, parenthesised for safe nesting.
- `quote(value: str) -> str` — Return ``value`` as a double-quoted, escaped expression string literal.

## API surface by asset type

### `exoscale_connector.resources.anti_affinity_group`

Anti-Affinity Group resource client.

#### model `AntiAffinityGroup`

An Exoscale anti-affinity group.

| Python attribute | JSON key | Type |
|---|---|---|
| `id` | `id` | Optional[str] |
| `name` | `name` | Optional[str] |
| `description` | `description` | Optional[str] |
| `instances` | `instances` | List[Reference] |

#### client `AntiAffinityGroupClient`

Manage anti-affinity groups.

API collection: `anti-affinity-group`; resource model: `AntiAffinityGroup`.

Inherits the common operations (see above) plus the methods below, if any.


### `exoscale_connector.resources.api_key`

API key resource client.

#### model `ApiKey`

An Exoscale IAM API key.

| Python attribute | JSON key | Type |
|---|---|---|
| `key` | `key` | Optional[str] |
| `name` | `name` | Optional[str] |
| `role_id` | `role-id` | Optional[str] |
| `role` | `role` | Optional[Reference] |
| `secret` | `secret` | Optional[str] |

#### client `ApiKeyClient`

Manage Exoscale IAM API keys.

API collection: `api-key`; resource model: `ApiKey`; keyed by `key` instead of `id`.

Inherits the common operations (see above) plus the methods below, if any.


### `exoscale_connector.resources.block_volume`

Block storage volume resource client.

#### model `BlockVolume`

A block storage volume.

| Python attribute | JSON key | Type |
|---|---|---|
| `id` | `id` | Optional[str] |
| `name` | `name` | Optional[str] |
| `size` | `size` | Optional[int] |
| `state` | `state` | Optional[str] |
| `created_at` | `created-at` | Optional[str] |
| `blocksize` | `blocksize` | Optional[int] |
| `labels` | `labels` | Optional[Dict[str, str]] |
| `instance` | `instance` | Optional[Reference] |
| `snapshots` | `block-storage-snapshots` | Optional[List[BlockVolumeSnapshotRef]] |

#### model `BlockVolumeSnapshotRef`

Lightweight reference to a block-storage snapshot attached to a volume.

| Python attribute | JSON key | Type |
|---|---|---|
| `id` | `id` | Optional[str] |
| `name` | `name` | Optional[str] |

#### client `BlockVolumeClient`

Manage block storage volumes, including attach / detach / resize operations.

API collection: `block-storage`; resource model: `BlockVolume`.

Inherits the common operations (see above) plus the methods below, if any.

- `attach(volume_id: str, instance_id: str, *, zone: Optional[str] = None, wait: Optional[bool] = None) -> Operation`
  Attach a volume to a compute instance (async).
- `create_snapshot(volume_id: str, payload: object = None, *, zone: Optional[str] = None, wait: Optional[bool] = None) -> Operation`
  Trigger a snapshot of this volume via the instance-action endpoint.
- `detach(volume_id: str, *, zone: Optional[str] = None, wait: Optional[bool] = None) -> Operation`
  Detach a volume from its currently attached instance (async).
- `resize(volume_id: str, size: int, *, zone: Optional[str] = None, wait: Optional[bool] = None) -> Operation`
  Resize a volume to ``size`` GiB (async, size can only increase).

### `exoscale_connector.resources.block_volume_snapshot`

Block storage snapshot resource client.

#### model `BlockVolumeSnapshot`

A snapshot derived from a block storage volume.

| Python attribute | JSON key | Type |
|---|---|---|
| `id` | `id` | Optional[str] |
| `name` | `name` | Optional[str] |
| `size` | `size` | Optional[int] |
| `state` | `state` | Optional[str] |
| `created_at` | `created-at` | Optional[str] |
| `labels` | `labels` | Optional[Dict[str, str]] |

#### client `BlockVolumeSnapshotClient`

Manage block storage snapshots.

API collection: `block-storage-snapshot`; resource model: `BlockVolumeSnapshot`.

Inherits the common operations (see above) plus the methods below, if any.

- `create_from_volume(volume_id: str, *, zone: Optional[str] = None, wait: Optional[bool] = None) -> BlockVolumeSnapshot`
  Trigger a snapshot of the named block storage volume.
- `update(resource_id: str, payload: object, *, zone: Optional[str] = None, wait: Optional[bool] = None) -> BlockVolumeSnapshot`
  Update snapshot properties (e.g. name, labels) via ``PUT``.

### `exoscale_connector.resources.dbaas`

DBaaS (managed database) resource client.

#### model `DBaaSConnectionInfo`

Connection parameters embedded in a service detail response.

| Python attribute | JSON key | Type |
|---|---|---|
| `host` | `host` | Optional[str] |
| `port` | `port` | Optional[int] |
| `user` | `user` | Optional[str] |
| `dbname` | `dbname` | Optional[str] |
| `ca` | `ca` | Optional[str] |
| `uri` | `uri` | Optional[List[str]] |

#### model `DBaaSService`

An Exoscale managed database service.

| Python attribute | JSON key | Type |
|---|---|---|
| `name` | `name` | Optional[str] |
| `type` | `type` | Optional[str] |
| `plan` | `plan` | Optional[str] |
| `state` | `state` | Optional[str] |
| `node_count` | `node-count` | Optional[int] |
| `disk_size` | `disk-size` | Optional[int] |
| `ip_filter` | `ip-filter` | Optional[List[str]] |
| `created_at` | `created-at` | Optional[str] |
| `uri_params` | `uri-params` | Optional[DBaaSConnectionInfo] |
| `uri` | `uri` | Optional[str] |
| `connection_info` | `connection-info` | Optional[DBaaSConnectionInfo] |

#### client `DBaaSServiceClient`

Manage Exoscale DBaaS (managed database) services.

API collection: `dbaas-service`; resource model: `DBaaSService`; keyed by `name` instead of `id`.

Inherits the common operations (see above) plus the methods below, if any.

- `create(payload: Any, *, service_type: str, name: str, zone: Optional[str] = None, wait: Optional[bool] = None) -> DBaaSService`
  Create a managed database service and return it.
- `create_user(name: str, username: str, *, service_type: str, zone: Optional[str] = None) -> dict`
  Create a database user (``POST dbaas-{type}/{name}/user``).
- `delete_user(name: str, username: str, *, service_type: str, zone: Optional[str] = None) -> dict`
  Delete a database user (``DELETE dbaas-{type}/{name}/user/{username}``).
- `ensure(payload: Any, **kwargs: Any) -> DBaaSService`
  Not supported: DBaaS ``create`` needs ``service_type``/``name`` kwargs.
- `get(resource_id: str, *, zone: Optional[str] = None) -> DBaaSService`
  Fetch a DBaaS service by name.
- `get_connection_info(name: str, *, service_type: str, zone: Optional[str] = None) -> DBaaSService`
  Fetch the full service detail including ``connection-info`` and ``uri-params``.
- `list_service_types(*, zone: Optional[str] = None) -> List[dict]`
  Return available DBaaS service types from the ``dbaas-service-type`` endpoint.
- `reset_user_password(name: str, username: str, *, service_type: str, zone: Optional[str] = None) -> dict`
  Reset a user's password (``PUT .../user/{username}/password/reset``).
- `reveal_user_password(name: str, username: str, *, service_type: str, zone: Optional[str] = None) -> dict`
  Return the revealed credentials for a service user.
- `update(name: str, payload: Any, *, service_type: str, zone: Optional[str] = None, wait: Optional[bool] = None) -> DBaaSService`
  Update a service (``PUT dbaas-{type}/{name}``) and return its new state.

### `exoscale_connector.resources.dns`

DNS Domain and Record resource client.

#### model `DnsDomain`

An Exoscale DNS domain (zone).

| Python attribute | JSON key | Type |
|---|---|---|
| `id` | `id` | Optional[str] |
| `unicode_name` | `unicode-name` | Optional[str] |
| `state` | `state` | Optional[str] |
| `created_at` | `created-at` | Optional[str] |
| `updated_at` | `updated-at` | Optional[str] |

#### model `DnsRecord`

A single DNS record within a domain.

| Python attribute | JSON key | Type |
|---|---|---|
| `id` | `id` | Optional[str] |
| `name` | `name` | Optional[str] |
| `type` | `type` | Optional[str] |
| `content` | `content` | Optional[str] |
| `ttl` | `ttl` | Optional[int] |
| `priority` | `priority` | Optional[int] |

#### client `DnsDomainClient`

Manage DNS domains and their records.

API collection: `dns-domain`; resource model: `DnsDomain`.

Inherits the common operations (see above) plus the methods below, if any.

- `create_record(domain_id: str, record: object, *, zone: Optional[str] = None, wait: Optional[bool] = None) -> DnsRecord`
  Create a DNS record and return it once settled.
- `delete_record(domain_id: str, record_id: str, *, zone: Optional[str] = None, wait: Optional[bool] = None) -> Operation`
  Delete a DNS record and return the settled operation.
- `get_record(domain_id: str, record_id: str, *, zone: Optional[str] = None) -> DnsRecord`
  Fetch a single DNS record by its id.
- `list_records(domain_id: str, *, zone: Optional[str] = None) -> List[DnsRecord]`
  Return all records for a domain.
- `update_record(domain_id: str, record_id: str, payload: object, *, zone: Optional[str] = None, wait: Optional[bool] = None) -> DnsRecord`
  Update a DNS record (HTTP ``PUT``) and return its settled state.

### `exoscale_connector.resources.elastic_ip`

Elastic IP resource client.

#### model `ElasticIP`

An Exoscale Elastic IP (public address that can be re-assigned).

| Python attribute | JSON key | Type |
|---|---|---|
| `id` | `id` | Optional[str] |
| `ip` | `ip` | Optional[str] |
| `description` | `description` | Optional[str] |
| `addressfamily` | `addressfamily` | Optional[str] |
| `healthcheck` | `healthcheck` | Optional[ElasticIPHealthcheck] |
| `labels` | `labels` | Optional[Dict[str, str]] |

#### model `ElasticIPHealthcheck`

Optional healthcheck configuration attached to an Elastic IP.

| Python attribute | JSON key | Type |
|---|---|---|
| `mode` | `mode` | Optional[str] |
| `port` | `port` | Optional[int] |
| `uri` | `uri` | Optional[str] |
| `interval` | `interval` | Optional[int] |
| `timeout` | `timeout` | Optional[int] |
| `strikes_ok` | `strikes-ok` | Optional[int] |
| `strikes_fail` | `strikes-fail` | Optional[int] |
| `tls_sni` | `tls-sni` | Optional[str] |
| `tls_skip_verify` | `tls-skip-verify` | Optional[bool] |

#### client `ElasticIPClient`

Manage Exoscale Elastic IPs, including their reverse-DNS PTR record.

API collection: `elastic-ip`; resource model: `ElasticIP`.

Inherits the common operations (see above) plus the methods below, if any.

- `delete_reverse_dns(resource_id: str, *, zone: Optional[str] = None, wait: Optional[bool] = None) -> Operation`
  Remove the PTR record (``DELETE /reverse-dns/{kind}/{id}``).
- `get_reverse_dns(resource_id: str, *, zone: Optional[str] = None) -> Optional[str]`
  Return the PTR domain name for the resource, or ``None`` if unset.
- `set_reverse_dns(resource_id: str, domain_name: str, *, zone: Optional[str] = None, wait: Optional[bool] = None) -> Operation`
  Set the PTR record (``POST /reverse-dns/{kind}/{id}``).

### `exoscale_connector.resources.iam_role`

IAM role resource client.

#### enum `RuleAction`: `allow`, `deny`
Allowed values for :attr:`IAMPolicyRule.action`.

#### enum `ServiceStrategy`: `allow`, `deny`
Allowed values for :attr:`IAMPolicy.default_service_strategy`.

#### enum `ServiceType`: `allow`, `deny`, `rules`
Allowed values for :attr:`IAMPolicyService.type`.

#### model `IAMPolicy`

The inline policy attached to an IAM role.

| Python attribute | JSON key | Type |
|---|---|---|
| `default_service_strategy` | `default-service-strategy` | Optional[str] |
| `services` | `services` | Optional[Dict[str, IAMPolicyService]] |

#### model `IAMPolicyRule`

A single rule inside a service's rule list.

| Python attribute | JSON key | Type |
|---|---|---|
| `action` | `action` | Optional[str] |
| `expression` | `expression` | Optional[str] |
| `resources` | `resources` | Optional[List[str]] |

#### model `IAMPolicyService`

The policy block for one service class (e.g. ``compute``, ``sos``).

| Python attribute | JSON key | Type |
|---|---|---|
| `type` | `type` | Optional[str] |
| `rules` | `rules` | Optional[List[IAMPolicyRule]] |

#### model `IAMRole`

An Exoscale IAM role.

| Python attribute | JSON key | Type |
|---|---|---|
| `id` | `id` | Optional[str] |
| `name` | `name` | Optional[str] |
| `description` | `description` | Optional[str] |
| `editable` | `editable` | Optional[bool] |
| `permissions` | `permissions` | Optional[List[str]] |
| `labels` | `labels` | Optional[Dict[str, str]] |
| `policy` | `policy` | Optional[IAMPolicy] |
| `assume_role_policy` | `assume-role-policy` | Optional[IAMPolicy] |

#### client `IAMRoleClient`

Manage Exoscale IAM roles.

API collection: `iam-role`; resource model: `IAMRole`.

Inherits the common operations (see above) plus the methods below, if any.

- `set_assume_role_policy(role_id: str, policy: Union[IAMPolicy, dict], *, zone: Optional[str] = None, wait: Optional[bool] = None) -> Operation`
  Replace the assume-role policy.
- `set_policy(role_id: str, policy: Union[IAMPolicy, dict], *, zone: Optional[str] = None, wait: Optional[bool] = None) -> Operation`
  Replace the role's permission policy (``PUT /iam-role/{id}:policy``).

### `exoscale_connector.resources.iam_user`

IAM user (org member) resource client.

#### model `IAMUser`

An Exoscale IAM user (organization member).

| Python attribute | JSON key | Type |
|---|---|---|
| `id` | `id` | Optional[str] |
| `email` | `email` | Optional[str] |
| `role_id` | `role-id` | Optional[str] |
| `role` | `role` | Optional[Reference] |

#### client `IAMUserClient`

Manage Exoscale IAM users.

API collection: `user`; resource model: `IAMUser`.

Inherits the common operations (see above) plus the methods below, if any.


### `exoscale_connector.resources.instance`

Compute Instance resource client.

#### model `Instance`

An Exoscale compute instance.

| Python attribute | JSON key | Type |
|---|---|---|
| `id` | `id` | Optional[str] |
| `name` | `name` | Optional[str] |
| `state` | `state` | Optional[str] |
| `instance_type` | `instance-type` | Optional[Reference] |
| `template` | `template` | Optional[Reference] |
| `disk_size` | `disk-size` | Optional[int] |
| `public_ip` | `public-ip` | Optional[str] |
| `ipv6_address` | `ipv6-address` | Optional[str] |
| `ssh_key` | `ssh-key` | Optional[SshKeyReference] |
| `security_groups` | `security-groups` | List[Reference] |
| `labels` | `labels` | Optional[dict] |
| `manager` | `manager` | Optional[Reference] |
| `created_at` | `created-at` | Optional[str] |

#### model `SshKeyReference`

Lightweight SSH key reference (name-keyed, not id-keyed).

| Python attribute | JSON key | Type |
|---|---|---|
| `name` | `name` | Optional[str] |

#### client `InstanceClient`

Manage compute instances.

API collection: `instance`; resource model: `Instance`.

Inherits the common operations (see above) plus the methods below, if any.

- `delete_reverse_dns(resource_id: str, *, zone: Optional[str] = None, wait: Optional[bool] = None) -> Operation`
  Remove the PTR record (``DELETE /reverse-dns/{kind}/{id}``).
- `get_reverse_dns(resource_id: str, *, zone: Optional[str] = None) -> Optional[str]`
  Return the PTR domain name for the resource, or ``None`` if unset.
- `reboot(instance_id: str, *, zone: Optional[str] = None, wait: Optional[bool] = None) -> Operation`
  Reboot a running instance (``PUT instance/{id}:reboot``).
- `scale(instance_id: str, instance_type_id: str, *, zone: Optional[str] = None, wait: Optional[bool] = None) -> Operation`
  Change the instance's compute offering (``PUT instance/{id}:scale``).
- `set_reverse_dns(resource_id: str, domain_name: str, *, zone: Optional[str] = None, wait: Optional[bool] = None) -> Operation`
  Set the PTR record (``POST /reverse-dns/{kind}/{id}``).
- `start(instance_id: str, *, zone: Optional[str] = None, wait: Optional[bool] = None) -> Operation`
  Start a stopped instance.
- `stop(instance_id: str, *, zone: Optional[str] = None, wait: Optional[bool] = None) -> Operation`
  Stop a running instance gracefully (``PUT instance/{id}:stop``).

### `exoscale_connector.resources.instance_pool`

Instance Pool resource client.

#### model `InstancePool`

An Exoscale instance pool (autoscaling group of identical instances).

| Python attribute | JSON key | Type |
|---|---|---|
| `id` | `id` | Optional[str] |
| `name` | `name` | Optional[str] |
| `description` | `description` | Optional[str] |
| `state` | `state` | Optional[str] |
| `size` | `size` | Optional[int] |
| `instance_type` | `instance-type` | Optional[Reference] |
| `template` | `template` | Optional[Reference] |
| `disk_size` | `disk-size` | Optional[int] |
| `instance_prefix` | `instance-prefix` | Optional[str] |
| `ipv6_enabled` | `ipv6-enabled` | Optional[bool] |
| `public_ip_assignment` | `public-ip-assignment` | Optional[str] |
| `security_groups` | `security-groups` | List[Reference] |
| `private_networks` | `private-networks` | List[Reference] |
| `labels` | `labels` | Optional[dict] |
| `instances` | `instances` | List[Reference] |
| `anti_affinity_groups` | `anti-affinity-groups` | List[Reference] |
| `deploy_target` | `deploy-target` | Optional[Reference] |
| `ssh_key` | `ssh-key` | Optional[Reference] |
| `created_at` | `created-at` | Optional[str] |

#### client `InstancePoolClient`

Manage instance pools.

API collection: `instance-pool`; resource model: `InstancePool`.

Inherits the common operations (see above) plus the methods below, if any.

- `scale(pool_id: str, size: int, *, zone: Optional[str] = None, wait: Optional[bool] = None) -> Operation`
  Resize an instance pool to the given number of instances.

### `exoscale_connector.resources.instance_type`

Instance type (compute offering) resource client — read-only.

#### model `InstanceType`

A compute offering (CPU/memory size).

| Python attribute | JSON key | Type |
|---|---|---|
| `id` | `id` | Optional[str] |
| `family` | `family` | Optional[str] |
| `size` | `size` | Optional[str] |
| `cpus` | `cpus` | Optional[int] |
| `memory` | `memory` | Optional[int] |
| `gpus` | `gpus` | Optional[int] |
| `authorized` | `authorized` | Optional[bool] |

#### client `InstanceTypeClient`

List compute offerings (read-only — types are defined by Exoscale).

API collection: `instance-type`; resource model: `InstanceType`.

Inherits the common operations (see above) plus the methods below, if any.

- `find(slug: str, *, zone: Optional[str] = None) -> Optional[InstanceType]`
  Resolve a ``family.size`` slug (e.g. ``"standard.tiny"``) to a type.

### `exoscale_connector.resources.load_balancer`

Load Balancer resource client (includes service sub-resource management).

#### model `LoadBalancer`

An Exoscale Network Load Balancer and its services.

| Python attribute | JSON key | Type |
|---|---|---|
| `id` | `id` | Optional[str] |
| `name` | `name` | Optional[str] |
| `description` | `description` | Optional[str] |
| `ip` | `ip` | Optional[str] |
| `state` | `state` | Optional[str] |
| `labels` | `labels` | Optional[Dict[str, str]] |
| `services` | `services` | List[LoadBalancerService] |

#### model `LoadBalancerService`

A listener/backend service belonging to a Load Balancer.

| Python attribute | JSON key | Type |
|---|---|---|
| `id` | `id` | Optional[str] |
| `name` | `name` | Optional[str] |
| `description` | `description` | Optional[str] |
| `protocol` | `protocol` | Optional[str] |
| `port` | `port` | Optional[int] |
| `target_port` | `target-port` | Optional[int] |
| `strategy` | `strategy` | Optional[str] |
| `healthcheck_mode` | `healthcheck-mode` | Optional[str] |
| `healthcheck_port` | `healthcheck-port` | Optional[int] |
| `healthcheck_uri` | `healthcheck-uri` | Optional[str] |
| `healthcheck_interval` | `healthcheck-interval` | Optional[int] |
| `healthcheck_timeout` | `healthcheck-timeout` | Optional[int] |
| `healthcheck_retries` | `healthcheck-retries` | Optional[int] |
| `healthcheck_tls_sni` | `healthcheck-tls-sni` | Optional[str] |
| `state` | `state` | Optional[str] |

#### client `LoadBalancerClient`

Manage Exoscale Network Load Balancers and their services.

API collection: `load-balancer`; resource model: `LoadBalancer`.

Inherits the common operations (see above) plus the methods below, if any.

- `add_service(lb_id: str, service: object, *, zone: Optional[str] = None, wait: Optional[bool] = None) -> Operation`
  Add a service to a load balancer.
- `delete_service(lb_id: str, service_id: str, *, zone: Optional[str] = None, wait: Optional[bool] = None) -> Operation`
  Remove a service from a load balancer by service id.
- `update_service(lb_id: str, service_id: str, payload: object, *, zone: Optional[str] = None, wait: Optional[bool] = None) -> Operation`
  Update an existing service on a load balancer (HTTP PUT).

### `exoscale_connector.resources.object_storage`

Object Storage (SOS) resource client.

#### model `Bucket`

A single SOS bucket as returned by the S3 ListBuckets response.

| Python attribute | JSON key | Type |
|---|---|---|
| `name` | `name` | Optional[str] |
| `creation_date` | `creation-date` | Optional[str] |

#### model `S3Object`

A single object as returned by the S3 ListObjectsV2 response.

| Python attribute | JSON key | Type |
|---|---|---|
| `key` | `key` | Optional[str] |
| `size` | `size` | Optional[int] |
| `etag` | `etag` | Optional[str] |
| `storage_class` | `storage-class` | Optional[str] |
| `last_modified` | `last-modified` | Optional[str] |

#### client `BucketClient`

Manage Exoscale SOS buckets via the S3-compatible API.


- `create(name: str) -> None`
  Create a new bucket with the given *name*.
- `delete(name: str) -> None`
  Delete the bucket with the given *name*.
- `delete_object(bucket: str, key: str) -> None`
  Delete ``s3://bucket/key``.
- `download_file(bucket: str, key: str, path: str) -> None`
  Download an object to a local file with boto3's managed transfer.
- `exists(name: str) -> bool`
  Return ``True`` if the bucket exists and is accessible.
- `get_cors(bucket: str) -> Optional[List[dict]]`
  Return the bucket's CORS rules, or ``None`` if none are set.
- `get_lifecycle(bucket: str) -> Optional[List[dict]]`
  Return the bucket's lifecycle rules, or ``None`` if none are set.
- `get_object(bucket: str, key: str) -> bytes`
  Download ``s3://bucket/key`` and return its content as bytes.
- `list() -> List[Bucket]`
  Return all buckets visible to the configured credentials.
- `list_objects(bucket: str, *, prefix: Optional[str] = None, limit: Optional[int] = None) -> List[S3Object]`
  List objects in *bucket*, following continuation tokens.
- `presign_get(bucket: str, key: str, *, expires_in: int = 3600) -> str`
  Return a presigned download URL for ``s3://bucket/key``.
- `presign_put(bucket: str, key: str, *, expires_in: int = 3600) -> str`
  Return a presigned upload URL for ``s3://bucket/key``.
- `put_object(bucket: str, key: str, data: bytes, *, content_type: Optional[str] = None) -> None`
  Upload *data* (bytes) as ``s3://bucket/key``.
- `set_cors(bucket: str, rules: List[dict]) -> None`
  Replace the bucket's CORS rules (S3 ``CORSRules`` schema, verbatim).
- `set_lifecycle(bucket: str, rules: List[dict]) -> None`
  Replace the bucket's lifecycle rules (S3 ``Rules`` schema, verbatim).
- `upload_file(bucket: str, key: str, path: str) -> None`
  Upload a local file with boto3's managed (multipart-capable) transfer.

### `exoscale_connector.resources.private_network`

Private Network resource client.

#### model `PrivateNetwork`

An Exoscale Private Network (layer-2 segment within a zone).

| Python attribute | JSON key | Type |
|---|---|---|
| `id` | `id` | Optional[str] |
| `name` | `name` | Optional[str] |
| `description` | `description` | Optional[str] |
| `start_ip` | `start-ip` | Optional[str] |
| `end_ip` | `end-ip` | Optional[str] |
| `netmask` | `netmask` | Optional[str] |
| `labels` | `labels` | Optional[Dict[str, str]] |

#### client `PrivateNetworkClient`

Manage Exoscale Private Networks.

API collection: `private-network`; resource model: `PrivateNetwork`.

Inherits the common operations (see above) plus the methods below, if any.

- `attach_instance(network_id: str, instance_id: str, *, ip: Optional[str] = None, zone: Optional[str] = None, wait: Optional[bool] = None) -> Operation`
  Attach a compute instance to this private network.
- `detach_instance(network_id: str, instance_id: str, *, zone: Optional[str] = None, wait: Optional[bool] = None) -> Operation`
  Detach a compute instance from this private network.

### `exoscale_connector.resources.security_group`

Security Group resource client (reference implementation for all asset types).

#### model `SecurityGroup`

An Exoscale security group and its rules.

| Python attribute | JSON key | Type |
|---|---|---|
| `id` | `id` | Optional[str] |
| `name` | `name` | Optional[str] |
| `description` | `description` | Optional[str] |
| `rules` | `rules` | List[SecurityGroupRule] |
| `external_sources` | `external-sources` | Optional[List[str]] |

#### model `SecurityGroupRule`

A single ingress/egress rule belonging to a security group.

| Python attribute | JSON key | Type |
|---|---|---|
| `id` | `id` | Optional[str] |
| `description` | `description` | Optional[str] |
| `flow_direction` | `flow-direction` | Optional[str] |
| `protocol` | `protocol` | Optional[str] |
| `start_port` | `start-port` | Optional[int] |
| `end_port` | `end-port` | Optional[int] |
| `network` | `network` | Optional[str] |
| `security_group` | `security-group` | Optional[Reference] |

#### client `SecurityGroupClient`

Manage security groups and their rules.

API collection: `security-group`; resource model: `SecurityGroup`.

Inherits the common operations (see above) plus the methods below, if any.

- `add_rule(security_group_id: str, rule: object, *, zone: Optional[str] = None, wait: Optional[bool] = None) -> Operation`
  Append a rule to a security group.
- `delete_rule(security_group_id: str, rule_id: str, *, zone: Optional[str] = None, wait: Optional[bool] = None) -> Operation`
  Remove a single rule from a security group by rule id.

### `exoscale_connector.resources.sks`

SKS (Scalable Kubernetes Service) resource client.

#### model `SksCluster`

An Exoscale SKS (managed Kubernetes) cluster.

| Python attribute | JSON key | Type |
|---|---|---|
| `id` | `id` | Optional[str] |
| `name` | `name` | Optional[str] |
| `description` | `description` | Optional[str] |
| `state` | `state` | Optional[str] |
| `version` | `version` | Optional[str] |
| `endpoint` | `endpoint` | Optional[str] |
| `cni` | `cni` | Optional[str] |
| `service_level` | `service-level` | Optional[str] |
| `addons` | `addons` | Optional[List[str]] |
| `nodepools` | `nodepools` | List[SksNodepool] |
| `labels` | `labels` | Optional[Dict[str, str]] |
| `auto_upgrade` | `auto-upgrade` | Optional[bool] |
| `created_at` | `created-at` | Optional[str] |

#### model `SksNodepool`

A pool of worker nodes within an SKS cluster.

| Python attribute | JSON key | Type |
|---|---|---|
| `id` | `id` | Optional[str] |
| `name` | `name` | Optional[str] |
| `description` | `description` | Optional[str] |
| `size` | `size` | Optional[int] |
| `state` | `state` | Optional[str] |
| `instance_type` | `instance-type` | Optional[Reference] |
| `template` | `template` | Optional[Reference] |
| `instance_pool` | `instance-pool` | Optional[Reference] |
| `disk_size` | `disk-size` | Optional[int] |
| `security_groups` | `security-groups` | Optional[List[Reference]] |
| `anti_affinity_groups` | `anti-affinity-groups` | Optional[List[Reference]] |
| `private_networks` | `private-networks` | Optional[List[Reference]] |
| `labels` | `labels` | Optional[Dict[str, str]] |
| `taints` | `taints` | Optional[Dict[str, str]] |
| `instance_prefix` | `instance-prefix` | Optional[str] |
| `public_ip_assignment` | `public-ip-assignment` | Optional[str] |

#### client `SksClusterClient`

Manage SKS clusters and their nodepools.

API collection: `sks-cluster`; resource model: `SksCluster`.

Inherits the common operations (see above) plus the methods below, if any.

- `create_nodepool(cluster_id: str, payload: object, *, zone: Optional[str] = None, wait: Optional[bool] = None) -> Operation`
  Add a nodepool to an existing cluster.
- `delete_nodepool(cluster_id: str, nodepool_id: str, *, zone: Optional[str] = None, wait: Optional[bool] = None) -> Operation`
  Delete a nodepool from a cluster.
- `generate_kubeconfig(cluster_id: str, payload: object, *, zone: Optional[str] = None) -> dict`
  Request a new kubeconfig for a cluster.
- `get_nodepool(cluster_id: str, nodepool_id: str, *, zone: Optional[str] = None) -> SksNodepool`
  Fetch a single nodepool by id.
- `list_nodepools(cluster_id: str, *, zone: Optional[str] = None) -> List[SksNodepool]`
  Return all nodepools belonging to a cluster.
- `list_versions(*, zone: Optional[str] = None) -> List[str]`
  Return the Kubernetes versions a new SKS cluster may be created with.
- `update_nodepool(cluster_id: str, nodepool_id: str, payload: object, *, zone: Optional[str] = None, wait: Optional[bool] = None) -> Operation`
  Update a nodepool (PUT).

### `exoscale_connector.resources.snapshot`

Compute snapshot resource client.

#### model `Snapshot`

A compute snapshot (disk image captured from a running or stopped instance).

| Python attribute | JSON key | Type |
|---|---|---|
| `id` | `id` | Optional[str] |
| `name` | `name` | Optional[str] |
| `size` | `size` | Optional[int] |
| `state` | `state` | Optional[str] |
| `created_at` | `created-at` | Optional[str] |
| `instance` | `instance` | Optional[Reference] |
| `export` | `export` | Optional[SnapshotExport] |

#### model `SnapshotExport`

Export metadata returned after a snapshot is exported to object storage.

| Python attribute | JSON key | Type |
|---|---|---|
| `md5sum` | `md5sum` | Optional[str] |
| `presigned_url` | `presigned-url` | Optional[str] |

#### client `SnapshotClient`

Manage compute snapshots.

API collection: `snapshot`; resource model: `Snapshot`.

Inherits the common operations (see above) plus the methods below, if any.

- `create_from_instance(instance_id: str, *, zone: Optional[str] = None, wait: Optional[bool] = None) -> Snapshot`
  Trigger a snapshot of the named instance and return the new snapshot.
- `export(snapshot_id: str, *, zone: Optional[str] = None, wait: Optional[bool] = None) -> Operation`
  Export a snapshot to object storage (async).

### `exoscale_connector.resources.ssh_key`

SSH key resource client.

#### model `SSHKey`

An Exoscale SSH public key.

| Python attribute | JSON key | Type |
|---|---|---|
| `name` | `name` | Optional[str] |
| `fingerprint` | `fingerprint` | Optional[str] |
| `public_key` | `public-key` | Optional[str] |

#### client `SSHKeyClient`

Manage Exoscale SSH keys.

API collection: `ssh-key`; resource model: `SSHKey`; keyed by `name` instead of `id`.

Inherits the common operations (see above) plus the methods below, if any.


### `exoscale_connector.resources.template`

Compute template resource client.

#### model `Template`

A compute template (boot image).

| Python attribute | JSON key | Type |
|---|---|---|
| `id` | `id` | Optional[str] |
| `name` | `name` | Optional[str] |
| `description` | `description` | Optional[str] |
| `family` | `family` | Optional[str] |
| `version` | `version` | Optional[str] |
| `size` | `size` | Optional[int] |
| `visibility` | `visibility` | Optional[str] |
| `url` | `url` | Optional[str] |
| `checksum` | `checksum` | Optional[str] |
| `boot_mode` | `boot-mode` | Optional[str] |
| `default_user` | `default-user` | Optional[str] |
| `ssh_key_enabled` | `ssh-key-enabled` | Optional[bool] |
| `password_enabled` | `password-enabled` | Optional[bool] |
| `build` | `build` | Optional[str] |
| `created_at` | `created-at` | Optional[str] |

#### client `TemplateClient`

List, register and delete compute templates.

API collection: `template`; resource model: `Template`.

Inherits the common operations (see above) plus the methods below, if any.

- `find_linux(*, zone: Optional[str] = None) -> Optional[Template]`
  Return the smallest public Linux template in the zone, or ``None``.
- `list(*, zone: Optional[str] = None, labels: Optional[dict] = None, visibility: Optional[str] = None) -> List[Template]`
  List templates, optionally filtered by ``visibility``.

### `exoscale_connector.resources.zone`

Zone resource client (read-only).

#### model `Zone`

An Exoscale zone (e.g. ``de-fra-1``).

| Python attribute | JSON key | Type |
|---|---|---|
| `name` | `name` | Optional[str] |
| `api_endpoint` | `api-endpoint` | Optional[str] |

#### client `ZoneClient`

List Exoscale zones.

API collection: `zone`; resource model: `Zone`; keyed by `name` instead of `id`.

Inherits the common operations (see above) plus the methods below, if any.


## Asset-type reference pages (live-verified)

### Asset type reference

One page per asset type the connector supports. Every page has the same six
sections — **Overview**, **Model**, **CLI**, **Library**, **Gotchas**,
**End-to-end example** — and is backed by a passing
[live test](../live-test-plan.md).

If something on a page contradicts the live behaviour, the live test is the
source of truth — open an issue and the page will be corrected.

#### Capability matrix

| Asset type | CLI binary | Live-tested | Tier |
|---|---|---|---|
| [security-group (+rules)](security-group.md) | `exoscale-security-group` | ✅ | 1 |
| [private-network](private-network.md) | `exoscale-private-network` | ✅ | 1 |
| [anti-affinity-group](anti-affinity-group.md) | `exoscale-anti-affinity-group` | ✅ | 1 |
| [ssh-key](ssh-key.md) | `exoscale-ssh-key` | ✅ | 1 |
| [iam-role](iam-role.md) | `exoscale-iam-role` | ✅ | 1 |
| [iam-user](iam-user.md) | `exoscale-iam-user` | read-only | — |
| [api-key](api-key.md) | `exoscale-api-key` | ✅ (gated) | 1 (opt-in, `EXOSCALE_TEST_TIER_1_API_KEY=1`) |
| [dns (domain + records)](dns.md) | `exoscale-dns` | ✅ | 1 |
| [elastic-ip](elastic-ip.md) | `exoscale-elastic-ip` | ✅ | 2 |
| [object-storage bucket](object-storage.md) | `exoscale-bucket` | ✅ | 2 |
| [block-volume](block-volume.md) | `exoscale-block-volume` | ✅ create/snapshot/delete (Tier 2); attach/detach (Tier 3); resize endpoint+method verified, size-change assertion self-skips on tenant quota | 2/3 |
| [block-volume-snapshot](block-volume-snapshot.md) | `exoscale-block-volume-snapshot` | ✅ | 2 |
| [instance (+lifecycle)](instance.md) | `exoscale-instance` | ✅ | 3 |
| [instance-pool (+scale)](instance-pool.md) | `exoscale-instance-pool` | ✅ | 3 |
| [snapshot (compute)](snapshot.md) | `exoscale-snapshot` | ✅ create/list/get/export/delete | 3 |
| [load-balancer (+services)](load-balancer.md) | `exoscale-load-balancer` | ✅ | 4 |
| [dbaas](dbaas.md) | `exoscale-dbaas` | ✅ | 4 |
| [sks (cluster + nodepool)](sks.md) | `exoscale-sks` | ✅ | 4 |
| [zone](zone.md) | `exoscale-zone` | ✅ read-only (list/get) | 0 |
| [template](template.md) | `exoscale-template` | ✅ | 0/1 |
| [instance-type](instance-type.md) | `exoscale-instance-type` | ✅ read-only (list/find-slug) | 0 |

Instance scale, reverse DNS, SOS objects, DBaaS users/update, and `ensure()`
were all live-verified in the 2026-06-10 extensions validation run (see
[live-test-results.md](../live-test-results.md)). Three spec-vs-reality
divergences were found and fixed during that run.

#### Page template

```
# <asset-type>
Overview — one paragraph.
## Model
Field table from the pydantic model.
## CLI
Every subcommand with a copy-pasteable example invocation.
## Library
Python snippet for each operation.
## Gotchas
Caveats verified by the live test (e.g. unit-of-measure quirks,
required-but-undocumented fields, quota constraints).
## End-to-end example
The full lifecycle distilled from the corresponding live test.
```

#### Conventions used on every page

- **Authentication** is always env-based: `EXOSCALE_API_KEY` /
  `EXOSCALE_API_SECRET` / `EXOSCALE_ZONE`. Inject with your secret manager
  (HashiCorp Vault, Infisical, Doppler, …); the connector reads only env vars.
- **JSON output** from CLIs goes to stdout; errors to stderr; exit 0 on
  success, 1 on API/connector error, 2 on argparse error.
- **All resources** are pydantic v2 models with snake_case attributes that
  auto-map to the API's kebab-case JSON keys (e.g. `flow_direction` ↔
  `flow-direction`). Unknown server fields pass through (`extra="allow"`),
  so the connector keeps working when the API adds fields ahead of the model.
- **Async operations** are awaited by default — pass `wait=False` to return
  the operation object without polling.

### anti-affinity-group

A scheduling hint telling Exoscale's placement engine that the instances
assigned to this group should be spread across distinct physical hosts. Used
to maximise availability for replica sets (e.g. a 3-node etcd cluster, or an
HA database).

#### Model

```python
class AntiAffinityGroup(ExoscaleModel):
    id: Optional[str]
    name: Optional[str]
    description: Optional[str]
    instances: Optional[List[Reference]]   # members; populated on detail responses
```

#### CLI

```bash
exoscale-anti-affinity-group list
exoscale-anti-affinity-group get --id <uuid>
exoscale-anti-affinity-group find --name <name>
exoscale-anti-affinity-group create --json '{"name": "etcd-aag", "description": "etcd replica anti-affinity"}'
exoscale-anti-affinity-group delete --id <uuid>
```

#### Library

```python
from exoscale_connector import ExoscaleClient
from exoscale_connector.resources.anti_affinity_group import AntiAffinityGroupClient

aag = AntiAffinityGroupClient(ExoscaleClient.from_env(zone="de-fra-1"))

group = aag.create({"name": "etcd-aag", "description": "etcd anti-affinity"})
fetched = aag.get(group.id)
aag.delete(group.id)
```

#### Gotchas

- **No `update` endpoint.** The API does not support modifying an AAG in
  place — `update()` is intentionally not exposed on this client. To change
  anything, delete and recreate.
- **Members are read-only here.** Instances are assigned via the
  *instance* create/update endpoint by including the AAG id in the
  instance's `anti-affinity-groups` array.

#### End-to-end example

Distilled from
[`tests/integration/test_tier_1.py::test_anti_affinity_group_lifecycle`](../../tests/integration/test_tier_1.py):

```python
from exoscale_connector import ExoscaleClient
from exoscale_connector.resources.anti_affinity_group import AntiAffinityGroupClient

aag = AntiAffinityGroupClient(ExoscaleClient.from_env(zone="de-fra-1"))

group = aag.create({"name": "etcd-aag", "description": "tier-1 smoke"})
assert aag.get(group.id).name == "etcd-aag"
assert aag.find_by_name("etcd-aag").id == group.id
aag.delete(group.id)
```

### api-key

A scoped credential bound to an IAM role at creation time. The role
determines what the key can do; the key's name/identifier on the wire is
its **key id** (an API-generated string), and the **secret is returned
exactly once** on the create response — there is no way to re-fetch it.

#### Model

```python
class ApiKey(ExoscaleModel):
    key: Optional[str]        # API key id (the public part, used in URL paths)
    name: Optional[str]
    role_id: Optional[str]    # bound role's id
    role: Optional[Reference]
    secret: Optional[str]     # PRESENT ONLY ON CREATE — never returned later
```

#### CLI

```bash
exoscale-api-key list
exoscale-api-key get --id <key-id>
exoscale-api-key find --name <name>
exoscale-api-key create --json '{"name": "ci-bot", "role-id": "<role-uuid>"}'
exoscale-api-key delete --id <key-id>
```

#### Library

```python
from exoscale_connector import ExoscaleClient
from exoscale_connector.resources.api_key import ApiKeyClient

keys = ApiKeyClient(ExoscaleClient.from_env(zone="de-fra-1"))

created = keys.create({"name": "ci-bot", "role-id": "<role-uuid>"})
# Capture the secret IMMEDIATELY — it is not retrievable later.
secret = created.secret
key_id = created.key

# Listing / fetching never returns the secret.
for existing in keys.list():
    print(existing.key, existing.name)

keys.delete(key_id)
```

#### Gotchas

- **`secret` is one-shot.** The first response after `create` is the only
  time the secret is visible. Persist it to a vault immediately; never log it.
  As a guard, `repr()` of an `ApiKey` masks the secret — but it is still
  present in `model_dump()` and in the CLI's `create` JSON output (that is
  your one chance to capture it).
- **`id_field = "key"`** — the path token is the `key` field, not a uuid in
  `id`. `keys.get("EXO...")` calls `GET /api-key/EXO...`.
- **`update` not exposed.** The API does not allow rotating a key in place —
  delete and create a new one.
- **Tier 1 live test for create is gated separately** (off by default) so
  the standard Tier 1 run never produces a stray secret-bearing response.
  Enable with `EXOSCALE_TEST_TIER_1_API_KEY=1`.

#### End-to-end example

```python
from exoscale_connector import ExoscaleClient
from exoscale_connector.resources.api_key import ApiKeyClient
from exoscale_connector.resources.iam_role import IAMPolicy, IAMRole, IAMRoleClient

client = ExoscaleClient.from_env(zone="de-fra-1")
roles = IAMRoleClient(client)
keys = ApiKeyClient(client)

# Bind to a minimal role created just for this key.
role = roles.create(IAMRole(
    name="ci-bot-role",
    policy=IAMPolicy(default_service_strategy="deny", services={}),
))

created = keys.create({"name": "ci-bot", "role-id": role.id})
assert created.secret, "secret must be returned exactly once"
# Hand `created.key` / `created.secret` to your vault here.

# Teardown
keys.delete(created.key)
roles.delete(role.id)
```

### block-volume-snapshot

A point-in-time snapshot of a block-volume. Created via the parent
volume's `:create-snapshot` action; can be deleted independently, or used
to restore data (recreate the volume from the snapshot).

#### Model

```python
class BlockVolumeSnapshot(ExoscaleModel):
    id: Optional[str]
    name: Optional[str]
    size: Optional[int]                   # GiB (matches the source volume size at snapshot time)
    state: Optional[str]                  # "snapshotting" -> "created"
    created_at: Optional[str]
    labels: Optional[Dict[str, str]]
```

#### CLI

```bash
exoscale-block-volume-snapshot list
exoscale-block-volume-snapshot get --id <uuid>
exoscale-block-volume-snapshot find --name <name>
exoscale-block-volume-snapshot delete --id <uuid>
```

> Snapshots are created via the parent volume's `create_snapshot` method,
> not via a top-level `create` (the API does not support
> `POST /block-storage-snapshot` directly).

#### Library

```python
from exoscale_connector import ExoscaleClient
from exoscale_connector.resources.block_volume import BlockVolumeClient
from exoscale_connector.resources.block_volume_snapshot import (
    BlockVolumeSnapshotClient,
)

client = ExoscaleClient.from_env(zone="de-fra-1")
volumes = BlockVolumeClient(client)
snapshots = BlockVolumeSnapshotClient(client)

# Create via the parent volume
op = volumes.create_snapshot(volume_id)
snap_id = op.reference_id

# Read
snap = snapshots.get(snap_id)
all_snaps = snapshots.list()

# Update / delete
snapshots.update(snap_id, {"labels": {"keep": "yes"}})
snapshots.delete(snap_id)

# Create a NEW volume from this snapshot (use BlockVolumeSnapshotClient.create_from_volume
# on the snapshot client to restore — see the connector source for the exact payload)
```

#### Gotchas

- **`list_key` is `block-storage-snapshots`** (note this differs from the
  block-volume `list_key`, `block-storage-volumes`).
- **Cannot delete the parent volume while snapshots exist** in some account
  configurations — delete snapshots first as a defensive default.
- **Snapshots are point-in-time, not application-consistent**: filesystem
  cache / DB transactions in flight are not flushed by the snapshot itself.
  For consistent backups, quiesce the workload first.

#### End-to-end example

Distilled from
[`tests/integration/test_tier_2.py::test_block_volume_lifecycle`](../../tests/integration/test_tier_2.py)
(snapshot covered inside the volume lifecycle):

```python
from exoscale_connector import ExoscaleClient
from exoscale_connector.resources.block_volume import BlockVolumeClient
from exoscale_connector.resources.block_volume_snapshot import (
    BlockVolumeSnapshotClient,
)

client = ExoscaleClient.from_env(zone="de-fra-1")
volumes = BlockVolumeClient(client)
snapshots = BlockVolumeSnapshotClient(client)

vol = volumes.create({"name": "data", "size": 10})

op = volumes.create_snapshot(vol.id)
snap_id = op.reference_id
assert snapshots.get(snap_id).id == snap_id

snapshots.delete(snap_id)
volumes.delete(vol.id)
```

### block-volume

Persistent block storage that can be attached to a single compute instance
at a time. Created in a zone, attached/detached online, resizable upward
only — and **only while attached to a running instance**. Volumes are
encrypted at rest by default.

#### Model

```python
class BlockVolumeSnapshotRef(ExoscaleModel):
    id: Optional[str]
    name: Optional[str]


class BlockVolume(ExoscaleModel):
    id: Optional[str]
    name: Optional[str]
    description: Optional[str]
    size: Optional[int]                          # GiB (on get); see resize gotcha
    state: Optional[str]                         # "detached" | "attached"
    created_at: Optional[str]
    blocksize: Optional[int]                     # typically 4096
    labels: Optional[Dict[str, str]]
    instance: Optional[Reference]                # the attached instance, when state == "attached"
    snapshots: Optional[List[BlockVolumeSnapshotRef]]
```

#### CLI

```bash
exoscale-block-volume list
exoscale-block-volume get --id <uuid>
exoscale-block-volume find --name <name>
exoscale-block-volume create --json '{"name": "data", "size": 10}'
exoscale-block-volume delete --id <uuid>
```

> attach / detach / resize / create-snapshot are exposed via the library
> client (and a future CLI update can add subcommands for them).

#### Library

```python
from exoscale_connector import ExoscaleClient
from exoscale_connector.resources.block_volume import BlockVolumeClient

volumes = BlockVolumeClient(ExoscaleClient.from_env(zone="de-fra-1"))

# Create (size in GiB)
vol = volumes.create({"name": "data", "size": 10})

# Online ops — these require the volume to be attached to a running instance
volumes.attach(vol.id, instance_id)
volumes.resize(vol.id, 20)       # GiB; see gotcha
op = volumes.create_snapshot(vol.id)   # snapshot id is op.reference_id
volumes.detach(vol.id)

volumes.delete(vol.id)
```

#### Gotchas

- **`list_key` is `block-storage-volumes`, NOT `block-storages`.** Caught
  by the Tier 2 live test; originally wrong.
- **`resize` endpoint is `:resize-volume`**, not `:resize` and not the plain
  `PUT /block-storage/{id}` (which accepts the size field on the wire but
  silently drops it). Caught and fixed by the Tier 3 live test.
- **Resize `size` is in BYTES on the wire**, despite the OpenAPI spec
  documenting GiB. The connector takes GiB from callers and converts to
  bytes internally so the caller-facing unit stays consistent with `create`
  and `get`.
- **Resize is online-only**: the volume must be attached to a running
  instance for the size change to propagate. A resize on a detached
  volume returns a success-looking response but the size never changes.
- **Resize only grows**: can't shrink a volume.
- **Attach requires `standard.small` or larger** — `standard.tiny` is
  rejected with `409: Instance size must be at least small`.
- **Delete fails (412) if attached**: detach first. The Tier 3 test wraps
  the tracker deleter in a safe-delete helper that detaches first if the
  volume is still attached when teardown runs.
- **Volume → tenant quota**: many accounts have a low total
  block-storage quota. Plan capacity before running tests that resize.

#### End-to-end example

Distilled from
[`tests/integration/test_tier_2.py::test_block_volume_lifecycle`](../../tests/integration/test_tier_2.py)
(detached) and
[`tests/integration/test_tier_3.py::test_block_volume_online_lifecycle`](../../tests/integration/test_tier_3.py)
(online ops):

```python
from exoscale_connector import ExoscaleClient
from exoscale_connector.resources.block_volume import BlockVolumeClient
from exoscale_connector.resources.block_volume_snapshot import BlockVolumeSnapshotClient

client = ExoscaleClient.from_env(zone="de-fra-1")
volumes = BlockVolumeClient(client)
snapshots = BlockVolumeSnapshotClient(client)

# 1. Create (smallest is 10 GiB)
vol = volumes.create({"name": "data", "size": 10})
assert volumes.get(vol.id).state == "detached"

# 2. Snapshot (no attached instance required for snapshot)
op = volumes.create_snapshot(vol.id)
snap_id = op.reference_id

# 3. Online operations need an attached instance (size >= standard.small)
volumes.attach(vol.id, instance_id)
volumes.resize(vol.id, 20)         # GiB; wait+poll for vol.size to change
volumes.detach(vol.id)

# 4. Cleanup
snapshots.delete(snap_id)
volumes.delete(vol.id)
```

### dbaas (managed databases)

Exoscale's Aiven-backed managed database service. Supports Postgres (`pg`),
MySQL (`mysql`), Valkey/Redis (`valkey`), OpenSearch (`opensearch`), Kafka
(`kafka`), Grafana (`grafana`), Thanos (`thanos`). **Services are
identified by name, not UUID.**

#### Model

```python
class DBaaSConnectionInfo(ExoscaleModel):
    host: Optional[str]
    port: Optional[int]
    user: Optional[str]
    dbname: Optional[str]
    ca: Optional[str]              # PEM-encoded TLS CA cert (single string)
    # The wire field is a LIST of URIs for Postgres (primary + replicas).
    # Other service types may differ — this is the most general shape.
    uri: Optional[List[str]]


class DBaaSService(ExoscaleModel):
    name: Optional[str]            # the unique identifier (used in URL paths)
    type: Optional[str]            # short form: "pg", "mysql", "valkey", ...
    plan: Optional[str]            # e.g. "hobbyist-2", "startup-4"
    state: Optional[str]           # "rebuilding" -> "running"
    node_count: Optional[int]
    disk_size: Optional[int]
    ip_filter: Optional[List[str]]  # allowed CIDRs; absent/empty = allow-all
    created_at: Optional[str]
    uri_params: Optional[DBaaSConnectionInfo]
    uri: Optional[str]
    connection_info: Optional[DBaaSConnectionInfo]
```

#### CLI

The DBaaS CLI keeps bespoke verbs (built on the shared CLI plumbing) because
`create` needs `--type` and `--name` separately from the JSON body, and services
are addressed by name rather than id:

```bash
exoscale-dbaas list
exoscale-dbaas get --name <name>
exoscale-dbaas create --type pg --name my-pg-1 --json '{"plan": "hobbyist-2"}'
exoscale-dbaas delete --name <name>
```

#### Library

```python
from exoscale_connector import ExoscaleClient
from exoscale_connector.resources.dbaas import DBaaSServiceClient

dbaas = DBaaSServiceClient(ExoscaleClient.from_env(zone="de-fra-1"))

# Discover the cheapest plan and create a service
plan = "hobbyist-2"
dbaas.create({"plan": plan}, service_type="pg", name="my-pg-1")

# Lock the service down to an IP allow-list (CIDRs). DBaaS can't join a
# private network, so ip-filter + TLS is the primary way to secure it.
dbaas.create(
    {"plan": plan, "ip-filter": ["203.0.113.0/24"]},
    service_type="pg",
    name="app-pg",
)
# Tighten or widen it later via update (replaces the whole list):
dbaas.update("app-pg", {"ip-filter": ["203.0.113.0/24", "198.51.100.7/32"]}, service_type="pg")

# Fetch (two-step lookup: list -> discover type -> type-specific GET)
svc = dbaas.get("my-pg-1")
print(svc.state)

# Connection info (host/port/user/uri); never log/print the values.
conn = dbaas.get_connection_info("my-pg-1", service_type="pg")
host = conn.uri_params.host

# Reveal user password — single-shot reveal endpoint
pw_response = dbaas.reveal_user_password("my-pg-1", "avnadmin", service_type="pg")
password = pw_response["password"]

dbaas.delete("my-pg-1")

# Helpers
plans = dbaas.list_service_types()
```

#### Gotchas

- **Short/long type names mismatch.** The API lists service types with
  *short* names (`pg`, `valkey`) but URL paths use *long* names
  (`postgres`). The connector keeps an alias map; callers may pass either.
  The only real mismatch in current use is `pg → postgres`.
- **`GET /dbaas-service/<name>` is list-only.** The generic collection
  path returns 404 on individual item GETs. The connector overrides
  `get()` to do a two-step lookup: list to discover the type, then fetch
  the detail body via the type-specific `dbaas-<long-type>/<name>` path.
- **`DELETE /dbaas-service/<name>` IS valid** (delete uses the generic
  path even though GET doesn't). Same path, different methods. ¯\\_(ツ)_/¯
- **`connection-info.uri` is a LIST**, not a string. Postgres returns
  multiple endpoint URIs (primary + replicas). The model field reflects
  this.
- **There are TWO `uri` fields with different shapes.** `DBaaSService.uri`
  is a scalar `Optional[str]` — the canonical hostname-based URI for the
  service. `DBaaSConnectionInfo.uri` (nested inside `connection_info`) is
  `Optional[List[str]]` — the per-endpoint URIs, typically IP-based, one
  per node. Both are populated by the live API; they are not duplicates.
- **Provisioning takes 5–15 minutes** on the cheapest plans; longer on
  larger plans. Use a generous timeout in `wait_for_state`.
- **The create response carries no `reference`** — the connector
  re-fetches from the type-specific path it just hit. Live test registers
  cleanup BEFORE create to avoid orphan leakage if the re-fetch fails.
- **`reveal_user_password` returns a raw `dict`, not a typed model.** The
  response shape is type-specific (Postgres has `password`, MySQL/Valkey
  may carry additional fields like ports). Keeping it as a dict avoids
  forcing a tight schema that varies per backend.
- **`ip-filter` is a typed field (`DBaaSService.ip_filter`) and your main
  security lever.** It's a list of CIDR strings, e.g. `["203.0.113.0/24"]`,
  settable through the create/update payload (wire key `ip-filter`) and read
  back as `svc.ip_filter`. **A managed DB can't join a private network**, so an
  `ip-filter` allow-list plus TLS (the CA cert lives in `connection_info.ca`)
  is the primary way to keep the service from being reachable by the whole
  internet. `update` **replaces** the entire list rather than merging, so pass
  the full set each time. An empty/absent filter means *allow all*.

#### End-to-end example

Distilled from
[`tests/integration/test_tier_4.py::test_dbaas_pg_lifecycle`](../../tests/integration/test_tier_4.py):

```python
from exoscale_connector import ExoscaleClient
from exoscale_connector.resources.dbaas import DBaaSServiceClient
from tests.integration._fixtures import resolve_cheapest_dbaas_plan, wait_for_state

client = ExoscaleClient.from_env(zone="de-fra-1")
dbaas = DBaaSServiceClient(client)

plan = resolve_cheapest_dbaas_plan(client, "pg")
name = "demo-pg-1"

dbaas.create({"plan": plan}, service_type="pg", name=name)
wait_for_state(lambda: dbaas.get(name), "running", timeout=1800, interval=15)

# Connection info (don't print the values)
conn = dbaas.get_connection_info(name, service_type="pg")
assert conn.uri_params.host and conn.uri_params.port
assert conn.connection_info.uri  # list of URIs

# Reveal admin password (single-shot endpoint)
pw = dbaas.reveal_user_password(name, "avnadmin", service_type="pg")
assert pw["password"]

dbaas.delete(name)
```

#### Service updates and user management

`update()`, `create_user()` and `delete_user()` are live-verified (extended
Tier 4 pg lifecycle, 2026-06-10). `reset_user_password()` is the one method
still implemented from the API reference only — the live test doesn't call
it (resetting `avnadmin`'s password mid-test would be disruptive for no
extra wire-shape coverage):

```python
# Plan change / maintenance window / type-specific settings.
dbaas.update(name, {"maintenance": {"dow": "sunday", "time": "04:00:00"}}, service_type="pg")

# Users — passwords are never returned by create/reset; fetch them
# explicitly (and treat them as secrets).
dbaas.create_user(name, "analyst", service_type="pg")
dbaas.reset_user_password(name, "analyst", service_type="pg")
secret = dbaas.reveal_user_password(name, "analyst", service_type="pg")
dbaas.delete_user(name, "analyst", service_type="pg")
```

`ensure()` is **not** supported for DBaaS (create needs `service_type`/`name`
kwargs) — use `get_or_none(name)` + `create(...)` explicitly.

### dns (domain + records)

A DNS zone in your Exoscale account plus the records it contains. The zone
is the parent resource (a *domain*) and records are sub-resources accessed
via `/dns-domain/<id>/record/...`.

#### Model

```python
class DnsDomain(ExoscaleModel):
    id: Optional[str]
    unicode_name: Optional[str]   # the zone's human name (e.g. "example.com")
    state: Optional[str]
    created_at: Optional[str]
    updated_at: Optional[str]


class DnsRecord(ExoscaleModel):
    id: Optional[str]
    name: Optional[str]           # subdomain (e.g. "www"); "" or "@" for apex
    type: Optional[str]           # "A" | "AAAA" | "CNAME" | "MX" | "TXT" | ...
    content: Optional[str]        # IP, hostname, text, ...
    ttl: Optional[int]
    priority: Optional[int]       # used for MX / SRV; omit for A/AAAA/...
```

#### CLI

The shared harness emits `<verb>-domain` / `<verb>-record` commands because DNS
has both domain- and record-level resources:

```bash
exoscale-dns list-domains
exoscale-dns get-domain --id <uuid>
exoscale-dns create-domain --json '{"unicode-name": "example.test"}'
exoscale-dns delete-domain --id <uuid>

exoscale-dns list-records --domain-id <uuid>
exoscale-dns create-record --domain-id <uuid> --json '{"name": "www", "type": "A", "content": "192.0.2.1", "ttl": 3600}'
exoscale-dns delete-record --domain-id <uuid> --id <uuid>
```

#### Library

```python
from exoscale_connector import ExoscaleClient
from exoscale_connector.resources.dns import DnsDomainClient

dns = DnsDomainClient(ExoscaleClient.from_env(zone="de-fra-1"))

# Zones
zone = dns.create({"unicode-name": "example.test"})
domains = dns.list()
dns.delete(zone.id)

# Records (sub-resource methods on DnsDomainClient)
record = dns.create_record(zone.id, {
    "name": "www", "type": "A", "content": "192.0.2.1", "ttl": 3600,
})
records = dns.list_records(zone.id)
dns.update_record(zone.id, record.id, {"ttl": 7200})
dns.delete_record(zone.id, record.id)
```

#### Gotchas

- **List wrapper key is `dns-domain-records`, not `dns-records`.** An early
  version used the wrong key and `list_records` always came back empty; live
  testing caught it. The connector now reads `dns-domain-records` only — no
  silent fallback, so a future wrapper-key change fails loudly instead of
  returning an empty list.
- **`.test` TLD is reserved (RFC 2606)** — use it for test zones; they
  won't resolve publicly even when delegated, which is fine for an
  isolated test environment.
- **`unicode-name` is the zone label**, not a generic `name` field. The
  client sets `name_field = "unicode_name"` so `find_by_name` works on it.
- **Account DNS quota.** Most accounts have a cap on the number of DNS
  zones you can have at once. A `400: DNS subscription limit reached`
  means you need to free a slot or raise the limit.

#### End-to-end example

Distilled from
[`tests/integration/test_tier_1.py::test_dns_lifecycle`](../../tests/integration/test_tier_1.py):

```python
from exoscale_connector import ExoscaleClient
from exoscale_connector.resources.dns import DnsDomainClient

dns = DnsDomainClient(ExoscaleClient.from_env(zone="de-fra-1"))

# 1. Create zone
zone = dns.create({"unicode-name": "smoke-test.test"})
zone_id = zone.id

# 2. Add a record
rec = dns.create_record(zone_id, {
    "name": "www", "type": "A", "content": "192.0.2.1", "ttl": 3600,
})

# 3. Verify
listed = dns.list_records(zone_id)
assert any(r.id == rec.id for r in listed)

# 4. Update TTL
dns.update_record(zone_id, rec.id, {"ttl": 7200})
assert dns.get_record(zone_id, rec.id).ttl == 7200

# 5. Cleanup (records first, then zone)
dns.delete_record(zone_id, rec.id)
dns.delete(zone_id)
```

### elastic-ip

A reserved public IP address that can be attached to instances or
load-balancers and re-assigned without reconfiguring DNS. Charged while
allocated.

#### Model

```python
class ElasticIPHealthcheck(ExoscaleModel):
    mode: Optional[str]
    port: Optional[int]
    uri: Optional[str]
    interval: Optional[int]
    timeout: Optional[int]
    strikes_ok: Optional[int]
    strikes_fail: Optional[int]
    tls_sni: Optional[str]
    tls_skip_verify: Optional[bool]


class ElasticIP(ExoscaleModel):
    id: Optional[str]
    ip: Optional[str]
    description: Optional[str]
    addressfamily: Optional[str]                # "inet4" | "inet6"
    healthcheck: Optional[ElasticIPHealthcheck]
    labels: Optional[Dict[str, str]]
```

#### CLI

```bash
exoscale-elastic-ip list
exoscale-elastic-ip get --id <uuid>
exoscale-elastic-ip find --name <description-as-name>
exoscale-elastic-ip create --json '{"description": "web-frontend", "addressfamily": "inet4"}'
exoscale-elastic-ip delete --id <uuid>
```

#### Library

```python
from exoscale_connector import ExoscaleClient
from exoscale_connector.resources.elastic_ip import ElasticIPClient

eips = ElasticIPClient(ExoscaleClient.from_env(zone="de-fra-1"))

eip = eips.create({"description": "web-frontend", "addressfamily": "inet4"})
print(eip.ip)        # the assigned public IPv4

eips.update(eip.id, {"description": "web-frontend (production)"})
eips.delete(eip.id)
```

#### Gotchas

- **EIPs have no `name` field** — the human label is `description`. The
  client uses `description` as the `name_field` so `find_by_name` works on
  it. Two EIPs with the same description will resolve to the first match.
- **Charged while allocated**, free when attached to a running resource on
  some account types. Delete promptly when no longer needed.
- **Healthcheck is optional** — the EIP itself works as a static address
  without one; configure it when you want EIP-managed failover.
- **Attach to an instance** is done via the instance's update endpoint
  (not exposed on this client).
- **Setting reverse DNS is a POST, not a PUT.** The API reference's path
  symmetry suggests `PUT /reverse-dns/elastic-ip/{id}`, but the live API
  404s on PUT and takes **POST** for create/update (confirmed live
  2026-06-10). `set_reverse_dns()` does the right thing.

#### End-to-end example

Distilled from
[`tests/integration/test_tier_2.py::test_elastic_ip_lifecycle`](../../tests/integration/test_tier_2.py):

```python
from exoscale_connector import ExoscaleClient
from exoscale_connector.resources.elastic_ip import ElasticIPClient

eips = ElasticIPClient(ExoscaleClient.from_env(zone="de-fra-1"))

eip = eips.create({"description": "smoke-test", "addressfamily": "inet4"})
assert eips.get(eip.id).ip, "the API did not assign an IP"

eips.update(eip.id, {"description": "smoke-test (updated)"})
assert eips.get(eip.id).description == "smoke-test (updated)"

eips.delete(eip.id)
```

#### Reverse DNS (PTR)

Live-verified in Tier 2 (`test_elastic_ip_reverse_dns`, 2026-06-10):

```python
eips.set_reverse_dns(eip.id, "mail.example.com.")
eips.get_reverse_dns(eip.id)                 # "mail.example.com." | None
eips.delete_reverse_dns(eip.id)
```

### iam-role

An IAM role bundles a set of permissions plus an optional inline policy. API
keys are bound to a role at creation time; the role determines what the key
can do. Account-global.

> Building a policy? See the **[IAM policy cookbook](../iam-policy-cookbook.md)**
> for ready-made helpers (`IAMPolicy.allow_services([...])`, …) and recipes.

#### Model

```python
class IAMPolicyRule(ExoscaleModel):
    action: Optional[str]                      # "allow" | "deny"
    expression: Optional[str]                  # Exoscale IAM DSL, kept verbatim
    resources: Optional[List[str]]


class IAMPolicyService(ExoscaleModel):
    type: Optional[str]                        # "allow" | "deny" | "rules"
    rules: Optional[List[IAMPolicyRule]]       # used when type == "rules"


class IAMPolicy(ExoscaleModel):
    default_service_strategy: Optional[str]    # "allow" | "deny"
    services: Optional[Dict[str, IAMPolicyService]]   # keyed by service name


class IAMRole(ExoscaleModel):
    id: Optional[str]
    name: Optional[str]
    description: Optional[str]
    editable: Optional[bool]
    permissions: Optional[List[str]]
    labels: Optional[Dict[str, str]]
    policy: Optional[IAMPolicy]               # permission policy
    assume_role_policy: Optional[IAMPolicy]   # who/what may assume the role
```

> Inline `policy` / `assume_role_policy` work on **create**. To change them on an
> existing role, use `IAMRoleClient.set_policy(role_id, policy)` /
> `set_assume_role_policy(role_id, policy)`. Under the hood the two are
> asymmetric: `policy` has a dedicated `PUT :policy` sub-endpoint, while
> `assume-role-policy` travels in the generic `PUT /iam-role/{id}` body — the
> setters hide this. See the
> [IAM policy cookbook](../iam-policy-cookbook.md).

#### CLI

```bash
exoscale-iam-role list
exoscale-iam-role get --id <uuid>
exoscale-iam-role find --name <name>
exoscale-iam-role create --json '{"name": "read-only", "description": "list+get only", "policy": {"default-service-strategy": "deny", "services": {}}}'
exoscale-iam-role delete --id <uuid>
```

#### Library

```python
from exoscale_connector import ExoscaleClient
from exoscale_connector.resources.iam_role import IAMPolicy, IAMRole, IAMRoleClient

roles = IAMRoleClient(ExoscaleClient.from_env(zone="de-fra-1"))

role = roles.create(IAMRole(
    name="read-only",
    description="list+get only",
    policy=IAMPolicy(default_service_strategy="deny", services={}),
))

roles.update(role.id, {"description": "list+get only (updated)"})
roles.delete(role.id)
```

A rule-based policy (deny everything except TXT records under one domain):

```python
from exoscale_connector.resources.iam_role import (
    IAMPolicy, IAMPolicyRule, IAMPolicyService, IAMRole,
)

role = roles.create(IAMRole(
    name="acme-dns",
    policy=IAMPolicy(
        default_service_strategy="deny",
        services={
            "dns": IAMPolicyService(
                type="rules",
                rules=[
                    IAMPolicyRule(action="deny", expression="parameters.has('type') && parameters.type != 'TXT'"),
                    IAMPolicyRule(action="allow", expression="operation in ['create-dns-domain-record', 'delete-dns-domain-record']"),
                ],
            ),
        },
    ),
))
```

#### Gotchas

- **The policy envelope is typed; rule expressions are not.** `services`,
  per-service `type`, and the `rules` list (`action` / `expression` /
  `resources`) are modelled, but `expression` stays a free-form string — it is
  Exoscale's CEL-like condition language (e.g.
  `resources.bucket != "backups"`, `operation in ['list-dns-domains']`) and the
  connector never parses it. See Exoscale's
  [IAM policy guide](https://community.exoscale.com/product/iam/how-to/policy-guide/)
  for the expression syntax.
- **`services` is an open map.** Service names (`compute`, `dns`, `sos`, …) are
  not enumerated, and `extra="allow"` preserves any field the API adds, so the
  model keeps round-tripping unknown content losslessly.
- **`editable=false` roles are managed by Exoscale** (e.g. the built-in
  admin role) and cannot be modified or deleted; the API will reject the
  attempt with a 403/409.
- **There is no `:assume-role-policy` sub-endpoint.** The API reference's
  symmetry suggests one, but `PUT /iam-role/{id}:assume-role-policy` returns
  **404** — confirmed live (2026-06-10). Assume-role-policy changes go through
  the generic `PUT /iam-role/{id}` body instead (`{"assume-role-policy": ...}`),
  which is what `set_assume_role_policy()` does. Only the permission `policy`
  has a dedicated sub-endpoint (`PUT :policy`).
- **`assume_role_policy` is write-only-ish.** The API accepts it on create and
  update, but a `get()` on an ordinary role does **not** echo
  `assume-role-policy` back (it comes through as `None`) — confirmed live. The
  permission `policy`, by contrast, does round-trip on `get()`.

#### End-to-end example

Distilled from
[`tests/integration/test_tier_1.py::test_iam_role_lifecycle`](../../tests/integration/test_tier_1.py):

```python
from exoscale_connector import ExoscaleClient
from exoscale_connector.resources.iam_role import IAMPolicy, IAMRole, IAMRoleClient

roles = IAMRoleClient(ExoscaleClient.from_env(zone="de-fra-1"))

# Create a deny-all role (harmless even if its key leaks).
role = roles.create(IAMRole(
    name="connector-smoke",
    description="tier-1 smoke",
    policy=IAMPolicy(default_service_strategy="deny", services={}),
))

assert roles.get(role.id).name == "connector-smoke"
roles.update(role.id, {"description": "updated"})
assert roles.get(role.id).description == "updated"
roles.delete(role.id)
```

### iam-user

An organisation user — a human member of the account. Identified by email.
**Mutation is intentionally not exercised by the live test suite** because
creating a user sends an invite email to a real address; the connector
exposes the endpoints but the live-test harness only does read-only
operations on existing users.

#### Model

```python
class IAMUser(ExoscaleModel):
    id: Optional[str]
    email: Optional[str]      # the unique human identifier
    role_id: Optional[str]    # bound role
    role: Optional[Reference]
```

#### CLI

```bash
exoscale-iam-user list
exoscale-iam-user get --id <uuid>
exoscale-iam-user find --name some.user@example.com   # name_field="email"
# create / delete are exposed but trigger real invite emails — use with care.
```

#### Library

```python
from exoscale_connector import ExoscaleClient
from exoscale_connector.resources.iam_user import IAMUserClient

users = IAMUserClient(ExoscaleClient.from_env(zone="de-fra-1"))

for user in users.list():
    print(user.id, user.email)

found = users.find_by_name("alice@example.com")  # name_field="email"
```

#### Gotchas

- **`find_by_name` matches `email`**, not a separate `name` field — the
  client sets `name_field = "email"` because users have no other label.
- **`create` triggers an email side-effect.** Calling `users.create({...})`
  with an unverified address will either bounce or spam someone — do not
  call from automated tests. The connector keeps the method available for
  legitimate provisioning workflows.

#### End-to-end example (read-only)

```python
from exoscale_connector import ExoscaleClient
from exoscale_connector.resources.iam_user import IAMUserClient

users = IAMUserClient(ExoscaleClient.from_env(zone="de-fra-1"))

# Inventory existing org users — safe in any environment.
for u in users.list():
    print(u.email, "->", u.role_id)
```

### instance-pool (+ scale)

A horizontally-scalable group of identical compute instances. Members are
created/destroyed automatically when you change the pool's `size`. Used as
the backing target for load-balancer services and for stateless
workloads.

#### Model

```python
class InstancePool(ExoscaleModel):
    id: Optional[str]
    name: Optional[str]
    description: Optional[str]
    state: Optional[str]                  # "running" | "scaling-up" | "scaling-down" | ...
    size: Optional[int]                   # current desired size
    instance_type: Optional[Reference]
    template: Optional[Reference]
    disk_size: Optional[int]              # GiB
    instance_prefix: Optional[str]
    ipv6_enabled: Optional[bool]
    public_ip_assignment: Optional[str]
    security_groups: List[Reference]
    private_networks: List[Reference]
    anti_affinity_groups: List[Reference]   # spread members across distinct hosts
    instances: List[Reference]              # current pool members
    labels: Optional[dict]
    deploy_target: Optional[Reference]
    ssh_key: Optional[Reference]
    created_at: Optional[str]
```

#### CLI

```bash
exoscale-instance-pool list
exoscale-instance-pool get --id <uuid>
exoscale-instance-pool find --name <name>
exoscale-instance-pool create --json '{"name":"web-pool","size":1,"instance-type":{"id":"<type-id>"},"template":{"id":"<template-id>"},"disk-size":10,"ssh-key":{"name":"laptop"},"security-groups":[{"id":"<sg-id>"}]}'
exoscale-instance-pool delete --id <uuid>
```

#### Library

```python
from exoscale_connector import ExoscaleClient
from exoscale_connector.resources.instance_pool import InstancePoolClient

pools = InstancePoolClient(ExoscaleClient.from_env(zone="de-fra-1"))

pool = pools.create({
    "name": "web-pool",
    "size": 1,
    "instance-type": {"id": "<type-id>"},
    "template": {"id": "<template-id>"},
    "disk-size": 10,
    "ssh-key": {"name": "laptop"},
    "security-groups": [{"id": "<sg-id>"}],
    # Spread members across distinct physical hosts for HA. Set at create only.
    "anti-affinity-groups": [{"id": "<aag-id>"}],
})

pools.scale(pool.id, 3)        # async — wait until state == "running" and size == 3
pools.update(pool.id, {"description": "production web"})
pools.delete(pool.id)          # cascade-deletes member instances
```

#### Gotchas

- **`scale` is the colon-action endpoint `PUT instance-pool/{id}:scale`** with
  `{"size": <n>}`. Calls return as soon as the operation is accepted; the
  pool transitions through `scaling-up` / `scaling-down` and back to
  `running` over ~1 min per added/removed member.
- **Pool delete cascades to member instances** — they're terminated as part
  of the pool deletion. Wait for the operation to complete before reusing
  the names.
- **Pool members are visible via the `instance` API**, with `manager`
  populated. Don't delete them individually if you want the pool's desired
  size to stay correct.
- **Pass `anti-affinity-groups: [{"id": ...}]` at create to spread members
  across distinct physical hosts** — this is the supported way an instance pool
  guarantees host spread for HA. Like anti-affinity groups generally, it is
  **create-only**: it can't be changed on an existing pool, so decide up front.

#### End-to-end example

Distilled from
[`tests/integration/test_tier_3.py::test_instance_pool_lifecycle`](../../tests/integration/test_tier_3.py):

```python
from exoscale_connector import ExoscaleClient
from exoscale_connector.resources.instance_pool import InstancePoolClient
from tests.integration._fixtures import (
    resolve_instance_type, resolve_linux_template, wait_for_state,
)

client = ExoscaleClient.from_env(zone="de-fra-1")
pools = InstancePoolClient(client)

pool = pools.create({
    "name": "demo-pool",
    "size": 1,
    "instance-type": {"id": resolve_instance_type(client, "standard.tiny")},
    "template": {"id": resolve_linux_template(client)},
    "disk-size": 10,
})

wait_for_state(lambda: pools.get(pool.id), "running", timeout=600)

pools.scale(pool.id, 2)
wait_for_state(lambda: pools.get(pool.id), "running", timeout=600)
assert pools.get(pool.id).size == 2

pools.scale(pool.id, 1)
wait_for_state(lambda: pools.get(pool.id), "running", timeout=600)

pools.delete(pool.id)
```

### instance-type

Read-only catalogue of compute offerings (CPU/memory sizes). Humans know types
as `family.size` (`standard.tiny`); the API addresses them by UUID —
`InstanceTypeClient.find` translates.

#### Model

| Field | Type | Notes |
|---|---|---|
| `id` | str (uuid) | |
| `family` | str | `standard`, `cpu`, `memory`, `gpu`, … |
| `size` | str | `tiny`, `small`, `medium`, … |
| `cpus` | int | |
| `memory` | int | **bytes** |
| `gpus` | int | |
| `authorized` | bool | whether your org may use the type |
| `slug` | property | derived `family.size` form |

#### CLI

```bash
exoscale-instance-type list-instance-types
exoscale-connector instance-type --output table list-instance-types
```

#### Library

```python
from exoscale_connector.resources.instance_type import InstanceTypeClient

types = InstanceTypeClient(client)
tiny = types.find("standard.tiny")     # InstanceType | None
inst = instances.create({"instance-type": {"id": tiny.id}, ...})
```

#### Gotchas

- **`authorized=False`** types appear in the list but cannot be used —
  filter on it before offering choices to users.
- **Live verification:** smoke test (`test_list_instance_types_and_find_slug`)
  ran 2026-06-10 against `at-vie-1`; the wire shape also matches what the
  Tier 3 fixtures (`resolve_instance_type`) have always exercised live.

### instance (+ lifecycle)

A compute virtual machine. The connector exposes full CRUD plus lifecycle
actions (start / stop / reboot) using the colon-action syntax on PUT.

#### Model

```python
class SshKeyReference(ExoscaleModel):
    name: Optional[str]


class Instance(ExoscaleModel):
    id: Optional[str]
    name: Optional[str]
    state: Optional[str]              # "running" | "stopped" | "starting" | "stopping" | ...
    instance_type: Optional[Reference]
    template: Optional[Reference]
    disk_size: Optional[int]          # GiB
    public_ip: Optional[str]
    ipv6_address: Optional[str]
    ssh_key: Optional[SshKeyReference]
    labels: Optional[dict]
    manager: Optional[Reference]      # set when the instance is a pool member
    created_at: Optional[str]
```

#### CLI

```bash
exoscale-instance list
exoscale-instance get --id <uuid>
exoscale-instance find --name <name>
exoscale-instance create --json '{"name":"web-01","instance-type":{"id":"<type-id>"},"template":{"id":"<template-id>"},"disk-size":10,"ssh-key":{"name":"laptop"},"security-groups":[{"id":"<sg-id>"}]}'
exoscale-instance delete --id <uuid>
```

> `start` / `stop` / `reboot` are exposed via the library client.

#### Library

```python
from exoscale_connector import ExoscaleClient
from exoscale_connector.resources.instance import InstanceClient

instances = InstanceClient(ExoscaleClient.from_env(zone="de-fra-1"))

# Create
instance = instances.create({
    "name": "web-01",
    "instance-type": {"id": "<type-id>"},        # resolve via /instance-type
    "template": {"id": "<template-id>"},         # resolve via /template
    "disk-size": 10,                              # GiB
    "ssh-key": {"name": "laptop"},
    "security-groups": [{"id": "<sg-id>"}],
})

# Lifecycle
instances.stop(instance.id)
instances.start(instance.id)
instances.reboot(instance.id)

# Update + delete
instances.update(instance.id, {"labels": {"role": "web"}})
instances.delete(instance.id)
```

#### Gotchas

- **Lifecycle actions are PUT, not POST.** The API returns 404 on POST;
  caught and fixed by the Tier 3 live test.
- **`instance-type` and `template` are References (`{"id": ...}`)**, not
  bare strings. Resolve their ids with
  `client.get("instance-type")` / `client.get("template")`. The Tier 3 test
  helpers `resolve_instance_type(name)` and `resolve_linux_template()` do
  this for you.
- **State transitions are asynchronous.** Create returns once the operation
  is accepted; the actual transition to `running` takes ~30-60 s. Same for
  stop/start/reboot. The Tier 3 test uses a `wait_for_state(getter,
  expected, timeout)` helper.
- **Type change requires a stop/start cycle** (no online vertical scaling).

#### End-to-end example

Distilled from
[`tests/integration/test_tier_3.py::test_instance_lifecycle`](../../tests/integration/test_tier_3.py):

```python
from exoscale_connector import ExoscaleClient
from exoscale_connector.resources.instance import InstanceClient
from tests.integration._fixtures import (
    resolve_instance_type, resolve_linux_template, wait_for_state,
)

client = ExoscaleClient.from_env(zone="de-fra-1")
instances = InstanceClient(client)

inst = instances.create({
    "name": "demo-instance",
    "instance-type": {"id": resolve_instance_type(client, "standard.tiny")},
    "template": {"id": resolve_linux_template(client)},
    "disk-size": 10,
    "ssh-key": {"name": "<your-key-name>"},
    "security-groups": [{"id": "<sg-id>"}],
})

wait_for_state(lambda: instances.get(inst.id), "running", timeout=600)

instances.stop(inst.id)
wait_for_state(lambda: instances.get(inst.id), "stopped", timeout=300)

instances.start(inst.id)
wait_for_state(lambda: instances.get(inst.id), "running", timeout=300)

instances.delete(inst.id)
```

#### Vertical scaling and reverse DNS

Vertical scaling is live-verified (Tier 3 `test_instance_scale`, 2026-06-10:
`standard.tiny` → `standard.small` on a stopped instance). Reverse DNS shares
its implementation with the elastic-ip variant, which is live-verified
(Tier 2); the instance-specific calls themselves have not been exercised
live — note the set call is a **POST** (the spec-suggested PUT 404s,
confirmed live on the elastic-ip endpoint).

```python
# Vertical scaling — instance must be STOPPED first.
instances.stop(inst.id)
instances.scale(inst.id, new_type_id)        # PUT /instance/{id}:scale

# Reverse DNS (PTR record).
instances.set_reverse_dns(inst.id, "host.example.com.")
instances.get_reverse_dns(inst.id)           # "host.example.com." | None
instances.delete_reverse_dns(inst.id)
```

### load-balancer (+ services)

A Network Load Balancer (NLB). Services are sub-resources that define
how incoming traffic on a port maps to a backing instance pool.

#### Model

```python
class LoadBalancerService(ExoscaleModel):
    id: Optional[str]
    name: Optional[str]
    description: Optional[str]
    protocol: Optional[str]                  # "tcp" | "udp"
    port: Optional[int]                      # public-facing port
    target_port: Optional[int]               # port on the backing instances
    strategy: Optional[str]                  # "round-robin" | "source-hash"
    healthcheck_mode: Optional[str]          # "tcp" | "http" | "https"
    healthcheck_port: Optional[int]
    healthcheck_uri: Optional[str]
    healthcheck_interval: Optional[int]
    healthcheck_timeout: Optional[int]
    healthcheck_retries: Optional[int]
    healthcheck_tls_sni: Optional[str]
    state: Optional[str]


class LoadBalancer(ExoscaleModel):
    id: Optional[str]
    name: Optional[str]
    description: Optional[str]
    ip: Optional[str]                        # public IP allocated to the LB
    state: Optional[str]
    labels: Optional[Dict[str, str]]
    services: Optional[List[LoadBalancerService]]
```

#### CLI

```bash
exoscale-load-balancer list
exoscale-load-balancer get --id <uuid>
exoscale-load-balancer find --name <name>
exoscale-load-balancer create --json '{"name": "web-lb", "description": "public web LB"}'
exoscale-load-balancer delete --id <uuid>
```

> Service management is exposed via the library client (`add_service` /
> `update_service` / `delete_service`).

#### Library

```python
from exoscale_connector import ExoscaleClient
from exoscale_connector.resources.load_balancer import LoadBalancerClient

lbs = LoadBalancerClient(ExoscaleClient.from_env(zone="de-fra-1"))

# Create the LB
lb = lbs.create({"name": "web-lb", "description": "public web LB"})

# Add a service pointing at a pool. Use a dict payload — the model currently
# flattens healthcheck fields and lacks instance-pool, so dicts are the more
# faithful API exercise.
lbs.add_service(lb.id, {
    "name": "http",
    "protocol": "tcp",
    "port": 80,
    "target-port": 80,
    "strategy": "round-robin",
    "instance-pool": {"id": "<pool-id>"},
    "healthcheck": {"mode": "tcp", "port": 80, "interval": 10, "timeout": 5, "retries": 2},
})

# Update — must send the full service spec; PUT is a replace, not a patch
lbs.update_service(lb.id, service_id, {
    "name": "http", "protocol": "tcp", "port": 80, "target-port": 80,
    "strategy": "round-robin", "instance-pool": {"id": "<pool-id>"},
    "healthcheck": {"mode": "tcp", "port": 80, "interval": 20, "timeout": 5, "retries": 2},
})

lbs.delete_service(lb.id, service_id)
lbs.delete(lb.id)
```

#### Gotchas

- **`update_service` is a FULL-RESOURCE PUT**, not a partial PATCH. Sending
  only the field you want to change is rejected by the API with a
  confusing error (the server tries to default missing required fields
  from the request line itself — yielding `"HTTP/1.1"` as the value at
  `protocol`). Always resend the full service spec on update.
- **Service needs a backing `instance-pool`** with `{"id": ...}`. Pointing
  at individual instances isn't supported.
- **Service path is `/service` (singular)**, e.g.
  `POST /load-balancer/<id>/service`. Confirmed against the live API; it's not
  always reflected in the OpenAPI index.
- **The `LoadBalancerService` model currently flattens healthcheck fields
  and lacks an `instance_pool` field.** Use dict payloads to send the full
  spec the wire expects. (Tracked as a follow-up model refinement.)
- **Delete the LB before deleting the backing pool** — the API rejects
  deleting a pool that has an LB pointing at it.

#### End-to-end example

Distilled from
[`tests/integration/test_tier_4.py::test_load_balancer_lifecycle`](../../tests/integration/test_tier_4.py):

```python
from exoscale_connector import ExoscaleClient
from exoscale_connector.resources.instance_pool import InstancePoolClient
from exoscale_connector.resources.load_balancer import LoadBalancerClient
from tests.integration._fixtures import (
    resolve_instance_type, resolve_linux_template, wait_for_state,
)

client = ExoscaleClient.from_env(zone="de-fra-1")
pools = InstancePoolClient(client)
lbs = LoadBalancerClient(client)

# 1. Backing pool
pool = pools.create({
    "name": "lb-pool",
    "size": 1,
    "instance-type": {"id": resolve_instance_type(client, "standard.tiny")},
    "template": {"id": resolve_linux_template(client)},
    "disk-size": 10,
})
wait_for_state(lambda: pools.get(pool.id), "running", timeout=600)

# 2. Load balancer
lb = lbs.create({"name": "demo-lb", "description": "demo"})
wait_for_state(lambda: lbs.get(lb.id), "running", timeout=300)

# 3. Service
svc_payload = {
    "name": "http", "protocol": "tcp", "port": 80, "target-port": 80,
    "strategy": "round-robin", "instance-pool": {"id": pool.id},
    "healthcheck": {"mode": "tcp", "port": 80, "interval": 10, "timeout": 5, "retries": 2},
}
lbs.add_service(lb.id, svc_payload)
svc = next(s for s in lbs.get(lb.id).services if s.name == "http")

# 4. Update (full-spec replace)
lbs.update_service(lb.id, svc.id, {**svc_payload,
    "healthcheck": {**svc_payload["healthcheck"], "interval": 20},
})

# 5. Cleanup in order: service -> LB -> pool
lbs.delete_service(lb.id, svc.id)
lbs.delete(lb.id)
pools.delete(pool.id)
```

### object-storage bucket

S3-compatible object storage. **This is the one asset type that does NOT
use the APIv2.** Buckets are managed via the S3 API at
`https://sos-<zone>.exo.io` using `boto3` and S3 SigV4 — the same
`EXOSCALE_API_KEY` / `EXOSCALE_API_SECRET` are used as the
`aws_access_key_id` / `aws_secret_access_key`. The connector wraps boto3
behind an asset-type interface that matches the other types.

#### Install

Object Storage support is an optional extra:

```bash
pip install 'exoscale-connector[sos]'
```

`boto3` is **lazily imported** inside the bucket client, so the rest of the
connector works without it installed.

#### Model

```python
class Bucket(ExoscaleModel):
    name: Optional[str]            # globally unique across all of S3
    creation_date: Optional[str]
```

#### CLI

```bash
exoscale-bucket list
exoscale-bucket exists --name my-bucket-name
exoscale-bucket create --name my-bucket-name
exoscale-bucket delete --name my-bucket-name
```

#### Library

```python
from exoscale_connector import ClientConfig
from exoscale_connector.resources.object_storage import BucketClient

# BucketClient takes a ClientConfig (not an ExoscaleClient) because it
# builds its own boto3 S3 client internally.
config = ClientConfig.from_env(zone="de-fra-1")
buckets = BucketClient(config)

buckets.create("my-unique-bucket-name-1234")
assert buckets.exists("my-unique-bucket-name-1234")
for b in buckets.list():
    print(b.name, b.creation_date)
buckets.delete("my-unique-bucket-name-1234")
```

For test injection, pass a pre-built S3 client:

```python
buckets = BucketClient(config, s3_client=my_mock_s3_client)
```

#### Gotchas

- **Bucket names are globally unique across all of S3**, not just your
  account — pick a long, unique name (3–63 chars, lowercase, no
  underscores). The live test uses a random 16-char suffix.
- **`delete_bucket` fails if the bucket is non-empty.** The connector
  exposes the raw S3 delete; if you need recursive cleanup, do it via
  boto3 directly before calling `buckets.delete()`.
- **Different endpoint per zone:** `https://sos-<zone>.exo.io`. The
  connector derives this from the zone in your `ClientConfig`.
- **Not behind `ExoscaleClient`.** The signed-session and operation-polling
  in `ExoscaleClient` are APIv2-specific; boto3 brings its own retry,
  pagination and error handling.
- **One key spans both surfaces — if the IAM role allows SOS.** The
  connector hands the same `EXOSCALE_API_KEY` / `EXOSCALE_API_SECRET` to
  both APIv2 and S3/SOS, and a single key works for both — *verified against
  a live tenant* whose role grants the `sos` service (`list_buckets`
  succeeded with the same key used for APIv2). The catch is the role: if it
  omits object-storage, `list_buckets` and friends fail with an opaque S3
  access-denied even though the same key works fine on APIv2. Grant it in
  the policy's `services` map (see the
  [IAM policy cookbook](../iam-policy-cookbook.md)).
- **Unconfigured lifecycle answers 200, not an error.** AWS S3 raises
  `NoSuchLifecycleConfiguration` for a bucket without lifecycle rules;
  Exoscale SOS returns 200 with no rules (confirmed live 2026-06-10).
  `get_lifecycle()` normalises both shapes to `None`.

#### End-to-end example

Distilled from
[`tests/integration/test_tier_2.py::test_bucket_lifecycle`](../../tests/integration/test_tier_2.py):

```python
import secrets, string
from exoscale_connector import ClientConfig
from exoscale_connector.resources.object_storage import BucketClient

config = ClientConfig.from_env(zone="de-fra-1")
buckets = BucketClient(config)

suffix = "".join(secrets.choice(string.ascii_lowercase + string.digits) for _ in range(16))
name = f"smoke-test-{suffix}"

buckets.create(name)
assert buckets.exists(name)
assert any(b.name == name for b in buckets.list())
buckets.delete(name)
assert not buckets.exists(name)
```

#### Object-level operations

Added on the extensions branch; live-verified in Tier 2
(`test_bucket_object_roundtrip`, 2026-06-10):

```bash
exoscale-bucket list-objects --bucket backups --prefix logs/
exoscale-bucket upload --bucket backups --key a.txt --file ./a.txt
exoscale-bucket download --bucket backups --key a.txt --file ./a.txt
exoscale-bucket delete-object --bucket backups --key a.txt
exoscale-bucket presign --bucket backups --key a.txt --method get --expires 600
```

```python
buckets.put_object("backups", "a.txt", b"data", content_type="text/plain")
buckets.list_objects("backups", prefix="logs/", limit=100)   # paginates internally
buckets.get_object("backups", "a.txt")                        # bytes, in memory
buckets.upload_file("backups", "big.bin", "/path/big.bin")    # multipart-capable
buckets.download_file("backups", "big.bin", "/path/big.bin")
buckets.presign_get("backups", "a.txt", expires_in=600)       # bearer capability!
buckets.set_lifecycle("backups", [{"ID": "expire", "Status": "Enabled", ...}])
buckets.get_cors("backups")                                    # None when unset
```

A presigned URL grants access to anyone who holds it until expiry — treat it
like a secret; never log it.

### private-network

An L2/L3 private network attached to a zone. Two flavours: **unmanaged**
(simple shared L2, no IP allocation) or **managed** (DHCP with API-allocated
IPs from `start-ip`/`end-ip`).

#### Model

```python
class PrivateNetwork(ExoscaleModel):
    id: Optional[str]
    name: Optional[str]
    description: Optional[str]
    start_ip: Optional[str]   # managed networks only
    end_ip: Optional[str]     # managed networks only
    netmask: Optional[str]    # managed networks only
    labels: Optional[Dict[str, str]]
```

#### CLI

```bash
exoscale-private-network list
exoscale-private-network get --id <uuid>
exoscale-private-network find --name <name>
exoscale-private-network create --json '{"name": "internal", "description": "service mesh"}'
exoscale-private-network delete --id <uuid>
```

#### Library

```python
from exoscale_connector import ExoscaleClient
from exoscale_connector.resources.private_network import PrivateNetworkClient

pn = PrivateNetworkClient(ExoscaleClient.from_env(zone="de-fra-1"))

# Unmanaged (shared L2)
network = pn.create({"name": "internal", "description": "service mesh"})

# Managed (DHCP)
network = pn.create({
    "name": "managed-internal",
    "start-ip": "10.0.0.10",
    "end-ip":   "10.0.0.250",
    "netmask":  "255.255.255.0",
})

# Update + delete
pn.update(network.id, {"description": "updated"})
pn.delete(network.id)

# Join / remove an instance (east-west networking)
pn.attach_instance(network.id, instance_id)                 # DHCP / unmanaged
pn.attach_instance(network.id, instance_id, ip="10.0.0.42") # static lease (managed)
pn.detach_instance(network.id, instance_id)
```

#### Gotchas

- **Instance membership lives on the network, not the instance.** Join an
  instance with `attach_instance(network_id, instance_id)` and remove it with
  `detach_instance(network_id, instance_id)` — these wrap the colon-actions
  `PUT private-network/{id}:attach` / `:detach`, not the instance's own update
  endpoint. Both return the async operation and are awaited by default.
- **Static leases are managed-network only.** Pass `ip=` to `attach_instance`
  to pin a fixed address from the network's `start-ip`/`end-ip` range; omit it
  for DHCP, and it has no effect on an unmanaged (shared-L2) network.
- **Managed networks need all three of `start-ip`, `end-ip`, `netmask`**;
  unmanaged networks need none.

#### End-to-end example

Distilled from
[`tests/integration/test_tier_1.py::test_private_network_lifecycle`](../../tests/integration/test_tier_1.py):

```python
from exoscale_connector import ExoscaleClient
from exoscale_connector.resources.private_network import PrivateNetworkClient

pn = PrivateNetworkClient(ExoscaleClient.from_env(zone="de-fra-1"))

# 1. Create + verify
network = pn.create({"name": "internal", "description": "tier-1 smoke"})
assert pn.get(network.id).name == "internal"

# 2. Update
pn.update(network.id, {"description": "updated"})
assert pn.get(network.id).description == "updated"

# 3. Cleanup
pn.delete(network.id)
```

### security-group (+ rules)

A security group is an Exoscale L3/L4 firewall ruleset that you attach to
compute resources (instances, instance pools, SKS nodepools). It owns its
ingress / egress rules as sub-resources.

#### Model

```python
class SecurityGroupRule(ExoscaleModel):
    id: Optional[str]               # API-assigned uuid
    description: Optional[str]      # free-form, useful as a human-friendly tag
    flow_direction: Optional[str]   # "ingress" | "egress"
    protocol: Optional[str]         # "tcp" | "udp" | "icmp" | "icmpv6"
    start_port: Optional[int]
    end_port: Optional[int]
    network: Optional[str]          # CIDR — mutually exclusive with security_group
    security_group: Optional[Reference]  # peer SG (rule allows traffic from members)


class SecurityGroup(ExoscaleModel):
    id: Optional[str]
    name: Optional[str]             # the human-readable identifier
    description: Optional[str]
    rules: List[SecurityGroupRule]  # embedded on every detail response
    external_sources: Optional[List[str]]  # IP set names (Exoscale-managed allow/deny lists)
```

#### CLI

```bash
exoscale-security-group list
exoscale-security-group get --id <uuid>
exoscale-security-group find --name <name>
exoscale-security-group create --json '{"name": "web", "description": "public web tier"}'
exoscale-security-group delete --id <uuid>
```

> Rule management is exposed via the library client (`add_rule` / `delete_rule`).
> A future CLI update can add `rule-add`/`rule-delete` subcommands.

#### Library

```python
from exoscale_connector import ExoscaleClient
from exoscale_connector.resources.security_group import (
    SecurityGroupClient, SecurityGroupRule,
)

client = ExoscaleClient.from_env(zone="de-fra-1")
sg = SecurityGroupClient(client)

# CRUD
group = sg.create({"name": "web", "description": "public web tier"})
fetched = sg.get(group.id)
maybe = sg.find_by_name("web")  # -> SecurityGroup | None
sg.delete(group.id)

# Rule add / delete
sg.add_rule(group.id, SecurityGroupRule(
    flow_direction="ingress",
    protocol="tcp",
    start_port=443,
    end_port=443,
    network="0.0.0.0/0",
    description="https-from-anywhere",
))
sg.delete_rule(group.id, rule_id)
```

#### Gotchas

- **Rules are not addressable by name.** Always carry an explicit
  `description` if you need to find rules later — `find` only works on the
  parent group's `name`.
- **Cannot delete a security group while it is referenced** by a running
  instance, instance pool, or LB. The API returns 412 — detach first.
- **Adding a rule is async**; the operation completes within a few seconds
  but `wait=False` is available for fire-and-forget scenarios.

#### End-to-end example

Distilled from
[`tests/integration/test_tier_1.py::test_security_group_lifecycle`](../../tests/integration/test_tier_1.py):

```python
from exoscale_connector import ExoscaleClient
from exoscale_connector.resources.security_group import (
    SecurityGroupClient, SecurityGroupRule,
)

client = ExoscaleClient.from_env(zone="de-fra-1")
sg = SecurityGroupClient(client)

# 1. Create
group = sg.create({"name": "web", "description": "tier-1 smoke"})
sg_id = group.id

# 2. Verify
assert sg.get(sg_id).name == "web"
assert sg.find_by_name("web").id == sg_id

# 3. Add a rule
sg.add_rule(sg_id, SecurityGroupRule(
    flow_direction="ingress", protocol="tcp",
    start_port=443, end_port=443, network="0.0.0.0/0",
    description="https",
))

# 4. Verify the rule is present
refreshed = sg.get(sg_id)
assert any(r.description == "https" for r in refreshed.rules)

# 5. Cleanup
for r in refreshed.rules:
    if r.description == "https":
        sg.delete_rule(sg_id, r.id)
sg.delete(sg_id)
```

### sks (cluster + nodepool + kubeconfig)

Exoscale Kubernetes Service. A cluster (`SksCluster`) owns one or more
nodepools (`SksNodepool`) as sub-resources. Worker nodes are real compute
instances spun up by Exoscale's control plane.

#### Model

```python
class SksNodepool(ExoscaleModel):
    id: Optional[str]
    name: Optional[str]
    description: Optional[str]
    size: Optional[int]                          # number of worker nodes
    state: Optional[str]
    instance_type: Optional[Reference]
    template: Optional[Reference]
    instance_pool: Optional[Reference]           # auto-created pool managing the nodes
    disk_size: Optional[int]                     # GiB
    security_groups: Optional[List[Reference]]
    anti_affinity_groups: Optional[List[Reference]]
    private_networks: Optional[List[Reference]]
    labels: Optional[Dict[str, str]]
    taints: Optional[Dict[str, str]]
    instance_prefix: Optional[str]
    public_ip_assignment: Optional[str]


class SksCluster(ExoscaleModel):
    id: Optional[str]
    name: Optional[str]
    description: Optional[str]
    state: Optional[str]
    version: Optional[str]                       # Kubernetes version
    endpoint: Optional[str]                      # control-plane API URL
    cni: Optional[str]                           # "calico" | "cilium"
    service_level: Optional[str]                 # "starter" | "pro"
    addons: Optional[List[str]]
    labels: Optional[Dict[str, str]]
    auto_upgrade: Optional[bool]
    created_at: Optional[str]
    nodepools: Optional[List[SksNodepool]]       # embedded in detail responses
```

#### Addons

Addons are optional components Exoscale installs into the cluster (or nodepool).
Enable them by passing `addons: [...]` at create. The valid values below are
**generated from the committed OpenAPI spec** and kept current by the upstream
drift watch — don't hand-edit them. Notably,
`exoscale-container-storage-interface` installs the Exoscale CSI driver needed
for block-volume-backed PersistentVolumeClaims.

<!-- BEGIN GENERATED:sks-addons -->
<!-- Generated from .github/upstream/openapi-v2.json by scripts/generate_llms_txt.py — do not edit by hand. -->
- **Cluster** (`SksCluster.addons`): `exoscale-cloud-controller`, `exoscale-container-storage-interface`, `metrics-server`, `karpenter`
- **Nodepool** (`SksNodepool.addons`): `storage-lvm`
<!-- END GENERATED:sks-addons -->

#### CLI

```bash
exoscale-sks list-clusters
exoscale-sks get-cluster --id <uuid>
exoscale-sks create-cluster --json '{"name":"prod-k8s","level":"starter","cni":"calico","version":"1.30"}'
exoscale-sks delete-cluster --id <uuid>

exoscale-sks list-nodepools --cluster-id <uuid>
exoscale-sks create-nodepool --cluster-id <uuid> --json '{"name":"workers","size":1,"instance-type":{"id":"<type-id>"},"disk-size":20}'
exoscale-sks delete-nodepool --cluster-id <uuid> --id <uuid>
```

#### Library

```python
from exoscale_connector import ExoscaleClient
from exoscale_connector.resources.sks import SksClusterClient, SksNodepool

sks = SksClusterClient(ExoscaleClient.from_env(zone="de-fra-1"))

# Discover valid Kubernetes versions instead of hardcoding one — the accepted
# set changes as Exoscale adds/retires releases. The API returns them
# newest-first, so [0] is the latest.
versions = sks.list_versions()        # e.g. ["1.31.0", "1.30.4", ...]

# Cluster
cluster = sks.create({
    "name": "prod-k8s",
    "description": "production cluster",
    "version": versions[0],            # latest; or pick a specific supported one
    "cni": "calico",
    "level": "starter",            # field is "level", not "service-level"
})

# Kubeconfig — both `user` and `groups` are required
kubeconfig = sks.generate_kubeconfig(cluster.id, {
    "user": "admin",
    "groups": ["system:masters"],
})

# Nodepool
op = sks.create_nodepool(cluster.id, SksNodepool(
    name="workers",
    size=1,
    instance_type={"id": "<type-id>"},
    disk_size=20,
))
np_id = op.reference_id

# Scale
sks.update_nodepool(cluster.id, np_id, {"size": 3})
sks.update_nodepool(cluster.id, np_id, {"size": 1})

# Cleanup (nodepool first, then cluster)
sks.delete_nodepool(cluster.id, np_id)
sks.delete(cluster.id)
```

#### Gotchas

- **Don't hardcode the Kubernetes `version` — discover it.** Call
  `list_versions()` (wraps `GET /sks-cluster-version`) and pick from the
  returned list. The accepted set shifts over time as Exoscale ships new
  Kubernetes releases and retires old ones, so a literal like `"1.30"` that
  works today can later be rejected at create. The list is newest-first.
- **Cluster create field is `level`, not `service-level`.** An initial test
  payload used `service-level` and the API responded with
  `400: missing keys 'level'`. Allowed values: `starter` (free control
  plane) or `pro` (paid SLA).
- **Kubeconfig requires `user` AND `groups`** in the request body.
  `groups` is a list of Kubernetes groups (e.g. `["system:masters"]` for
  cluster-admin). Missing `groups` returns `400: missing keys 'groups'`.
- **Nodepool needs `standard.small` or larger** if you want to attach
  block-storage volumes (same constraint as raw instances).
- **Cluster provisioning takes ~5–10 min**; nodepool another few minutes;
  scale operations ~1 min per added node. Use generous timeouts.
- **`list_nodepools` reads from the cluster detail's embedded
  `nodepools` array** — there is no standalone nodepool list endpoint.
- **Cluster delete cascades to nodepools and member instances.** Wait for
  the delete to complete before reusing names.
- **`generate_kubeconfig` endpoint** is at the top level
  `POST /sks-cluster-kubeconfig/<id>`, not nested under
  `sks-cluster/<id>/...`.
- **`create_nodepool` returns an `Operation`, not the nodepool.** This
  breaks the otherwise-uniform "create returns the resource" contract,
  but it is deliberate: the API has no standalone nodepool list/get
  endpoint — nodepools are only visible embedded in the cluster's detail
  response. Use `op.reference_id` and then read the nodepool out of the
  cluster's `nodepools` array (or `list_nodepools(cluster_id)` which
  does exactly that).

#### End-to-end example

Distilled from
[`tests/integration/test_tier_4.py::test_sks_lifecycle`](../../tests/integration/test_tier_4.py):

```python
import time
from exoscale_connector import ExoscaleClient
from exoscale_connector.resources.sks import SksClusterClient, SksNodepool
from tests.integration._fixtures import (
    resolve_instance_type, resolve_sks_version, wait_for_state,
)

client = ExoscaleClient.from_env(zone="de-fra-1")
sks = SksClusterClient(client)

# 1. Cluster (cheapest service level + smallest version)
cluster = sks.create({
    "name": "demo-cluster",
    "version": resolve_sks_version(client),
    "cni": "calico",
    "level": "starter",
})
wait_for_state(lambda: sks.get(cluster.id), "running", timeout=1200, interval=15)

# 2. Kubeconfig — never print the contents in real code
kubeconfig = sks.generate_kubeconfig(cluster.id, {
    "user": "demo-admin",
    "groups": ["system:masters"],
})
assert kubeconfig  # contains a base64 kubeconfig blob

# 3. Nodepool (size=1, standard.small)
np_op = sks.create_nodepool(cluster.id, SksNodepool(
    name="workers",
    size=1,
    instance_type={"id": resolve_instance_type(client, "standard.small")},
    disk_size=20,
))
np_id = np_op.reference_id

# Poll the cluster's embedded nodepool list until the node is running
deadline = time.time() + 1200
while time.time() < deadline:
    c = sks.get(cluster.id)
    np = next((n for n in (c.nodepools or []) if n.id == np_id), None)
    if np and (np.state or "").lower() == "running":
        break
    time.sleep(15)

# 4. Scale 1 -> 2 -> 1
sks.update_nodepool(cluster.id, np_id, {"size": 2})
sks.update_nodepool(cluster.id, np_id, {"size": 1})

# 5. Cleanup (nodepool first)
sks.delete_nodepool(cluster.id, np_id)
sks.delete(cluster.id)
```

### snapshot (compute)

A point-in-time snapshot of a compute instance's root disk. Created via the
parent instance's `:create-snapshot` action; can be exported (the API
returns a pre-signed URL) or used to restore data. **Not directly creatable**
via `POST /snapshot` — the connector exposes `create_from_instance(...)`
instead.

#### Model

```python
class SnapshotExport(ExoscaleModel):
    md5sum: Optional[str]          # checksum of the exported image
    presigned_url: Optional[str]   # download URL (time-limited)


class Snapshot(ExoscaleModel):
    id: Optional[str]
    name: Optional[str]            # auto-generated by Exoscale; not user-set
    size: Optional[int]            # bytes
    state: Optional[str]           # "snapshotting" -> "exported" (current Exoscale)
    created_at: Optional[str]
    instance: Optional[Reference]  # parent instance
    export: Optional[SnapshotExport]
```

#### CLI

```bash
exoscale-snapshot list
exoscale-snapshot get --id <uuid>
exoscale-snapshot find --name <name>
exoscale-snapshot delete --id <uuid>
```

> Snapshot creation is exposed via the library (`create_from_instance`),
> not the generic CLI `create` verb.

#### Library

```python
from exoscale_connector import ExoscaleClient
from exoscale_connector.resources.snapshot import SnapshotClient

snapshots = SnapshotClient(ExoscaleClient.from_env(zone="de-fra-1"))

# Create from an instance
snap = snapshots.create_from_instance(instance_id)

# Export — returns a pre-signed download URL in the response
exported = snapshots.export(snap.id)
print(exported.export.presigned_url)

# Delete
snapshots.delete(snap.id)
```

#### Gotchas

- **Terminal state is `"exported"`**, not `"ready"`. Current Exoscale
  auto-exports snapshots to object storage as part of the snapshot pipeline.
  The Tier 3 live test was originally written to wait for `ready` and
  timed out; fixed to accept either.
- **No direct create endpoint.** `POST /snapshot` is not valid; snapshots
  are always created from a specific instance via its action endpoint.
- **Snapshot of a running instance is allowed** but is not
  application-consistent — quiesce databases / filesystems first for
  consistent backups.
- **Snapshot storage is charged per GiB-month** — delete what you no
  longer need.

#### End-to-end example

Distilled from
[`tests/integration/test_tier_3.py::test_compute_snapshot_lifecycle`](../../tests/integration/test_tier_3.py):

```python
import time
from exoscale_connector import ExoscaleClient
from exoscale_connector.resources.snapshot import SnapshotClient

client = ExoscaleClient.from_env(zone="de-fra-1")
snapshots = SnapshotClient(client)

# Assumes you already have a running instance (see instance.md for setup)
snap = snapshots.create_from_instance(instance_id)
snap_id = snap.id

# Poll for terminal state (current Exoscale: "exported", legacy: "ready")
terminal = {"exported", "ready"}
deadline = time.time() + 900
while True:
    s = snapshots.get(snap_id)
    if (s.state or "").lower() in terminal:
        break
    if time.time() >= deadline:
        raise RuntimeError(f"snapshot never settled, last state: {s.state}")
    time.sleep(10)

# Cleanup
snapshots.delete(snap_id)
```

### ssh-key

An ssh public key registered with the account, used when creating instances
so that the matching private key can SSH in. Account-global (not zone-scoped)
but reached through any zone host. **Identified by name, not UUID** — its
name *is* its id in the URL path.

#### Model

```python
class SSHKey(ExoscaleModel):
    name: Optional[str]         # the unique identifier (used in URL paths)
    fingerprint: Optional[str]  # MD5 / SHA fingerprint computed by the API
    public_key: Optional[str]   # OpenSSH-format public key, write-only on get
```

#### CLI

```bash
exoscale-ssh-key list
exoscale-ssh-key get --id laptop                  # name is the id
exoscale-ssh-key find --name laptop
exoscale-ssh-key create --json '{"name": "laptop", "public-key": "ssh-ed25519 AAAAC3... user@host"}'
exoscale-ssh-key delete --id laptop
```

#### Library

```python
from exoscale_connector import ExoscaleClient
from exoscale_connector.resources.ssh_key import SSHKey, SSHKeyClient

keys = SSHKeyClient(ExoscaleClient.from_env(zone="de-fra-1"))

# Register
key = keys.create(SSHKey(name="laptop", public_key="ssh-ed25519 AAAAC3... user@host"))

# Fetch by name (which is the id)
fetched = keys.get("laptop")
print(fetched.fingerprint)

# Remove
keys.delete("laptop")
```

#### Gotchas

- **Name is the id.** `keys.get("laptop")` calls `GET /ssh-key/laptop`, not
  `GET /ssh-key/<uuid>`. `id_field` / `name_field` are both `"name"` on this
  client.
- **No `update` endpoint.** Keys are immutable — to rotate, delete and
  recreate. The connector intentionally exposes no `update`.
- **Public key format.** Send the OpenSSH single-line form
  (`"ssh-ed25519 AAAA... optional comment"`). The API computes the
  fingerprint and returns it on subsequent `get` responses; `public_key`
  itself is often omitted from listing responses.
- **Async-but-no-reference quirk.** The create response is an operation
  envelope with no `reference` field — the connector handles this by
  re-fetching via the submitted name (for name-keyed resources, the id *is*
  the submitted name). Caught by the Tier 1 live test.

#### End-to-end example

Distilled from
[`tests/integration/test_tier_1.py::test_ssh_key_lifecycle`](../../tests/integration/test_tier_1.py):

```python
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
from exoscale_connector import ExoscaleClient
from exoscale_connector.resources.ssh_key import SSHKey, SSHKeyClient

# Generate an ephemeral keypair in-memory (private key is discarded).
private = Ed25519PrivateKey.generate()
pub = private.public_key().public_bytes(
    Encoding.OpenSSH, PublicFormat.OpenSSH
).decode("ascii")

keys = SSHKeyClient(ExoscaleClient.from_env(zone="de-fra-1"))
keys.create(SSHKey(name="laptop", public_key=f"{pub} demo"))
assert keys.get("laptop").fingerprint
keys.delete("laptop")
```

### template

Compute templates — the boot images instances are created from. List Exoscale's
stock images (`visibility="public"`, the API default) or your own registered
ones (`"private"`). Registering is a normal `create` with a source URL +
checksum.

#### Model

| Field | Type | Notes |
|---|---|---|
| `id` | str (uuid) | |
| `name` / `description` | str | |
| `family` | str | OS family, e.g. `Linux Ubuntu` — what `find_linux` matches on |
| `version` / `build` | str | |
| `size` | int | minimum disk the template needs, **bytes** |
| `visibility` | str | `public` \| `private` |
| `url` / `checksum` | str | registration source (private templates) |
| `boot_mode` | str | `legacy` \| `uefi` |
| `default_user` | str | |

#### CLI

```bash
exoscale-template list-templates
exoscale-template get-template --id <uuid>
exoscale-template create-template --json '{"name": "...", "url": "...", "checksum": "..."}'
exoscale-template delete-template --id <uuid>
```

#### Library

```python
from exoscale_connector.resources.template import TemplateClient

templates = TemplateClient(client)
public = templates.list()                      # API default: public
mine = templates.list(visibility="private")
smallest_linux = templates.find_linux()        # smallest public Linux image
```

#### Gotchas

- **`size` is in bytes**, unlike instance `disk-size` (GiB) — the same
  unit-of-measure trap as block volumes.
- **`ssh-key-enabled` and `password-enabled` are required on registration** —
  the API returns 400 `missing keys 'ssh-key-enabled', 'password-enabled'` if
  omitted, even though both are optional on the model (they only exist on private
  templates). Set both explicitly: `"ssh-key-enabled": False, "password-enabled": False`.
- **Virtual disk must be ≥ 10 GB** — the import rejects images smaller than 10 GB
  (operation ends in `failure`). qcow2 sparse images are fine; the on-disk file
  stays ~200 KB even at 10 GB virtual size.
- **register/delete live-verified 2026-06-11** via `test_template_register_delete`
  (Tier 1, gated on `EXOSCALE_TEST_TEMPLATE_URL` + `EXOSCALE_TEST_TEMPLATE_CHECKSUM`).

### zone

Read-only catalogue of Exoscale zones (`GET /zone`). Use it instead of
hardcoding zone names — `config.KNOWN_ZONES` is only a static hint list, this
is the live answer. Note the chicken-and-egg: the APIv2 host is itself
zone-scoped, so one working zone (or an endpoint override) is needed to list
the others.

#### Model

| Field | Type | Notes |
|---|---|---|
| `name` | str | e.g. `de-fra-1` |
| `api_endpoint` | str | the zone's API host, when advertised |

#### CLI

```bash
exoscale-zone list-zones
exoscale-connector zone --output table list-zones
```

#### Library

```python
from exoscale_connector.resources.zone import ZoneClient

zones = ZoneClient(client).list()
names = [z.name for z in zones]
```

#### Gotchas

- **Read-only.** The inherited mutating verbs exist on the class but are not
  supported by the API.
- **Live verification:** smoke test (`test_list_zones`) ran 2026-06-10
  against `at-vie-1` (see [live-test-results](../live-test-results.md)).
