Trilio Site Recovery for OpenStackDisaster Recovery Solution for OpenStack Virtual Machines
API reference

API Reference

Complete REST API endpoint documentation


Description

Complete REST API reference for Trilio Site Recovery for OpenStack (protector-api). The API runs on port 8788 on each site and accepts authenticated requests from the openstack dr CLI, the Horizon dashboard panel, or any HTTP client. All resource endpoints are versioned under /v1/{tenant_id}/. Authentication is via an X-Auth-Token header obtained from Keystone on port 5000. Version discovery at GET / requires no authentication.

The maximum supported microversion is 1.1; the minimum is 1.0. The OpenStack-API-Version: protector <version> header is optional — when omitted the service defaults to microversion 1.0 and processes the request normally. A malformed value or a version outside the 1.0–1.1 range returns HTTP 400 Bad Request.


Parameters

Global request headers

HeaderRequiredDescription
X-Auth-TokenYes (all versioned endpoints)Keystone token obtained from port 5000. Not required for GET /.
OpenStack-API-VersionNoFormat: protector <version>. Valid values: protector 1.0, protector 1.1. Omitting this header is equivalent to protector 1.0.
Content-TypeYes (POST/PUT)Must be application/json.

URL structure

All versioned resource endpoints share the prefix /v1/{tenant_id}/. URLs that omit the /v1/ segment return 404.

Path segmentDescription
{tenant_id}The caller's OpenStack project UUID, taken from the Keystone token scope.
{id} / {pg_id}UUID of the resource being addressed. Names are accepted where noted.

Query parameters (common)

ParameterEndpointsDescription
all_projects=trueProtection Groups list/show, actionAdmin only. Returns resources across all tenants.
status=<value>Operations list, Site Operations listFilter by status string.
operation_type=<value>Operations listFilter by operation type.
pg_id=<uuid>Operations listFilter operations for a specific Protection Group.

Returns

Version discovery

GET / requires no authentication and returns HTTP 200 with supported API versions:

{
  "versions": [
    {
      "id": "v1.0",
      "status": "CURRENT",
      "version": "1.1",
      "min_version": "1.0",
      "links": [
        {
          "rel": "self",
          "href": "http://<host>:8788/v1/"
        }
      ]
    }
  ]
}

Field reference: id identifies the version family (v1.0); version is the maximum supported microversion (1.1); min_version is the minimum (1.0); status is CURRENT for the only shipped version.

Health probe

GET /healthz requires no authentication. Returns 200 OK when the service and its database connection are healthy:

{"status": "ok", "checks": {"database": "ok"}}

Returns 503 Service Unavailable when the database is unreachable:

{"status": "unhealthy", "checks": {"database": "unavailable (<ExceptionClassName>)"}}

Accepted responses by resource type

ResourceCreateShow/ListUpdateDeleteAction
Site201 Created200 OK200 OK204 No Content202 Accepted
Protection Group202 Accepted200 OK200 OK204 No Content (clean) / 200 OK (with leftovers)202 Accepted
Member201 Created200 OK200 OK202 Accepted—
Tenant Mapping201 Created200 OK—204 No Content—
Resource Mapping201 Created200 OK—204 No Content—
Operation—200 OK———
Policy200 OK200 OK———

Action endpoint (POST /v1/{tenant_id}/protection-groups/{pg_id}/action)

All DR operations — failover, failback, test failover, test cleanup, and reconcile — are triggered through the single /action endpoint using a typed request body. A successful dispatch returns 202 Accepted:

{
  "message": "failover operation initiated",
  "status": "pending",
  "protection_group_id": "<pg-uuid>",
  "operation_id": "<operation-uuid>",
  "operation": { ... }
}

The status field in the immediate 202 response is always "pending". Poll GET /v1/{tenant_id}/operations/{operation_id} to track progress.

Operation response

GET /v1/{tenant_id}/operations/{id} returns:

