From 335a959eea9aaf6e33011972a54499f1cc56c8b7 Mon Sep 17 00:00:00 2001 From: Chris Goller Date: Thu, 16 May 2024 12:37:28 -0500 Subject: [PATCH] feat: handle creating and removing snapshots and clones Signed-off-by: Chris Goller --- proto/depot/cloud/v2/cloud.proto | 28 +++++ src/handlers/volumes.ts | 89 ++++++++++++- src/proto/depot/cloud/v2/cloud_pb.ts | 180 +++++++++++++++++++++++++++ src/utils/ceph.ts | 59 ++++----- 4 files changed, 318 insertions(+), 38 deletions(-) diff --git a/proto/depot/cloud/v2/cloud.proto b/proto/depot/cloud/v2/cloud.proto index 9045a82..94ac363 100644 --- a/proto/depot/cloud/v2/cloud.proto +++ b/proto/depot/cloud/v2/cloud.proto @@ -195,6 +195,8 @@ message ReconcileVolumesResponse { DeleteClientAction delete_client = 6; TrimVolumeAction trim_volume = 7; + + CopyVolumeAction copy_volume = 8; } } @@ -209,6 +211,7 @@ message ReportVolumeUpdatesRequest { DeleteClientUpdate delete_client = 6; TrimVolumeUpdate trim_volume = 7; + CopyVolumeUpdate copy_volume = 8; } } @@ -289,3 +292,28 @@ message AuthorizeClientUpdate { string volume_name = 2; string image_spec = 3; } + +message CopyVolumeAction { + enum Kind { + KIND_UNSPECIFIED = 0; + KIND_SNAPSHOT = 1; + KIND_CLONE = 2; + } + + Kind kind = 1; + string volume_name = 2; + // The parent spec is the volume id of the volume that the snapshot or clone is a child of. + string parent_image_spec = 3; +} + +message CopyVolumeUpdate { + enum Kind { + KIND_UNSPECIFIED = 0; + KIND_SNAPSHOT = 1; + KIND_CLONE = 2; + } + Kind kind = 1; + string volume_name = 2; + string image_spec = 3; + string parent_image_spec = 4; +} diff --git a/src/handlers/volumes.ts b/src/handlers/volumes.ts index 4c61047..cba9e88 100644 --- a/src/handlers/volumes.ts +++ b/src/handlers/volumes.ts @@ -1,6 +1,9 @@ import {PlainMessage} from '@bufbuild/protobuf' import { AuthorizeClientAction, + CopyVolumeAction, + CopyVolumeAction_Kind, + CopyVolumeUpdate_Kind, CreateClientAction, CreateVolumeAction, DeleteClientAction, @@ -17,14 +20,20 @@ import { cephConfig, createAuthEntity, createBlockDevice, + createClone, createNamespace, + createSnapshot, enableCephMetrics, imageRm, namespaceRm, newClientName, + newCloneSpec, newImageSpec, newOsdProfile, newPoolSpec, + newSnapshotSpec, + snapshotFromImageSpec, + snapshotRm, sparsify, } from '../utils/ceph' import {reportError} from '../utils/errors' @@ -70,6 +79,8 @@ async function handleAction( switch (action.case) { case 'createVolume': return await createVolume(action.value) + case 'copyVolume': + return await copyVolume(action.value) case 'resizeVolume': return await resizeVolume(action.value) case 'trimVolume': @@ -110,6 +121,70 @@ async function createVolume({volumeName, size}: CreateVolumeAction): Promise | null> { + switch (kind) { + case CopyVolumeAction_Kind.SNAPSHOT: + return await snapshotVolume(volumeName, parentImageSpec) + case CopyVolumeAction_Kind.CLONE: + return await cloneVolume(volumeName, parentImageSpec) + default: + return null + } +} + +async function snapshotVolume( + volumeName: string, + parentImage: string, +): Promise> { + const parentImageSpec = newImageSpec(parentImage) + const snapshotSpec = snapshotFromImageSpec(parentImageSpec, volumeName) + await createSnapshot(snapshotSpec) + + return { + update: { + case: 'copyVolume', + value: { + kind: CopyVolumeUpdate_Kind.SNAPSHOT, + volumeName, + imageSpec: snapshotSpec, + parentImageSpec, + }, + }, + } +} + +async function cloneVolume( + volumeName: string, + parentImageSpec: string, +): Promise | null> { + // PRECONDITION: parent name is a snapshot name with the `@` symbol. + if (!parentImageSpec.includes('@')) { + console.error(`Invalid snapshot name: ${parentImageSpec}`) + return null + } + const [snapshotParentName] = parentImageSpec.split('@') + const snapshotSpec = newSnapshotSpec(parentImageSpec) + + const cloneSpec = newCloneSpec(newPoolSpec(snapshotParentName), volumeName) + await createClone(snapshotSpec, cloneSpec) + + return { + update: { + case: 'copyVolume', + value: { + kind: CopyVolumeUpdate_Kind.CLONE, + volumeName, + imageSpec: cloneSpec, + parentImageSpec, + }, + }, + } +} + async function resizeVolume(_action: ResizeVolumeAction) { // TODO: resize volume return null @@ -143,8 +218,20 @@ async function trimVolume({volumeName}: TrimVolumeAction): Promise> { +}: DeleteVolumeAction): Promise | null> { if (imageSpec) { + if (imageSpec.includes('@')) { + const snapshotSpec = newSnapshotSpec(imageSpec) + await snapshotRm(snapshotSpec) + return { + update: { + case: 'deleteVolume', + value: { + volumeName, + }, + }, + } + } await imageRm(newImageSpec(imageSpec)) } else { await imageRm(newImageSpec(volumeName)) diff --git a/src/proto/depot/cloud/v2/cloud_pb.ts b/src/proto/depot/cloud/v2/cloud_pb.ts index bdd0419..6249f8b 100644 --- a/src/proto/depot/cloud/v2/cloud_pb.ts +++ b/src/proto/depot/cloud/v2/cloud_pb.ts @@ -1408,6 +1408,13 @@ export class ReconcileVolumesResponse extends Message value: TrimVolumeAction case: 'trimVolume' } + | { + /** + * @generated from field: depot.cloud.v2.CopyVolumeAction copy_volume = 8; + */ + value: CopyVolumeAction + case: 'copyVolume' + } | {case: undefined; value?: undefined} = {case: undefined} constructor(data?: PartialMessage) { @@ -1425,6 +1432,7 @@ export class ReconcileVolumesResponse extends Message {no: 5, name: 'authorize_client', kind: 'message', T: AuthorizeClientAction, oneof: 'action'}, {no: 6, name: 'delete_client', kind: 'message', T: DeleteClientAction, oneof: 'action'}, {no: 7, name: 'trim_volume', kind: 'message', T: TrimVolumeAction, oneof: 'action'}, + {no: 8, name: 'copy_volume', kind: 'message', T: CopyVolumeAction, oneof: 'action'}, ]) static fromBinary(bytes: Uint8Array, options?: Partial): ReconcileVolumesResponse { @@ -1504,6 +1512,13 @@ export class ReportVolumeUpdatesRequest extends Message) { @@ -1521,6 +1536,7 @@ export class ReportVolumeUpdatesRequest extends Message): ReportVolumeUpdatesRequest { @@ -2218,3 +2234,167 @@ export class AuthorizeClientUpdate extends Message { return proto3.util.equals(AuthorizeClientUpdate, a, b) } } + +/** + * @generated from message depot.cloud.v2.CopyVolumeAction + */ +export class CopyVolumeAction extends Message { + /** + * @generated from field: depot.cloud.v2.CopyVolumeAction.Kind kind = 1; + */ + kind = CopyVolumeAction_Kind.UNSPECIFIED + + /** + * @generated from field: string volume_name = 2; + */ + volumeName = '' + + /** + * The parent spec is the volume id of the volume that the snapshot or clone is a child of. + * + * @generated from field: string parent_image_spec = 3; + */ + parentImageSpec = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'depot.cloud.v2.CopyVolumeAction' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + {no: 1, name: 'kind', kind: 'enum', T: proto3.getEnumType(CopyVolumeAction_Kind)}, + {no: 2, name: 'volume_name', kind: 'scalar', T: 9 /* ScalarType.STRING */}, + {no: 3, name: 'parent_image_spec', kind: 'scalar', T: 9 /* ScalarType.STRING */}, + ]) + + static fromBinary(bytes: Uint8Array, options?: Partial): CopyVolumeAction { + return new CopyVolumeAction().fromBinary(bytes, options) + } + + static fromJson(jsonValue: JsonValue, options?: Partial): CopyVolumeAction { + return new CopyVolumeAction().fromJson(jsonValue, options) + } + + static fromJsonString(jsonString: string, options?: Partial): CopyVolumeAction { + return new CopyVolumeAction().fromJsonString(jsonString, options) + } + + static equals( + a: CopyVolumeAction | PlainMessage | undefined, + b: CopyVolumeAction | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(CopyVolumeAction, a, b) + } +} + +/** + * @generated from enum depot.cloud.v2.CopyVolumeAction.Kind + */ +export enum CopyVolumeAction_Kind { + /** + * @generated from enum value: KIND_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: KIND_SNAPSHOT = 1; + */ + SNAPSHOT = 1, + + /** + * @generated from enum value: KIND_CLONE = 2; + */ + CLONE = 2, +} +// Retrieve enum metadata with: proto3.getEnumType(CopyVolumeAction_Kind) +proto3.util.setEnumType(CopyVolumeAction_Kind, 'depot.cloud.v2.CopyVolumeAction.Kind', [ + {no: 0, name: 'KIND_UNSPECIFIED'}, + {no: 1, name: 'KIND_SNAPSHOT'}, + {no: 2, name: 'KIND_CLONE'}, +]) + +/** + * @generated from message depot.cloud.v2.CopyVolumeUpdate + */ +export class CopyVolumeUpdate extends Message { + /** + * @generated from field: depot.cloud.v2.CopyVolumeUpdate.Kind kind = 1; + */ + kind = CopyVolumeUpdate_Kind.UNSPECIFIED + + /** + * @generated from field: string volume_name = 2; + */ + volumeName = '' + + /** + * @generated from field: string image_spec = 3; + */ + imageSpec = '' + + /** + * @generated from field: string parent_image_spec = 4; + */ + parentImageSpec = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'depot.cloud.v2.CopyVolumeUpdate' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + {no: 1, name: 'kind', kind: 'enum', T: proto3.getEnumType(CopyVolumeUpdate_Kind)}, + {no: 2, name: 'volume_name', kind: 'scalar', T: 9 /* ScalarType.STRING */}, + {no: 3, name: 'image_spec', kind: 'scalar', T: 9 /* ScalarType.STRING */}, + {no: 4, name: 'parent_image_spec', kind: 'scalar', T: 9 /* ScalarType.STRING */}, + ]) + + static fromBinary(bytes: Uint8Array, options?: Partial): CopyVolumeUpdate { + return new CopyVolumeUpdate().fromBinary(bytes, options) + } + + static fromJson(jsonValue: JsonValue, options?: Partial): CopyVolumeUpdate { + return new CopyVolumeUpdate().fromJson(jsonValue, options) + } + + static fromJsonString(jsonString: string, options?: Partial): CopyVolumeUpdate { + return new CopyVolumeUpdate().fromJsonString(jsonString, options) + } + + static equals( + a: CopyVolumeUpdate | PlainMessage | undefined, + b: CopyVolumeUpdate | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(CopyVolumeUpdate, a, b) + } +} + +/** + * @generated from enum depot.cloud.v2.CopyVolumeUpdate.Kind + */ +export enum CopyVolumeUpdate_Kind { + /** + * @generated from enum value: KIND_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: KIND_SNAPSHOT = 1; + */ + SNAPSHOT = 1, + + /** + * @generated from enum value: KIND_CLONE = 2; + */ + CLONE = 2, +} +// Retrieve enum metadata with: proto3.getEnumType(CopyVolumeUpdate_Kind) +proto3.util.setEnumType(CopyVolumeUpdate_Kind, 'depot.cloud.v2.CopyVolumeUpdate.Kind', [ + {no: 0, name: 'KIND_UNSPECIFIED'}, + {no: 1, name: 'KIND_SNAPSHOT'}, + {no: 2, name: 'KIND_CLONE'}, +]) diff --git a/src/utils/ceph.ts b/src/utils/ceph.ts index 26c8a9c..7d369c8 100644 --- a/src/utils/ceph.ts +++ b/src/utils/ceph.ts @@ -8,7 +8,7 @@ type PoolSpec = string & {readonly $type: unique symbol} type ImageSpec = string & {readonly $type: unique symbol} type CloneSpec = string & {readonly $type: unique symbol} type ClientName = string & {readonly $type: unique symbol} -type SnapshotName = string & {readonly $type: unique symbol} +type SnapshotSpec = string & {readonly $type: unique symbol} type OsdProfile = string & {readonly $type: unique symbol} export function newPoolSpec(volumeName: string): PoolSpec { @@ -36,8 +36,12 @@ export function newOsdProfile(volumeName: string): OsdProfile { return `profile rbd pool=${POOL} namespace=${volumeName}` as OsdProfile } -export function newSnapshotName(snapshotName: string): SnapshotName { - return snapshotName as SnapshotName +export function newSnapshotSpec(snapshotName: string): SnapshotSpec { + return snapshotName as SnapshotSpec +} + +export function snapshotFromImageSpec(imageSpec: ImageSpec, snapshotName: string): SnapshotSpec { + return `${imageSpec}@${snapshotName}` as SnapshotSpec } /*** Low-level Ceph functions ***/ @@ -71,16 +75,12 @@ export async function createBlockDevice(imageSpec: ImageSpec, gigabytes: number) throw new Error(stderr) } -export async function createSnapshot(imageSpec: ImageSpec, snapshotName: SnapshotName) { - console.log('Creating ceph snapshot', imageSpec, snapshotName) - const {exitCode, stderr} = await execa( - 'rbd', - ['snap', 'create', imageSpec, '--snap', snapshotName, '--no-progress'], - { - reject: false, - stdio: 'inherit', - }, - ) +export async function createSnapshot(snapshotSpec: SnapshotSpec) { + console.log('Creating ceph snapshot', snapshotSpec) + const {exitCode, stderr} = await execa('rbd', ['snap', 'create', snapshotSpec, '--no-progress'], { + reject: false, + stdio: 'inherit', + }) // 17 is "already exists" a.k.a EEXIST. if (exitCode === 0 || exitCode === 17) { return @@ -89,16 +89,12 @@ export async function createSnapshot(imageSpec: ImageSpec, snapshotName: Snapsho throw new Error(stderr) } -export async function createClone(imageSpec: ImageSpec, snapshotName: SnapshotName, cloneSpec: CloneSpec) { - console.log('Creating ceph clone', imageSpec, snapshotName, cloneSpec) - const {exitCode, stderr} = await execa( - 'rbd', - ['clone', imageSpec, '--snap', snapshotName, cloneSpec, '--rbd-default-clone-format=2'], - { - reject: false, - stdio: 'inherit', - }, - ) +export async function createClone(snapshotSpec: SnapshotSpec, cloneSpec: CloneSpec) { + console.log('Creating ceph clone', snapshotSpec, cloneSpec) + const {exitCode, stderr} = await execa('rbd', ['clone', snapshotSpec, cloneSpec, '--rbd-default-clone-format=2'], { + reject: false, + stdio: 'inherit', + }) // 17 is "already exists" a.k.a EEXIST. if (exitCode === 0 || exitCode === 17) { return @@ -167,9 +163,9 @@ export async function imageRm(imageSpec: ImageSpec) { throw new Error(stderr) } -export async function snapshotRm(imageSpec: ImageSpec, snapshotName: SnapshotName) { - console.log('Removing ceph snapshot', imageSpec, snapshotName) - const {exitCode, stderr} = await execa('rbd', ['snap', 'rm', imageSpec, '--snap', snapshotName, '--no-progress'], { +export async function snapshotRm(snapshotSpec: SnapshotSpec) { + console.log('Removing ceph snapshot', snapshotSpec) + const {exitCode, stderr} = await execa('rbd', ['snap', 'rm', snapshotSpec, '--no-progress'], { reject: false, stdio: 'inherit', }) @@ -181,17 +177,6 @@ export async function snapshotRm(imageSpec: ImageSpec, snapshotName: SnapshotNam throw new Error(stderr) } -export async function cloneRm(cloneSpec: CloneSpec) { - console.log('Removing ceph clone', cloneSpec) - const {exitCode, stderr} = await execa('rbd', ['rm', '--no-progress', cloneSpec], {reject: false, stdio: 'inherit'}) - // 2 is "image does not exist" a.k.a ENOENT. - if (exitCode === 0 || exitCode === 2) { - return - } - - throw new Error(stderr) -} - export async function namespaceRm(poolSpec: PoolSpec) { console.log('Removing ceph namespace', poolSpec) const {exitCode, stderr} = await execa('rbd', ['namespace', 'rm', poolSpec], {