diff --git a/concepts/command.mdx b/concepts/command.mdx
index af76ed3e..75c6e841 100644
--- a/concepts/command.mdx
+++ b/concepts/command.mdx
@@ -25,7 +25,7 @@ The `lifecycleStage` field shows where a command is. It starts at `pending` and
| --- | --- |
| `pending` | Accepted, not yet started. |
| `running` | Control Plane is carrying it out. |
-| `cancellation-requested` | Control Plane decided to stop the command, for example because a cron run passed its `activeDeadlineSeconds`, and is removing what the command created. It normally ends in `cancelled`, but can still end in `completed` or `failed` if the work finished first. |
+| `cancellation-requested` | The command is being stopped, because Control Plane decided to (for example, a cron run passed its `activeDeadlineSeconds`) or a caller [cancelled it](#working-with-commands). Control Plane is removing what the command created. It normally ends in `cancelled`, but can still end in `completed` or `failed` if the work finished first. |
| `cancelled` | Final. Stopped, and everything it created has been removed. |
| `completed` | Final. Finished successfully. |
| `failed` | Final. It didn't finish. The command's `status` holds type-specific details. |
@@ -56,12 +56,19 @@ cpln rest get "/org/my-org/gvc/my-gvc/workload/my-cron-workload/-command?lifecyc
cpln rest get /org/my-org/gvc/my-gvc/workload/my-cron-workload/-command/COMMAND_ID
```
-Only Control Plane moves a command between stages. To stop a `runCronWorkload` run that is in progress, [stop its replica](/reference/workload/general#stop-a-replica): that deletes the run's job and marks its command `cancelled`.
+Control Plane moves a command between stages. The one change a caller can make is to **cancel** a command by patching its `lifecycleStage` to `cancellation-requested`. Every volume set command, including a [volume copy](/reference/volumeset#cancel-a-volume-copy), and `runCronWorkload` can be cancelled; `stopReplica` can't. This needs the same `exec.TYPE` permission as creating the command:
+
+```bash
+echo '{"lifecycleStage": "cancellation-requested"}' > cancel.json
+cpln rest patch /org/my-org/gvc/my-gvc/volumeset/my-volume-set/-command/COMMAND_ID --file cancel.json
+```
+
+A cancellation request is refused with `400 Command type 'TYPE' does not support cancellation`, or with `400 Cannot request cancellation for command in terminal state 'STAGE'` once the command has finished.
## Gotchas
- **A `201` doesn't mean the action finished.** It means the command was accepted. Read `lifecycleStage` until it reaches `completed`, `failed`, or `cancelled`.
-- **A command is read-only once created.** A `PATCH` from a user or service account is rejected with `403 Cannot modify commands`, and a `DELETE` isn't allowed. A command stays as a record of what ran until the workload or volume set it belongs to is deleted, which deletes its commands too.
+- **A command is read-only once created, except for cancellation.** Any other `PATCH` from a user or service account is rejected with `403 Cannot modify commands`, and a `DELETE` isn't allowed. A command stays as a record of what ran until the workload or volume set it belongs to is deleted, which deletes its commands too.
- **Some commands conflict, and they conflict in different ways.**
- A second `stopReplica` for the same replica is rejected with `400` while the first is still in progress.
- A volume set command that overlaps one already in progress on the same volume is rejected with `409`. The exception is `deleteVolume`, which fails the overlapping commands instead.
@@ -77,7 +84,7 @@ Only Control Plane moves a command between stages. To stop a `runCronWorkload` r
The `stopReplica` spec and what Control Plane does when a replica is stopped.
- Expand, shrink, delete, snapshot, and restore the volumes of a volume set.
+ Expand, shrink, copy, delete, snapshot, and restore the volumes of a volume set.
The `exec` permissions that allow a principal to create workload commands.
diff --git a/llms.txt b/llms.txt
index 58c06b48..7e4ee9cd 100644
--- a/llms.txt
+++ b/llms.txt
@@ -235,6 +235,7 @@ Volume sets provide persistent storage within a GVC. Mount via `cpln://volumeset
- Custom encryption (AWS KMS) is supported on `ext4`/`xfs` only.
- Volumes can only be **expanded once every 6 hours**.
- `shrinkVolume` provisions a NEW smaller volume and **permanently deletes the old volume and all its data** — only safe for distributed systems with replication (Kafka, CockroachDB). Never use for single-replica stateful workloads or databases without replication.
+- `copyVolume` copies one volume into another location of the same volume set, or into another volume set in the same org (`spec.targetVolumeSet`). It **deletes the destination volume's storage device**, always requires `acknowledgeDestinationVolumeDeletion: true`, needs `exec.copyVolume` (not included in `exec`), and supports `ext4`/`xfs` only. A copy from a mounted source is not crash-consistent.
- `createFinalSnapshot: true` (default) auto-snapshots before any volume deletion — leave on.
- Volume sets are GVC-scoped. A workload can only use volume sets in the same GVC. Autoscaling (reactive and predictive) is available for automatic expansion.
@@ -356,7 +357,7 @@ cpln logs '{gvc="GVC", workload="WL"}' --org ORG --tail
## Concepts
- [Access Control](https://docs.controlplane.com/concepts/access-control): The two systems, billing account roles and org policies, then the policy model: the four principal kinds defined (user, group, service account, identity as what you attach to a workload), bindings, permission implication, dynamic tag-query targets, built-in superusers/viewers policies, the access report, and how billing membership stays separate from org membership
- [Billing Account](https://docs.controlplane.com/concepts/billing): What a billing account holds, the three billing roles, setup order, monthly spend alerts, why billing roles grant nothing inside an org, and why billing users and org members are two separate lists joined in different ways
-- [Command](https://docs.controlplane.com/concepts/command): The asynchronous resource behind one-off actions on a workload or volume set (runCronWorkload, stopReplica, volume and snapshot operations): the -command endpoint, the six lifecycle stages and the three final ones, listing (filter by lifecycleStage) and getting commands, why commands are read-only once created (PATCH returns 403, no DELETE), stopping a cron run by stopping its replica, how stopReplica, volume set, and runCronWorkload conflicts differ, and why a 201 means accepted rather than finished
+- [Command](https://docs.controlplane.com/concepts/command): The asynchronous resource behind one-off actions on a workload or volume set (runCronWorkload, stopReplica, volume and snapshot operations): the -command endpoint, the six lifecycle stages and the three final ones, listing (filter by lifecycleStage) and getting commands, why commands are read-only once created except for cancellation (PATCH lifecycleStage to cancellation-requested with exec.TYPE; any other PATCH returns 403, no DELETE), stopping a cron run by stopping its replica, how stopReplica, volume set, and runCronWorkload conflicts differ, and why a 201 means accepted rather than finished
- [Deployment](https://docs.controlplane.com/concepts/deployment): The read-only per-location record Control Plane writes back for a workload: ready gated on the expected version, deploying that stops on failure, the two-version view of a rollout, health rollup to every interface, stale flagging and 24-hour expiry, cron run history, and why fixes go to the workload
- [GVC (Global Virtual Cloud)](https://docs.controlplane.com/concepts/gvc): The locations a GVC spans and the shared ground its workloads inherit: per-GVC geo routing, the namespace behind cpln.local and cpln.app endpoints, env vars, pull secrets, KEDA, and the volume-set deletion block
- [Identity](https://docs.controlplane.com/concepts/identity): What you attach to a workload to give it credentials, and the source of every credential it presents: the three-part secret chain (attach, reveal policy, cpln://secret reference), Universal Cloud Identity across AWS, GCP, Azure, and NATS, wormhole network resources, PrivateLink and Private Service Connect native network resources, and the use permission needed to attach one
diff --git a/reference/policy.mdx b/reference/policy.mdx
index 1e418660..76e6de82 100644
--- a/reference/policy.mdx
+++ b/reference/policy.mdx
@@ -66,7 +66,8 @@ Beyond those two rules, some kinds grant more. Each row below is what the named
| [Audit Context](/reference/auditctx) | `readAudit`, `writeAudit` | `view` |
| [Managed Kubernetes](/mk8s/overview) | `clusterAdmin` | `view` |
| [Org](/reference/org) | `readLogs` | `view` |
-| [Workload](/reference/workload/general), [Volume Set](/reference/volumeset) | `exec` | the individual `exec.*` permissions |
+| [Workload](/reference/workload/general) | `exec` | `exec.runCronWorkload`, `exec.stopReplica` |
+| [Volume Set](/reference/volumeset) | `exec` | every `exec.*` permission except `exec.copyVolume` and `exec.shrinkVolume` |
**`edit` on a secret grants `reveal`,** which is the plaintext. Someone who can change a secret can also read it. Grant `use` when a principal only needs to attach the secret to a GVC or workload, and `view` when it only needs to see that the secret exists.
diff --git a/reference/volumeset.mdx b/reference/volumeset.mdx
index be433dbc..d7af97f3 100644
--- a/reference/volumeset.mdx
+++ b/reference/volumeset.mdx
@@ -1,7 +1,7 @@
---
title: Volume Set
description: "Persistent storage volumes for stateful workloads. Covers filesystem types (ext4, xfs, shared), autoscaling, snapshots, and capacity management."
-keywords: ['pv', 'pvc', 'block storage', 'shared filesystem', 'efs', 'nfs', 'volume set spec', 'wal', 'backup', 'snapshot policy', 'storageclass', 'replicated volume']
+keywords: ['pv', 'pvc', 'block storage', 'shared filesystem', 'efs', 'nfs', 'volume set spec', 'wal', 'backup', 'snapshot policy', 'storageclass', 'replicated volume', 'copyVolume', 'migrate volume', 'clone volume']
---
## Overview
@@ -28,6 +28,7 @@ The shared filesystem does not support:
- `restoreVolume`
- `deleteVolume`
- `shrinkVolume`
+ - `copyVolume`
### Capacity and Billing
@@ -511,6 +512,169 @@ For example:
}
```
+## Volume Copy
+
+A `copyVolume` command copies one volume into another location of the same volume set, or into a volume of another volume set in the same org, including one in another [GVC](/reference/gvc). The copy lands on the same volume index at the destination and replaces the volume there. The source volume is never modified.
+
+
+
+**The destination volume is replaced, and its storage device is DELETED.**
+
+Whatever storage device occupies the volume at `volumeIndex` in the destination is deleted once the copy is committed, and its data is lost. Every `copyVolume` command must set `acknowledgeDestinationVolumeDeletion` to `true`, even when the volume set status shows no device at the destination: status can lag behind reality.
+
+
+
+`copyVolume` is only available for `ext4` and `xfs` volume sets, on both ends. It is not supported for `shared` file systems.
+
+### copyVolume
+
+Spec:
+
+- `location`: the location the volume is copied **from**. Required.
+- `volumeIndex`: the index of the volume to copy. The copy replaces the volume at the same index at the destination. Required.
+- `targetLocation`: the location the volume is copied **to**. Required.
+- `acknowledgeDestinationVolumeDeletion`: must be `true`. Confirms that the destination volume is replaced and its storage device deleted.
+- `targetVolumeSet`: optional. A link to the volume set that receives the copy, in the same org. It defaults to the volume set the command is issued on. It accepts these forms:
+ - `//volumeset/VOLUME_SET`: a volume set in the same GVC
+ - `//gvc/GVC/volumeset/VOLUME_SET`
+ - `/org/ORG/gvc/GVC/volumeset/VOLUME_SET`
+
+To copy a volume into another location of the same volume set:
+
+```json copyVolume Command
+{
+ "type": "copyVolume",
+ "spec": {
+ "location": "aws-us-east-2",
+ "volumeIndex": 0,
+ "targetLocation": "aws-us-west-2",
+ "acknowledgeDestinationVolumeDeletion": true
+ }
+}
+```
+
+To copy a volume into a volume set in another GVC:
+
+```json copyVolume Command (another volume set)
+{
+ "type": "copyVolume",
+ "spec": {
+ "location": "aws-us-east-2",
+ "volumeIndex": 0,
+ "targetLocation": "aws-us-west-2",
+ "targetVolumeSet": "//gvc/my-other-gvc/volumeset/my-volume-set",
+ "acknowledgeDestinationVolumeDeletion": true
+ }
+}
+```
+
+### Copy Volume Rules
+
+- **Source:** the volume must have a storage device. A replica using the source volume keeps running during the copy, so the copy isn't crash-consistent. Stop the workload first if you need a consistent copy.
+- **Destination:** the destination location doesn't need a volume at that index yet; the copy creates it. The destination GVC must include `targetLocation`. The destination volume ends up the same size as the source.
+- **Same location:** within one volume set, `targetLocation` must differ from `location`. A same-location copy is allowed into another volume set, but only when both GVCs run on the same cluster in that location.
+- **Destination workload:** if a workload replica uses the destination volume, it keeps running on its old volume until the copy is committed. Control Plane then waits for all of the workload's replicas to be ready, restarts that replica onto the copied volume, and deletes the old storage device.
+- **Permissions:** the caller needs `exec.copyVolume` on both the source and target volume sets.
+- **Conflicts:** a copy conflicts with `restoreVolume`, `expandVolume`, `shrinkVolume`, `deleteVolume`, and other `copyVolume` commands on the same volume, at either end. A snapshot of the source can run alongside it. A `deleteVolume` issued on either end while a copy runs isn't refused: it marks the copy `failed`.
+
+### Run a Volume Copy
+
+
+
+ 1. Open the source volume set and go to its **Volumes** page.
+ 2. In the **Actions** menu of the volume to copy, select **Copy Volume**. It is disabled when the volume has no storage device.
+ 3. Under **Destination Volume Set**, leave the field empty to copy into another location of this volume set, or pick any volume set in the org.
+ 4. Choose the **Destination Location**.
+ 5. Tick the acknowledgement that the destination volume will be replaced and its storage device deleted. If the destination is known to hold a storage device, also type the confirmation text shown.
+ 6. Select **Copy**. The copy's progress shows in the **Copy operations** panel on the source volume set's **Volumes** page. A destination in another volume set doesn't list the copy.
+
+
+ Use [`cpln volumeset copy`](/cli-reference/commands/volumeset):
+
+ ```bash
+ cpln volumeset copy my-volume-set --gvc my-gvc --location aws-us-east-2 --target-location aws-us-west-2 --volume-index 0 --acknowledge-destination-deletion
+ ```
+
+ To copy into another volume set, add `--target-volumeset` with its name or link, such as `--target-volumeset //gvc/my-other-gvc/volumeset/my-volume-set`. Without `--volume-index`, every volume in `--location` that has a storage device is copied, each to its own index.
+
+
+ ```bash
+ curl -X POST "https://api.cpln.io/org/my-org/gvc/my-gvc/volumeset/my-volume-set/-command" \
+ -H "Authorization: Bearer $TOKEN" \
+ -H "Content-Type: application/json" \
+ -d @copy.json
+ ```
+
+ `$TOKEN` is a user or [service account](/reference/serviceaccount) token; `cpln profile token` prints one for the current profile.
+
+
+
+### Follow a Volume Copy
+
+A copy is an asynchronous [command](/concepts/command). The API `POST` returns the created command, including its `id`. `cpln volumeset copy` doesn't print the ID, so list the source volume set's running commands to find it:
+
+```bash
+cpln rest get "/org/my-org/gvc/my-gvc/volumeset/my-volume-set/-command?lifecycleStage=running"
+```
+
+Get the command by ID from the source volume set until its `lifecycleStage` is `completed`, `cancelled`, or `failed`:
+
+```bash
+cpln rest get /org/my-org/gvc/my-gvc/volumeset/my-volume-set/-command/COMMAND_ID
+```
+
+Each end of the copy reports its own progress in `status`:
+
+- `status.source.stage`: `capture-source`, `await-receiver`, `apply-source-egress`, `run-bulk-sync`, then `cleanup-k8s`.
+- `status.target.stage`: `prepare-destination`, `apply-receiver`, `await-bulk-sync`, `commit-and-prepare-swap`, `shutdown-target-replica`, `await-target-replica-termination`, then `cleanup-k8s`. `shutdown-target-replica` and `await-target-replica-termination` are skipped when no workload replica uses the destination volume.
+- Either end's stage is `fail` when that end has failed; the command is then cancelled (see [Cancel a Volume Copy](#cancel-a-volume-copy)).
+- `status.source.messages` and `status.target.messages` explain what each end is doing or why it failed.
+- `status.bulkSyncProgressPercent`, `status.bulkSyncTransferredBytes`, `status.bulkSyncRate` and `status.bulkSyncEta` report the transfer's progress. They refresh about every 10 seconds.
+
+The command is `completed` once both ends have finished and cleaned up.
+
+### Cancel a Volume Copy
+
+A copy has no time limit. To stop one that is in progress or taking too long, request its cancellation. This needs `exec.copyVolume` on the source volume set:
+
+```bash
+echo '{"lifecycleStage": "cancellation-requested"}' > cancel.json
+cpln rest patch /org/my-org/gvc/my-gvc/volumeset/my-volume-set/-command/COMMAND_ID --file cancel.json
+```
+
+What a cancellation does depends on how far the copy got:
+
+The copy is committed at the destination's `commit-and-prepare-swap` stage, when the volume set is pointed at the copied volume.
+
+- **Before the copy is committed:** the new volume is deleted, and the destination keeps its original storage device.
+- **After the copy is committed:** the volume set already points at the copied volume, so the copy is rolled forward. The destination replica is moved onto the copied volume, and the old storage device is deleted.
+
+Either way, the command ends in `cancelled` only after both ends have removed the temporary resources they created.
+
+A copy that fails on its own also ends in `cancelled`, not `failed`: a failing end cancels the command so that both ends clean up, with the same before- and after-commit outcomes. Read `status.source.messages` and `status.target.messages` for the reason. The source makes up to 3 bulk-sync attempts before it fails. A copy ends in `failed` only when a `deleteVolume` on either end overrides it.
+
+The console has no cancel action for a copy; use the CLI or API.
+
+### Copy Volume Errors
+
+The `POST` is refused with `400`, `403` or `409` when the copy can't run. The messages include:
+
+| Message | Fix |
+| :------ | :-- |
+| `This command replaces the volume at index N in LOCATION with a copy from LOCATION. ... Set spec.acknowledgeDestinationVolumeDeletion to confirm.` | Set `acknowledgeDestinationVolumeDeletion` to `true` after confirming that the destination volume can be deleted. |
+| `targetLocation must differ from location unless targetVolumeSet names another volume set` | Pick another `targetLocation`, or set `targetVolumeSet` to copy into another volume set. |
+| `targetLocation must differ from location when the copy stays within one volume set` | `targetVolumeSet` names the source volume set itself. Pick another `targetLocation`. |
+| `spec.targetVolumeSet must be //volumeset/, //gvc//volumeset/ or /org/ORG/gvc//volumeset/, got ...` | Use one of the three link forms. |
+| `The volume at index N in LOCATION has no underlying storage device, so there is nothing to copy.` | The source volume has no data yet. Pick a volume that has a storage device. |
+| `GVC GVC is not scheduled in LOCATION, so no actuator there can receive the volume. Add the location to the GVC and retry.` | Add `targetLocation` to the destination GVC's locations. |
+| `spec.targetVolumeSet must be in org ORG; a volume cannot be copied across orgs` | Copy into a volume set in the same org. |
+| `spec.targetVolumeSet ... does not exist` | Check the link, and that the volume set exists and is ready. |
+| `GVC A and GVC B run on different clusters in LOCATION ..., so a same-location copy cannot reach both volumes.` | Copy into another location of the destination GVC. |
+| `Cannot copy into VolumeSet ... because it has a fileSystemType of shared` | Copy into an `ext4` or `xfs` volume set. |
+| `Cannot create a copyVolume command on VolumeSet ... because it has a fileSystemType of shared` | The source is a `shared` volume set, which can't be copied. |
+| `PRINCIPAL is not granted [exec.copyVolume] on VOLUME_SET` (`403`) | Grant `exec.copyVolume` on that volume set. |
+| `There is a conflicting TYPE command for volume: ... (conflicting command id = ID).` (`409`) | Wait for that command to finish, then retry. |
+
## Volume Deletion
To delete a volume, issue a `deleteVolume` command.
@@ -654,16 +818,20 @@ The permissions below are used to define [policies](/reference/policy) together
| create | Create new volumesets | |
| delete | Delete existing volumesets | |
| edit | Modify existing volumesets | view |
-| exec | Execute commands | exec.restoreVolume, exec.createVolumeSnapshot, exec.expandVolume, exec.deleteVolume, exec.deleteVolumeSnapshot, exec.shrinkVolume |
+| exec | Execute commands | exec.restoreVolume, exec.createVolumeSnapshot, exec.expandVolume, exec.deleteVolume, exec.deleteVolumeSnapshot, exec.deleteOrphanedVolume |
+| exec.copyVolume | Copy a volume into another location | |
| exec.createVolumeSnapshot | Create a snapshot of a volume | |
+| exec.deleteOrphanedVolume | Delete a volume which is no longer in the cluster | |
| exec.deleteVolume | Delete a volume | |
| exec.deleteVolumeSnapshot | Delete a volume snapshot | |
| exec.expandVolume | Increase the storage capacity of a volume | |
| exec.restoreVolume | Restore a volume to a snapshot | |
| exec.shrinkVolume | Shrink a volume (causes data loss) | |
-| manage | Full access | create, delete, edit, exec, exec.createVolumeSnapshot, exec.deleteVolume, exec.deleteVolumeSnapshot, exec.expandVolume, exec.restoreVolume, exec.shrinkVolume, manage, view |
+| manage | Full access | create, delete, edit, exec, exec.copyVolume, exec.createVolumeSnapshot, exec.deleteOrphanedVolume, exec.deleteVolume, exec.deleteVolumeSnapshot, exec.expandVolume, exec.restoreVolume, exec.shrinkVolume, view |
| view | Read-only access | |
+`exec` doesn't include `exec.copyVolume` or `exec.shrinkVolume`. Both destroy a storage device, so they must be granted explicitly or through `manage`.
+
## Access Report
Displays the permissions granted to principals for the volume set. See [Access Report](/concepts/access-control#access-report) for what each entry shows, how to open it from the Console, CLI, or API, and how groups, query targets, and implied permissions appear in it.
diff --git a/skill.md b/skill.md
index af47f151..65afd5f7 100644
--- a/skill.md
+++ b/skill.md
@@ -134,7 +134,7 @@ A workload without firewall config cannot reach its database, be reached by user
Before any destructive operation, present a structured summary AND wait for explicit confirmation — **even when permissions auto-approve.** Permission mode is tool-prompt UX; this is conversation-level safety, independent.
-- **Always destructive**: any `cpln delete`, `gvc delete-all-workloads`, `volumeset shrink`, `volumeset snapshot delete`, `volumeset volume delete`.
+- **Always destructive**: any `cpln delete`, `gvc delete-all-workloads`, `volumeset shrink`, `volumeset snapshot delete`, `volumeset volume delete`, and `volumeset copy` (it deletes the destination volume's storage device).
- **Service-disrupting**: `policy remove-binding` (breaks runtime access), `serviceaccount remove-key` (breaks CI/CD), `group remove-member` (locks users out), `gvc remove-location` (forces redeployment).
- **Implicit destructive (immutability traps)**: org delete impossible; workload type/name immutable (rename via `cpln workload clone OLD --name NEW --gvc GVC`); volume set `fileSystemType` and `performanceClass` immutable; `cpln apply` of a renamed resource creates a new one (old must be deleted with `cpln delete --file ...`).
@@ -543,6 +543,7 @@ Performance classes: `general-purpose-ssd` (min 10 GB), `high-throughput-ssd` (*
- **Both `fileSystemType` AND `performanceClass` are immutable.** Changing either = delete + recreate (data loss — guardrail #6).
- `shrinkVolume` destroys data; volumes can be expanded only once every 6 hours.
+- `copyVolume` replaces the destination volume and deletes its storage device; it needs `acknowledgeDestinationVolumeDeletion: true` and `exec.copyVolume` (not in `exec`).
- `createFinalSnapshot: true` (default) auto-snapshots before any volume deletion — leave on.
For autoscaling, predictive scaling, mount options on `shared`, and AWS KMS custom encryption, fetch [/reference/volumeset](https://docs.controlplane.com/reference/volumeset).