{
  "operation": {
    "id": "<uuid>",
    "protection_group_id": "<uuid>",
    "operation_type": "failover",
    "failover_type": "planned",
    "status": "running",
    "progress": 45,
    "source_site_id": "<uuid>",
    "target_site_id": "<uuid>",
    "execution_site_id": "<uuid>",
    "started_at": "2024-03-15T10:00:00",
    "completed_at": null,
    "error_message": null,
    "instances_created": [],
    "volumes_managed": [],
    "networks_configured": [],
    "floating_ips_allocated": [],
    "security_groups_applied": [],
    "vm_details": [],
    "warnings": [],
    "errors": [],
    "current_phase": "promoting_volumes",
    "steps_completed": ["validate", "snapshot"],
    "steps_failed": []
  }
}

progress is an integer from 0 to 100. status transitions through pending → running → completed or failed.

Replication health response

GET /v1/{tenant_id}/protection-groups/{pg_id}/replication-health returns a nested report. Key fields are nested under replication_health.health_report.* alongside sibling keys at the replication_health level:

{
  "replication_health": {
    "protection_group_id": "<uuid>",
    "protection_group_name": "my-pg",
    "failover_validation": {
      "can_failover": true,
      "warnings": [],
      "blockers": [],
      "message": "Protection group is ready for failover"
    },
    "storage_config": { ... },
    "health_report": {
      "sync_state": "in_sync",
      "lag_seconds": 12,
      "rpo_compliant": true,
      "recovery_points_available": 8
    }
  }
}

Use replication_health.failover_validation.can_failover (boolean) as the programmatic readiness check before triggering failover.


Errors

Error envelope

All errors from protector-api — except microversion rejections from the version middleware — share this JSON shape:

{
  "error": {
    "code": 404,
    "message": "Protection group <id> not found",
    "type": "ProtectionGroupNotFound"
  }
}

The type field is usually the exception class name. Two values are synthesised by the fault middleware itself and have no corresponding exception class:

  • ResourceConflict — a database unique-constraint violation, most often caused by reusing the name of a soft-deleted resource.
  • ServiceUnavailable — the service has lost its connection to MariaDB/MySQL; retryable and unrelated to cross-site reachability.

Microversion rejections from the version middleware carry the same {"error": {"code": 400, "message": ...}} envelope but no type field.

HTTP status codes

CodeMeaningCommon causes
200 OKSuccess (GET, PUT, some POST)—
201 CreatedResource createdSite, member, tenant mapping, resource mapping
202 AcceptedAsync operation initiatedProtection group create; action endpoint; member delete
204 No ContentDeleted successfullySite delete, protection group delete (clean), resource mapping delete, tenant mapping delete
400 Bad RequestInvalid input or bad microversionMissing required fields; invalid replication_type; unsupported OpenStack-API-Version value; InvalidAction; InvalidInput; InvalidParameters; ValidationError; ReplicationTypeMismatch; InvalidVolumeType (volume type mismatch, not missing replication support); InvalidExecutionSite
401 UnauthorizedMissing or invalid tokenNo X-Auth-Token; expired Keystone token; Unauthorized; SiteAuthenticationFailed; TrustAuthFailed
403 ForbiddenAction not permitted in current stateProtectionGroupFailedOver; ProtectionGroupNotOnPrimarySite; Forbidden
404 Not FoundResource does not existProtectionGroupNotFound; SiteNotFound; MemberNotFound; OperationNotFound; PolicyNotFound; ReplicationPolicyNotFound; ConsistencyGroupNotFound; VolumeNotFound; InstanceNotFound; TenantMappingNotFound; MappingNotFound; TrustNotFoundForTenant; MetadataNotFound; ReplicaNotFound; SiteOperationNotFound; RecoveryPointNotFound
409 ConflictState conflict or duplicate nameInvalidState (protection group in a state that does not allow the operation — returned as 409 on every endpoint including /action); InstanceAlreadyProtected; VolumeAlreadyInCG; VolumeAttachedElsewhere; VolumeInUse; SiteAlreadyExists; TenantMappingAlreadyExists; ResourceConflict (synthesised, DB unique-constraint violation)
422 Unprocessable EntityDR eligibility precondition failedInstanceNotCinderBoot (VM does not boot from a Cinder volume); VolumeTypeNotReplicated (volume type lacks replication_enabled='<is> True'); FailoverNotReady; PreflightValidationFailed; PlannedFailoverValidationFailed; UnplannedFailoverValidationFailed; NoVolumesFound; NoBootableVolumeFound; VolumeBackendMismatch; ConsistencyGroupCreateFailed; NoVolumesInConsistencyGroup; ReplicationNotHealthy; RPOViolation; WriteOrderConsistencyFailed; DataConsistencyFailed; TenantValidationFailed; NoProtectionGroupsForSite; SiteFailoverNotReady
503 Service UnavailableRemote site or database unreachableSiteUnreachable; SiteNotHealthy; RemoteSiteUnreachable; RemoteAPICallFailed; ServiceUnavailable (synthesised, local DB connectivity loss — retryable)

Important: InvalidState is 409 on every endpoint, including the /action endpoint. There is no per-endpoint status override anywhere in this service. The fault middleware returns each exception's declared code unconditionally.

Notable error details

InstanceNotCinderBoot (422) — Raised at member-add time when the Nova instance uses an ephemeral boot disk instead of a Cinder volume. All VMs in a Protection Group must boot from Cinder.

VolumeTypeNotReplicated (422) — Raised at member-add time when an attached Cinder volume's type does not have replication_enabled='<is> True' as an extra-spec. When detected in-process this is HTTP 422; when the check crosses an RPC boundary it may surface as HTTP 500 before the exception is re-raised.

InvalidVolumeType (400) — Raised when a volume's type mismatches the Protection Group's configured type (not when replication support is absent).

ProtectionGroupFailedOver (403) — Raised when you attempt to modify a Protection Group that is currently failed over to the DR site. Run failback before making changes, or use the reset-to-primary / reset-to-secondary update flags.

ResourceConflict (409) — No exception class; synthesised by the fault middleware for any DB unique-constraint violation. The most common cause is reusing the name of a soft-deleted resource.

ServiceUnavailable (503) — No exception class; synthesised when the service loses its MariaDB/MySQL connection. This error is local and retryable; it is distinct from SiteUnreachable (which means the remote peer is unreachable).

Roles and trust errors — The service never creates Keystone roles; it looks them up. If dr_operator or dr_site_admin do not exist on both sites, trust creation fails with Role <name> does not exist on site <site>. Pre-create both roles before any cross-site operation.


Examples

Obtain a Keystone token

All versioned API calls require a token from Keystone on port 5000. The protector-api has no authentication endpoint of its own.

export OS_TOKEN=$(openstack token issue -f value -c id)
export TENANT_ID=$(openstack project show -f value -c id myproject)
export PROTECTOR_URL=http://protector-primary.example.com:8788

Discover supported API versions

No authentication required.

curl -s ${PROTECTOR_URL}/

Response:

{
  "versions": [
    {
      "id": "v1.0",
      "status": "CURRENT",
      "version": "1.1",
      "min_version": "1.0",
      "links": [
        {
          "rel": "self",
          "href": "http://protector-primary.example.com:8788/v1/"
        }
      ]
    }
  ]
}

Check service health

No authentication required. Use this as a liveness probe or before making cross-site calls.

curl -s ${PROTECTOR_URL}/healthz

Response (healthy):

{"status": "ok", "checks": {"database": "ok"}}

Register a site

curl -s -X POST ${PROTECTOR_URL}/v1/${TENANT_ID}/sites \
  -H "X-Auth-Token: ${OS_TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{
    "site": {
      "name": "site-secondary",
      "site_type": "SECONDARY",
      "auth_url": "http://keystone-dr.example.com:5000/v3",
      "region_name": "RegionDR",
      "service_username": "protector",
      "service_password": "s3cr3t",
      "service_project_name": "service",
      "service_user_domain_name": "Default",
      "service_project_domain_name": "Default"
    }
  }'

Response (201 Created):

{
  "site": {
    "id": "a1b2c3d4-...",
    "name": "site-secondary",
    "site_type": "SECONDARY",
    "auth_url": "http://keystone-dr.example.com:5000/v3",
    "region_name": "RegionDR",
    "status": "ACTIVE"
  }
}

The service validates the provided service_password against the remote Keystone before saving. If credentials are incomplete or authentication fails, you receive 400 Bad Request.


List sites

curl -s ${PROTECTOR_URL}/v1/${TENANT_ID}/sites \
  -H "X-Auth-Token: ${OS_TOKEN}"

Create a Protection Group

A Protection Group requires both sites to be registered and a Cinder volume type with replication_enabled='<is> True'.

curl -s -X POST ${PROTECTOR_URL}/v1/${TENANT_ID}/protection-groups \
  -H "X-Auth-Token: ${OS_TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{
    "protection_group": {
      "name": "web-tier-pg",
      "primary_site_id": "<primary-site-uuid>",
      "secondary_site_id": "<secondary-site-uuid>",
      "replication_type": "async",
      "volume_type_id": "<replicated-volume-type-uuid>"
    }
  }'

Response (202 Accepted):

{
  "protection_group": {
    "id": "pg-uuid-...",
    "name": "web-tier-pg",
    "status": "creating",
    "message": "Protection group creation initiated. Use GET to check status."
  }
}

Creation is asynchronous. Poll GET /v1/${TENANT_ID}/protection-groups/<id> until status is active.

Valid replication_type values: async (periodic Pure Storage Protection Group snapshots) and active_dr (Pure Storage ActiveDR pod-based continuous replication, near-zero RPO, requires Purity//FA 6.0+). volume_type_id is required for async.


Add a VM to a Protection Group

The instance must boot from a Cinder volume; all attached volumes must use a volume type with replication_enabled='<is> True'.

curl -s -X POST \
  ${PROTECTOR_URL}/v1/${TENANT_ID}/protection-groups/<pg-id>/members \
  -H "X-Auth-Token: ${OS_TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{
    "member": {
      "instance_id": "<nova-instance-uuid>",
      "boot_order": 1
    }
  }'

Response (201 Created):

{
  "member": {
    "id": "member-uuid",
    "protection_group_id": "pg-uuid",
    "instance_id": "<nova-instance-uuid>",
    "instance_name": "web-01",
    "boot_order": 1,
    "status": "protected",
    "volumes_added": 2
  }
}

If the instance uses ephemeral storage, the service returns 422 with type: InstanceNotCinderBoot. If any attached volume type lacks replication support, it returns 422 with type: VolumeTypeNotReplicated.


Pre-validate a member without adding it

Use the validate endpoint to run all eligibility checks without side effects. This always returns 200 OK; check the valid field in the response body.

curl -s -X POST \
  ${PROTECTOR_URL}/v1/${TENANT_ID}/protection-groups/<pg-id>/members/validate \
  -H "X-Auth-Token: ${OS_TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{"member": {"instance_id": "<nova-instance-uuid>"}}'

Response (200 OK, validation failed):

{
  "validation": {
    "valid": false,
    "instance_id": "<nova-instance-uuid>",
    "instance_name": "web-02",
    "protection_group_id": "pg-uuid",
    "errors": [
      {
        "code": "InstanceNotCinderBoot",
        "message": "Instance web-02 is not a Cinder boot instance."
      }
    ],
    "warnings": [],
    "details": {}
  }
}

Trigger a planned failover

All DR actions use the single /action endpoint with a typed body.

curl -s -X POST \
  ${PROTECTOR_URL}/v1/${TENANT_ID}/protection-groups/<pg-id>/action \
  -H "X-Auth-Token: ${OS_TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{
    "failover": {
      "failover_type": "planned",
      "vm_creation_params": {
        "availability_zone": "nova-az-dr"
      },
      "network_mapping": {
        "net-primary-uuid": "net-secondary-uuid"
      }
    }
  }'

Response (202 Accepted):

{
  "message": "failover operation initiated",
  "status": "pending",
  "protection_group_id": "<pg-uuid>",
  "operation_id": "<operation-uuid>",
  "operation": { "id": "<operation-uuid>", "status": "pending", "progress": 0 }
}

For failover_type: planned, the engine gracefully shuts down VMs and takes a final snapshot before promoting volumes — zero data loss. For failover_type: unplanned, the engine skips shutdown and accepts data loss up to the RPO.


Trigger an unplanned failover

curl -s -X POST \
  ${PROTECTOR_URL}/v1/${TENANT_ID}/protection-groups/<pg-id>/action \
  -H "X-Auth-Token: ${OS_TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{
    "failover": {
      "failover_type": "unplanned",
      "force": true
    }
  }'

Trigger a test failover (non-disruptive DR validation)

A test failover clones replicated volumes from the secondary site and boots isolated test VMs (prefixed with test-) on a separate network without touching production.

curl -s -X POST \
  ${PROTECTOR_URL}/v1/${TENANT_ID}/protection-groups/<pg-id>/action \
  -H "X-Auth-Token: ${OS_TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{
    "test_failover": {
      "network_mapping": {
        "net-primary-uuid": "net-test-isolated-uuid"
      },
      "vm_creation_params": {
        "availability_zone": "nova-az-test"
      },
      "instance_name_prefix": "test-"
    }
  }'

Either network_mapping or auto_network is required. Omitting both returns 400 Bad Request.


Clean up test failover resources

curl -s -X POST \
  ${PROTECTOR_URL}/v1/${TENANT_ID}/protection-groups/<pg-id>/action \
  -H "X-Auth-Token: ${OS_TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{
    "test_cleanup": {
      "operation_id": "<test-failover-operation-uuid>"
    }
  }'

Trigger a failback

curl -s -X POST \
  ${PROTECTOR_URL}/v1/${TENANT_ID}/protection-groups/<pg-id>/action \
  -H "X-Auth-Token: ${OS_TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{
    "failback": {
      "failback_type": "planned"
    }
  }'

Poll operation status

curl -s \
  ${PROTECTOR_URL}/v1/${TENANT_ID}/operations/<operation-uuid> \
  -H "X-Auth-Token: ${OS_TOKEN}"

Response (200 OK):

{
  "operation": {
    "id": "<operation-uuid>",
    "operation_type": "failover",
    "status": "completed",
    "progress": 100,
    "started_at": "2024-03-15T10:00:00",
    "completed_at": "2024-03-15T10:04:22",
    "error_message": null,
    "steps_completed": ["validate", "snapshot", "promote", "create_vms"],
    "steps_failed": []
  }
}

List all operations (with filters)

curl -s \
  "${PROTECTOR_URL}/v1/${TENANT_ID}/operations?status=running&operation_type=failover" \
  -H "X-Auth-Token: ${OS_TOKEN}"

There is no DELETE route for operations — only GET /v1/{tenant_id}/operations and GET /v1/{tenant_id}/operations/{id} are registered.


Check replication health

curl -s \
  ${PROTECTOR_URL}/v1/${TENANT_ID}/protection-groups/<pg-id>/replication-health \
  -H "X-Auth-Token: ${OS_TOKEN}"

Check replication_health.failover_validation.can_failover to determine programmatic failover readiness.


List recovery points

curl -s \
  "${PROTECTOR_URL}/v1/${TENANT_ID}/protection-groups/<pg-id>/recovery-points?limit=10" \
  -H "X-Auth-Token: ${OS_TOKEN}"

Create a tenant mapping

Tenant mappings are required because each site has an independent Keystone that assigns different UUIDs to the same project.

curl -s -X POST \
  ${PROTECTOR_URL}/v1/${TENANT_ID}/tenant-mappings \
  -H "X-Auth-Token: ${OS_TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{
    "tenant_mapping": {
      "local_tenant_id": "<primary-project-uuid>",
      "remote_site_id": "<secondary-site-uuid>",
      "remote_tenant_id": "<secondary-project-uuid>",
      "description": "Production DR mapping"
    }
  }'

Response (201 Created): The response includes a warning field if the reverse mapping could not be synced to the remote site, indicating that only one half of the bidirectional mapping exists. Cross-site operations will not work until both halves are present.


Validate an existing tenant mapping

curl -s -X POST \
  ${PROTECTOR_URL}/v1/${TENANT_ID}/tenant-mappings/<mapping-id>/validate \
  -H "X-Auth-Token: ${OS_TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{}'

Create a resource mapping (network, flavor, floating-IP pool)

curl -s -X POST \
  ${PROTECTOR_URL}/v1/${TENANT_ID}/protection-groups/<pg-id>/mappings \
  -H "X-Auth-Token: ${OS_TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{
    "mapping": {
      "resource_type": "network",
      "primary_resource_id": "<primary-network-uuid>",
      "secondary_resource_id": "<secondary-network-uuid>"
    }
  }'

Supported resource_type values: network, flavor, floating_ip_pool.


Auto-map resources

Let the service attempt to match resources between sites by name.

curl -s -X POST \
  ${PROTECTOR_URL}/v1/${TENANT_ID}/protection-groups/<pg-id>/mappings/auto \
  -H "X-Auth-Token: ${OS_TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{}'

Response (200 OK):

{
  "auto_mappings": { "networks": 2, "flavors": 1 },
  "decisions": [
    { "resource_type": "network", "name": "internal", "outcome": "adopted" },
    { "resource_type": "network", "name": "external", "outcome": "not_found",
      "reason": "No network named 'external' on secondary site" }
  ],
  "message": "Auto-mapped 3 resources; 1 left unmapped -- see decisions for why"
}

Trigger a site-level action

Site-level failover requires the dr_site_admin role.

curl -s -X POST \
  ${PROTECTOR_URL}/v1/${TENANT_ID}/sites/<site-id>/action \
  -H "X-Auth-Token: ${OS_TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{"failover": {"failover_type": "unplanned"}}'

The action body must use "failover" or "failback" as the top-level key. Unknown keys return 400 InvalidInput.


Retrieve a usage summary (admin only)

curl -s \
  ${PROTECTOR_URL}/v1/${TENANT_ID}/usage/summary \
  -H "X-Auth-Token: ${OS_TOKEN}"

CLI equivalents

The openstack dr CLI plugin issues the same requests. Equivalent commands for the examples above:

# Register a site
openstack dr site create site-secondary \
  --site-type SECONDARY \
  --auth-url http://keystone-dr.example.com:5000/v3

# Create a Protection Group
openstack dr protection group create web-tier-pg \
  --primary-site site-primary \
  --secondary-site site-secondary \
  --replication-type async \
  --volume-type replicated-ceph

# Add a VM
openstack dr protection group member add web-tier-pg \
  --instance <nova-instance-uuid> \
  --boot-order 1

# Check replication health
openstack dr replication health <pg-id>

# Planned failover
openstack dr failover <pg-id> --failover-type planned --wait

# Non-disruptive test failover
openstack dr test failover <pg-id> \
  --network-mapping net-primary-uuid=net-test-uuid

# Clean up test failover
openstack dr test failover cleanup <pg-id>

# Failback
openstack dr failback <pg-id>

# Poll an operation
openstack dr operation show <operation-id>

The CLI plugin is a separate package. Install it with:

pip install -e ./client

The openstackclient snap is incompatible with the triliositerecovery plugin; install python-openstackclient via pip instead.


Notes

Authentication and roles

All versioned API endpoints require a valid X-Auth-Token from Keystone on port 5000. The protector-api has no authentication endpoint of its own. Before any cross-site operation can succeed, the following Keystone roles must exist on both sites:

  • dr_operator — required for tenant DR operations. Must be granted alongside member on the tenant's project for the user, and on each DR-enabled project for the protector service account.
  • dr_site_admin — required for site-level operations (sites:create/update/delete/action, site-level failover, and site operations endpoints). Must be pre-created on both sites; if absent, trust creation fails with a message naming the missing role.

The service never creates Keystone roles — it only looks them up and grants them.

Microversioning

The OpenStack-API-Version header is optional. Omitting it is equivalent to sending protector 1.0; the request is processed normally. A version string outside the 1.0–1.1 range, or a malformed value, returns HTTP 400 with a structured error body (no type field).

Every response carries OpenStack-API-Version: protector 1.1 and OpenStack-API-Minimum-Version: protector 1.0 headers regardless of the requested version.

Bidirectional connectivity on port 8788

Port 8788 must be open bidirectionally between sites. One-way connectivity allows you to initiate DR operations but prevents cross-site metadata and tenant mapping synchronisation, leaving the two sites inconsistent. When the remote protector-api is unreachable, tenant mapping creation succeeds locally but sets status: pending_sync with a warning rather than failing hard.

Operations are long-running — always poll

Failover, failback, test failover, member removal, and Protection Group creation are all asynchronous. The 202 response body includes an operation_id; use GET /v1/{tenant_id}/operations/{operation_id} to track progress and detect completion or failure. The progress field runs from 0 to 100. There is no DELETE route for operations — only GET.

Action endpoint: single path, multiple actions

All DR actions (failover, failback, test failover, test cleanup, reconcile) are triggered through the single endpoint POST /v1/{tenant_id}/protection-groups/{pg_id}/action. The action is selected by the top-level key of the JSON body (failover, failback, test_failover, test_cleanup, reconcile). There are no separate /failover, /failback, or /test-failover sub-paths.

Protection Group member eligibility

Two constraints are enforced at member-add time and cannot be bypassed:

  1. Cinder boot volumes only. Instances that boot from ephemeral storage are rejected with HTTP 422 (InstanceNotCinderBoot). Every protected VM must have a Cinder-backed root volume.
  2. Replicated volume types only. Every Cinder volume attached to the instance must use a volume type with replication_enabled='<is> True' as an extra-spec. Volumes on non-replicated types return HTTP 422 (VolumeTypeNotReplicated).

Use POST /v1/{tenant_id}/protection-groups/{pg_id}/members/validate to pre-check eligibility without side effects. The endpoint always returns HTTP 200 with a validation.valid boolean and a structured validation.errors array.

Storage driver

Activate the Pure Storage FlashArray driver by setting storage_driver = pure in the [storage] section of protector.conf. The default value is mock, which is suitable for development and testing only — do not use it in production. In Juju charm deployments, set this via:

juju config trilio-site-recovery storage-driver=pure

Do not edit /etc/protector/protector.conf directly in Juju deployments — the charm overwrites it on every config-changed event.

Protection Group delete behaviour

DELETE /v1/{tenant_id}/protection-groups/{id} returns 204 No Content on clean deletion. If you pass ?force=true and the engine cannot reach the peer site to clean up remote resources, the response is 200 with a leftovers array describing what was not removed, rather than 204, so you know the remote site still holds a copy.

Deleting a Protection Group that was already deleted returns 204 (idempotent). Deleting a site that was already deleted also returns 204.

Resource mappings and networking

Networks, subnets, security groups, and flavors are adopted from the target site by name during failover — they are never created by the DR service. A pre-flight check runs before planned failover shuts down any VMs; it reports all missing resources at once so you can fix them without retrying the failover one resource at a time. Use POST /v1/{tenant_id}/protection-groups/{pg_id}/mappings/auto to auto-discover mappings, or create them explicitly via POST /v1/{tenant_id}/protection-groups/{pg_id}/mappings.

InvalidState is HTTP 409 everywhere

InvalidState is declared with code = 409 in protector/common/exception.py. The fault middleware returns that code unconditionally on every route, including the /action endpoint. There is no per-endpoint override. If you see documentation or client code treating InvalidState as HTTP 400 on /action, that source is stale.

Consistency Group data is embedded

Consistency Group details are returned as part of the Protection Group API response. There is no separate /consistency-group sub-resource or endpoint.

CLI plugin installation

The openstack dr CLI plugin is a separate Python package (triliositerecovery) located in the client/ subdirectory. Installing the server package does not install the CLI. Install the CLI separately:

pip install -e ./client

The openstackclient snap is incompatible with the triliositerecovery plugin — install python-openstackclient via pip, not snap.

Juju charm deployments

In Juju charm deployments, protector.conf is rendered by the charm from relation data and juju config values. All configuration must be set via juju config trilio-site-recovery <hyphenated-key>=<value>. The charm creates the Keystone service account (protector) and the database automatically via relations — no manual pre-creation is needed.