From ae6fffc5497229328a6f44458d4440a05cf72cf4 Mon Sep 17 00:00:00 2001 From: sdairs Date: Tue, 20 Oct 2026 12:25:49 +0100 Subject: [PATCH] fix(api): refresh drift snapshot and preserve AWS backup external IDs --- crates/clickhouse-cloud-api/README.md | 2 + .../clickhouse_cloud_openapi.json | 325 +++++++++++++++++- crates/clickhouse-cloud-api/src/meta.rs | 1 + .../src/meta/operations.rs | 15 +- .../src/models/backups.rs | 65 +++- .../clickhouse-cloud-api/tests/client_test.rs | 23 +- .../clickhouse-cloud-api/tests/models_test.rs | 75 ++++ 7 files changed, 489 insertions(+), 17 deletions(-) diff --git a/crates/clickhouse-cloud-api/README.md b/crates/clickhouse-cloud-api/README.md index d1bd5818..08909e4f 100644 --- a/crates/clickhouse-cloud-api/README.md +++ b/crates/clickhouse-cloud-api/README.md @@ -72,6 +72,8 @@ The beta `replica_aware_routing_get` and `replica_aware_routing_update` service The MySQL and Postgres ClickPipe source authentication enums (`ClickPipeMutateMySQLSourceAuthentication`, `ClickPipeMySQLSourceAuthentication`, `ClickPipePatchMySQLSourceAuthentication`, `ClickPipeMutatePostgresSourceAuthentication`, `ClickPipePostgresSourceAuthentication`) gain `ServiceAccountWorkloadIdentity` (`SERVICE_ACCOUNT_WORKLOAD_IDENTITY`), and the MySQL source type enums gain `Cloudsqlmysql` (`cloudsqlmysql`); exhaustive `match`es over them need the new arm. `ClickPipeMongoDBPipeTableMapping` gains `structured_ingestion` (`ClickPipeMongoDBStructuredIngestion`: required `enabled`, plus optional `columns` of `ClickPipeMongoDBPipeColumn` `name`/`type` pairs, `drop_unexpected_values`, `excluded_columns`, `sorting_keys`, and `partition_by_expr`) to replicate a collection into typed columns instead of one JSON column; responses use the all-`Option` `ClickPipeMongoDBStructuredIngestionResponse` and `ClickPipeMongoDBPipeColumnResponse`. Struct-literal callers of the table mapping need `structured_ingestion: None` or `..Default::default()`. +AWS backup-bucket models now carry `iam_role_external_id` (`iamRoleExternalId`). `AwsBackupBucket` responses tolerate missing and null IDs. The current spec requires a string on `AwsBackupBucketPostRequestV1` and `AwsBackupBucketProperties`; supply it when constructing or deserializing these request types. `AwsBackupBucketPatchRequestV1.iam_role_external_id` distinguishes omission (`None`, preserve), a value (`Some(Some(id))`, replace), and explicit null (`Some(None)`, clear). Its deserializer preserves all three states. Regenerated operation metadata also adds `control-plane:service:manage` to `INSTANCE_CREATE`, alongside `control-plane:organization:create-service`. + The Query API endpoint management methods are `query_api_endpoint_create`, `query_api_endpoint_get`, `query_api_endpoint_list`, `query_api_endpoint_update`, and `query_api_endpoint_delete`. Create and update take `PublicQueryApiEndpointRequest`; list accepts an optional cursor and limit (1–100) and returns `ApiResponse>`. Read entries from `result` and pagination from the envelope's `next_cursor`, `limit`, and `total_count`. The earlier `QueryApiEndpointListResponse` container remains available for existing callers, but is no longer the list method's return payload. User-owned endpoints can be listed and read, but cannot be updated or deleted through this API. The saved query methods (no longer beta) are `saved_query_create`, `saved_query_get`, `saved_query_list`, `saved_query_update`, and `saved_query_delete`, scoped to a service. Create and update take `PublicSavedQueryRequest` (update replaces the whole query); get returns `PublicSavedQuery` and list returns `PublicSavedQueryListItem` entries. List accepts an optional cursor and limit (1–100) and reports pagination on the envelope: `ApiResponse::next_cursor` (`None` on the last page), `limit`, and `total_count`. diff --git a/crates/clickhouse-cloud-api/clickhouse_cloud_openapi.json b/crates/clickhouse-cloud-api/clickhouse_cloud_openapi.json index eb15abf8..0b6faf1a 100644 --- a/crates/clickhouse-cloud-api/clickhouse_cloud_openapi.json +++ b/crates/clickhouse-cloud-api/clickhouse_cloud_openapi.json @@ -5,7 +5,7 @@ "version": "1.0", "contact": { "name": "ClickHouse Support", - "url": "https://clickhouse.com/docs/en/cloud/manage/openapi?referrer=openapi-1242575", + "url": "https://clickhouse.com/docs/en/cloud/manage/openapi?referrer=openapi-1256081", "email": "support@clickhouse.com" } }, @@ -1883,7 +1883,8 @@ "security": [ { "basicAuth": [ - "control-plane:organization:create-service" + "control-plane:organization:create-service", + "control-plane:service:manage" ] } ], @@ -8407,6 +8408,200 @@ ] } }, + "/v1/organizations/{organizationId}/notifications": { + "get": { + "summary": "List of organization notifications", + "description": "**Disclaimer:** This beta endpoint is evolving; the API contract may change.

Returns notifications for the organization, most recent first. Only notifications about resources the caller can access are returned, so two keys in the same organization can see different results. The [notification types endpoint](#tag/Notifications/operation/notificationTypesGetList) lists the types this organization can receive, so it shows what to expect here. Notifications are retained for 180 days. A page holds at most `limit` notifications (default 100, maximum 250; a larger value is clamped) and the server may return fewer. Every response carries `limit` and `nextCursor`; pass `nextCursor` as the `cursor` query parameter to fetch the next page, repeating until it is null.", + "operationId": "notificationGetList", + "parameters": [ + { + "in": "path", + "name": "organizationId", + "description": "ID of the organization the notifications belong to.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "query", + "name": "limit", + "description": "Maximum number of results to return.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 250, + "default": 100 + } + }, + { + "in": "query", + "name": "cursor", + "description": "Opaque cursor from a previous response's `nextCursor`, marking where to resume the list.", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "severity", + "description": "Filter to notifications of this severity: 'info', 'warning', 'critical'.", + "schema": { + "type": "string", + "example": "warning" + } + }, + { + "in": "query", + "name": "type", + "description": "Filter to notifications of this type. New types are added over time; the [notification types endpoint](#tag/Notifications/operation/notificationTypesGetList) lists the ones this organization can receive.", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "resource_id", + "description": "Filter to notifications about this resource. A service also matches the notifications of its ClickPipes; pass `resource_type` to narrow to one kind of resource.", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "resource_type", + "description": "Filter to notifications about resources of this kind. One of 'service', 'organization', 'clickpipe', 'postgres'.", + "schema": { + "type": "string", + "example": "service" + } + }, + { + "in": "query", + "name": "from_date", + "description": "Return notifications created at or after this time. An RFC 3339 timestamp, not a bare date.", + "schema": { + "type": "string", + "format": "date-time", + "example": "2026-10-01T00:00:00Z" + } + }, + { + "in": "query", + "name": "to_date", + "description": "Return notifications created at or before this time. An RFC 3339 timestamp, not a bare date.", + "schema": { + "type": "string", + "format": "date-time", + "example": "2026-10-08T00:00:00Z" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "number", + "description": "HTTP status code.", + "example": 200 + }, + "requestId": { + "type": "string", + "description": "Unique id assigned to every request. UUIDv4", + "format": "uuid" + }, + "result": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Notification" + } + }, + "limit": { + "type": "integer", + "description": "Maximum number of results returned in this page." + }, + "nextCursor": { + "type": [ + "string", + "null" + ], + "description": "Cursor for the next page, to be sent as the `cursor` query parameter. Null on the last page." + } + } + } + } + } + }, + "400": { + "description": "The request cannot be processed due to a client error. Please verify your request parameters and try again.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "number", + "description": "HTTP status code.", + "example": 400 + }, + "error": { + "type": "string", + "description": "Detailed error description." + }, + "requestId": { + "type": "string", + "description": "Unique id assigned to every request. UUIDv4", + "format": "uuid" + } + } + } + } + } + }, + "500": { + "description": "An internal server error has occurred. If this issue persists, please contact ClickHouse Cloud support for assistance.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "integer", + "description": "HTTP status code.", + "example": 500 + }, + "error": { + "type": "string", + "description": "Detailed error description." + }, + "requestId": { + "type": "string", + "description": "Unique id assigned to every request. UUIDv4", + "format": "uuid" + } + } + } + } + } + } + }, + "tags": [ + "Notifications" + ], + "x-badges": [ + { + "name": "Beta", + "position": "after" + } + ] + } + }, "/v1/organizations/{organizationId}/notificationTypes": { "get": { "summary": "List notification types", @@ -29050,11 +29245,38 @@ "ClickPipeBigQueryPipeSettings": { "properties": { "replicationMode": { - "description": "Replication mode. BigQuery only supports snapshot mode.", + "description": "Replication mode: \"cdc\" (change data capture with initial snapshot), \"snapshot\" (one-time snapshot only), or \"cdc_only\" (CDC without initial snapshot).", "type": "string", - "enum": [ - "snapshot" - ] + "example": "cdc" + }, + "replicationMethod": { + "description": "How changes are captured from BigQuery. \"query_based\" polls each table using a watermark timestamp column, \"events_based\" reads the BigQuery change history using the APPENDS or CHANGES table function. Required for \"cdc\" and \"cdc_only\" replication modes and not allowed for \"snapshot\".", + "type": "string", + "example": "query_based" + }, + "syncIntervalSeconds": { + "description": "Interval in seconds between CDC syncs. Not allowed for \"snapshot\" replication mode.", + "type": "integer", + "minimum": 1, + "example": 60 + }, + "queryCdcPullSyncParallelism": { + "description": "Number of tables pulled in parallel during a CDC sync. Not allowed for \"snapshot\" replication mode.", + "type": "integer", + "minimum": 1, + "example": 4 + }, + "syncDelaySeconds": { + "description": "Each CDC sync reads data only up to now minus this delay, giving recently written rows time to become visible in BigQuery. Increase it if your tables are loaded via batch jobs or Google Cloud Dataflow; if set too low, late-visible rows may be skipped. Not allowed for \"snapshot\" replication mode.", + "type": "integer", + "minimum": 0, + "example": 60 + }, + "pullWindowSizeSeconds": { + "description": "Maximum amount of time one pull (query) can cover. Applies when the pipe has fallen behind, e.g. on the first CDC sync or when resuming after a pause: with a 6 hour limit, a pipe that is 3 days behind catches up in 12 smaller pulls instead of one large query. If a pull fails, only that pull is retried. Not allowed for \"snapshot\" replication mode.", + "type": "integer", + "minimum": 1, + "example": 86400 }, "allowNullableColumns": { "description": "Allow nullable columns in the destination table.", @@ -29117,6 +29339,15 @@ "ReplacingMergeTree", "Null" ] + }, + "queryCdcWatermarkColumn": { + "description": "Source column of type TIMESTAMP used as the CDC watermark. Required for every table when replicationMethod is \"query_based\"; not allowed otherwise.", + "type": "string" + }, + "eventsFunction": { + "description": "BigQuery table function used to read changes: \"appends\" (newly appended rows) or \"changes\" (all row changes). Required for every table when replicationMethod is \"events_based\"; not allowed otherwise.", + "type": "string", + "example": "appends" } }, "required": [ @@ -30371,7 +30602,7 @@ "example": 4 }, "clickhouse_parallel_distributed_insert_select": { - "description": "Parallel distributed insert select. Parallel distributed insert select setting", + "description": "Parallel distributed insert select. Only applies when object_storage_use_cluster_function is enabled. When disabled, this setting is ignored and the effective value is 0.", "type": [ "integer", "null" @@ -30669,7 +30900,7 @@ "example": 4 }, "clickhouse_parallel_distributed_insert_select": { - "description": "Parallel distributed insert select. Parallel distributed insert select setting", + "description": "Parallel distributed insert select. Only applies when object_storage_use_cluster_function is enabled. When disabled, this setting is ignored and the effective value is 0.", "type": "integer", "minimum": 0, "maximum": 2, @@ -31013,6 +31244,10 @@ "iamRoleSessionName": { "description": "AWS Role session name", "type": "string" + }, + "iamRoleExternalId": { + "description": "External ID the IAM role trust policy requires when the data plane assumes the role", + "type": "string" } } }, @@ -31093,6 +31328,10 @@ "iamRoleSessionName": { "description": "AWS IAM Role", "type": "string" + }, + "iamRoleExternalId": { + "description": "External ID the IAM role trust policy requires when the data plane assumes the role", + "type": "string" } } }, @@ -31163,6 +31402,10 @@ "iamRoleSessionName": { "description": "AWS Role session name", "type": "string" + }, + "iamRoleExternalId": { + "description": "External ID the IAM role trust policy requires when the data plane assumes the role", + "type": "string" } } }, @@ -31244,6 +31487,13 @@ "string", "null" ] + }, + "iamRoleExternalId": { + "description": "External ID the IAM role trust policy requires when the data plane assumes the role. Set to `null` to clear it.", + "type": [ + "string", + "null" + ] } } }, @@ -31450,6 +31700,65 @@ } } }, + "Notification": { + "properties": { + "id": { + "description": "Unique notification ID.", + "type": "string", + "format": "uuid", + "example": "1b7e0a94-2d0f-4c3a-8f1e-6c9b2d5a4e7f" + }, + "type": { + "description": "Notification type. New types are added over time, so treat this as an open set.", + "type": "string", + "example": "service_backup_failed" + }, + "severity": { + "description": "Severity of the notification. One of 'info', 'warning', 'critical'.", + "type": "string", + "example": "warning" + }, + "resourceType": { + "description": "Kind of resource the notification is about. One of 'service', 'organization', 'clickpipe', 'postgres'.", + "type": "string", + "example": "service" + }, + "resourceId": { + "description": "ID of the resource the notification is about.", + "type": "string" + }, + "resourceName": { + "description": "Name the resource had when the notification was created. Absent for most notification types; use `resourceId` to identify the resource.", + "type": "string" + }, + "createdAt": { + "description": "When the notification was created. ISO-8601.", + "type": "string", + "format": "date-time", + "example": "2026-10-07T09:30:00Z" + }, + "title": { + "description": "Short human-readable summary.", + "type": "string", + "example": "Backup failed for analytics-prod" + }, + "description": { + "description": "Human-readable body as plain text, with any links written as \"label (url)\".", + "type": "string", + "example": "A scheduled backup for analytics-prod did not complete. Review the backup settings (https://console.clickhouse.cloud/services/abc/backups)." + } + }, + "required": [ + "id", + "type", + "severity", + "resourceType", + "resourceId", + "createdAt", + "title", + "description" + ] + }, "SnapshotConfiguration": { "properties": { "enabled": { diff --git a/crates/clickhouse-cloud-api/src/meta.rs b/crates/clickhouse-cloud-api/src/meta.rs index cb18950c..3ba12d27 100644 --- a/crates/clickhouse-cloud-api/src/meta.rs +++ b/crates/clickhouse-cloud-api/src/meta.rs @@ -90,6 +90,7 @@ pub const BETA_OPERATIONS: &[&str] = &[ "click_stack_update_webhook", "click_stack_validate_dashboard", "credit_balances_get", + "notification_get_list", "notification_types_get_list", "organization_byoc_infrastructure_get", "organization_byoc_infrastructure_private_endpoint_config_get", diff --git a/crates/clickhouse-cloud-api/src/meta/operations.rs b/crates/clickhouse-cloud-api/src/meta/operations.rs index cf388fe7..be3f4067 100644 --- a/crates/clickhouse-cloud-api/src/meta/operations.rs +++ b/crates/clickhouse-cloud-api/src/meta/operations.rs @@ -549,7 +549,10 @@ pub const INSTANCE_CREATE: OperationMetadata = OperationMetadata { rust_method: "instance_create", method: "POST", path: "/v1/organizations/{organizationId}/services", - required_permissions: &["control-plane:organization:create-service"], + required_permissions: &[ + "control-plane:organization:create-service", + "control-plane:service:manage", + ], }; /// `DELETE /v1/organizations/{organizationId}/services/{serviceId}` (`instanceDelete`). @@ -750,6 +753,15 @@ pub const MEMBER_UPDATE: OperationMetadata = OperationMetadata { required_permissions: &["control-plane:organization:manage"], }; +/// `GET /v1/organizations/{organizationId}/notifications` (`notificationGetList`). +pub const NOTIFICATION_GET_LIST: OperationMetadata = OperationMetadata { + operation_id: "notificationGetList", + rust_method: "notification_get_list", + method: "GET", + path: "/v1/organizations/{organizationId}/notifications", + required_permissions: &[], +}; + /// `GET /v1/organizations/{organizationId}/notificationTypes` (`notificationTypesGetList`). pub const NOTIFICATION_TYPES_GET_LIST: OperationMetadata = OperationMetadata { operation_id: "notificationTypesGetList", @@ -1643,6 +1655,7 @@ pub const ALL: &[OperationMetadata] = &[ MEMBER_GET, MEMBER_GET_LIST, MEMBER_UPDATE, + NOTIFICATION_GET_LIST, NOTIFICATION_TYPES_GET_LIST, OPENAPI_KEY_CREATE, OPENAPI_KEY_DELETE, diff --git a/crates/clickhouse-cloud-api/src/models/backups.rs b/crates/clickhouse-cloud-api/src/models/backups.rs index 9407d64c..f1da5ba8 100644 --- a/crates/clickhouse-cloud-api/src/models/backups.rs +++ b/crates/clickhouse-cloud-api/src/models/backups.rs @@ -540,6 +540,8 @@ pub struct AwsBackupBucket { pub bucket_provider: Option, #[serde(rename = "iamRoleArn", skip_serializing_if = "Option::is_none")] pub iam_role_arn: Option, + #[serde(rename = "iamRoleExternalId", skip_serializing_if = "Option::is_none")] + pub iam_role_external_id: Option, #[serde(rename = "iamRoleSessionName", skip_serializing_if = "Option::is_none")] pub iam_role_session_name: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -547,7 +549,7 @@ pub struct AwsBackupBucket { } /// `AwsBackupBucketPatchRequestV1` from the ClickHouse Cloud API. -#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Default, Serialize)] pub struct AwsBackupBucketPatchRequestV1 { #[serde(rename = "bucketPath")] pub bucket_path: String, @@ -555,10 +557,67 @@ pub struct AwsBackupBucketPatchRequestV1 { pub bucket_provider: AwsBackupBucketPatchRequestV1Bucketprovider, #[serde(rename = "iamRoleArn")] pub iam_role_arn: String, + /// Omit to preserve the external ID, set a string to replace it, or send null to clear it. + #[serde(rename = "iamRoleExternalId", skip_serializing_if = "Option::is_none")] + pub iam_role_external_id: Option>, #[serde(rename = "iamRoleSessionName", skip_serializing_if = "Option::is_none")] pub iam_role_session_name: Option, } +impl<'de> Deserialize<'de> for AwsBackupBucketPatchRequestV1 { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + struct Fields; + impl<'de> serde::de::Visitor<'de> for Fields { + type Value = AwsBackupBucketPatchRequestV1; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("an AWS backup bucket update") + } + + fn visit_map(self, mut map: M) -> Result + where + M: serde::de::MapAccess<'de>, + { + let mut bucket_path = None; + let mut bucket_provider = None; + let mut iam_role_arn = None; + let mut iam_role_external_id = None; + let mut iam_role_session_name = None; + let mut seen = std::collections::HashSet::new(); + while let Some(key) = map.next_key::()? { + if !seen.insert(key.clone()) { + return Err(serde::de::Error::custom(format!("duplicate field `{key}`"))); + } + match key.as_str() { + "bucketPath" => bucket_path = Some(map.next_value()?), + "bucketProvider" => bucket_provider = Some(map.next_value()?), + "iamRoleArn" => iam_role_arn = Some(map.next_value()?), + "iamRoleExternalId" => iam_role_external_id = Some(map.next_value()?), + "iamRoleSessionName" => iam_role_session_name = map.next_value()?, + _ => { + map.next_value::()?; + } + } + } + Ok(AwsBackupBucketPatchRequestV1 { + bucket_path: bucket_path + .ok_or_else(|| serde::de::Error::missing_field("bucketPath"))?, + bucket_provider: bucket_provider + .ok_or_else(|| serde::de::Error::missing_field("bucketProvider"))?, + iam_role_arn: iam_role_arn + .ok_or_else(|| serde::de::Error::missing_field("iamRoleArn"))?, + iam_role_external_id, + iam_role_session_name, + }) + } + } + deserializer.deserialize_map(Fields) + } +} + /// `AwsBackupBucketPostRequestV1` from the ClickHouse Cloud API. #[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] pub struct AwsBackupBucketPostRequestV1 { @@ -568,6 +627,8 @@ pub struct AwsBackupBucketPostRequestV1 { pub bucket_provider: AwsBackupBucketPostRequestV1Bucketprovider, #[serde(rename = "iamRoleArn")] pub iam_role_arn: String, + #[serde(rename = "iamRoleExternalId")] + pub iam_role_external_id: String, #[serde(rename = "iamRoleSessionName")] pub iam_role_session_name: String, } @@ -581,6 +642,8 @@ pub struct AwsBackupBucketProperties { pub bucket_provider: AwsBackupBucketPropertiesBucketprovider, #[serde(rename = "iamRoleArn")] pub iam_role_arn: String, + #[serde(rename = "iamRoleExternalId")] + pub iam_role_external_id: String, #[serde(rename = "iamRoleSessionName")] pub iam_role_session_name: String, } diff --git a/crates/clickhouse-cloud-api/tests/client_test.rs b/crates/clickhouse-cloud-api/tests/client_test.rs index 5a05c2af..63491634 100644 --- a/crates/clickhouse-cloud-api/tests/client_test.rs +++ b/crates/clickhouse-cloud-api/tests/client_test.rs @@ -2215,15 +2215,22 @@ async fn get_backup_bucket() { .and(path("/v1/organizations/org-1/services/svc-1/backupBucket")) .respond_with(ok_json(serde_json::json!({ "bucketPath": "s3://my-backup-bucket/prefix", - "bucketProvider": "aws_s3", + "bucketProvider": "AWS", "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", - "roleArn": "arn:aws:iam::123:role/backup-role" + "iamRoleArn": "arn:aws:iam::123:role/backup-role", + "iamRoleExternalId": "trust-policy-id" }))) .mount(&s) .await; let resp = c.backup_bucket_get("org-1", "svc-1").await.unwrap(); - assert!(resp.result.is_some()); + let Some(BackupBucket::AwsBackupBucket(bucket)) = resp.result else { + panic!("expected AWS bucket"); + }; + assert_eq!( + bucket.iam_role_external_id.as_deref(), + Some("trust-policy-id") + ); } #[tokio::test] @@ -2233,11 +2240,11 @@ async fn create_backup_bucket() { Mock::given(method("POST")) .and(path("/v1/organizations/org-1/services/svc-1/backupBucket")) .and(body_partial_json( - serde_json::json!({"bucketPath": "s3://new-bucket"}), + serde_json::json!({"bucketPath": "s3://new-bucket", "iamRoleExternalId": "trust-policy-id"}), )) .respond_with(ok_json(serde_json::json!({ "bucketPath": "s3://new-bucket", - "bucketProvider": "aws_s3", + "bucketProvider": "AWS", "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" }))) .mount(&s) @@ -2246,6 +2253,7 @@ async fn create_backup_bucket() { let body = BackupBucketPostRequest::AwsBackupBucketPostRequestV1(AwsBackupBucketPostRequestV1 { bucket_path: "s3://new-bucket".to_string(), + iam_role_external_id: "trust-policy-id".to_string(), ..Default::default() }); let resp = c @@ -2262,11 +2270,11 @@ async fn update_backup_bucket() { Mock::given(method("PATCH")) .and(path("/v1/organizations/org-1/services/svc-1/backupBucket")) .and(body_partial_json( - serde_json::json!({"bucketPath": "s3://updated-bucket"}), + serde_json::json!({"bucketPath": "s3://updated-bucket", "iamRoleExternalId": null}), )) .respond_with(ok_json(serde_json::json!({ "bucketPath": "s3://updated-bucket", - "bucketProvider": "aws_s3", + "bucketProvider": "AWS", "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" }))) .mount(&s) @@ -2275,6 +2283,7 @@ async fn update_backup_bucket() { let body = BackupBucketPatchRequest::AwsBackupBucketPatchRequestV1(AwsBackupBucketPatchRequestV1 { bucket_path: "s3://updated-bucket".to_string(), + iam_role_external_id: Some(None), ..Default::default() }); let resp = c diff --git a/crates/clickhouse-cloud-api/tests/models_test.rs b/crates/clickhouse-cloud-api/tests/models_test.rs index 9e748ce5..ae90aa3f 100644 --- a/crates/clickhouse-cloud-api/tests/models_test.rs +++ b/crates/clickhouse-cloud-api/tests/models_test.rs @@ -1,5 +1,80 @@ use clickhouse_cloud_api::models::*; +#[test] +fn aws_backup_external_id_response_tolerates_absence_and_null() { + for wire in [ + serde_json::json!({}), + serde_json::json!({"iamRoleExternalId": null}), + ] { + let response: AwsBackupBucket = serde_json::from_value(wire).unwrap(); + assert_eq!(response.iam_role_external_id, None); + assert_eq!( + serde_json::to_value(response).unwrap(), + serde_json::json!({}) + ); + } + let response: AwsBackupBucket = serde_json::from_value( + serde_json::json!({"iamRoleExternalId": "trust-policy-id", "futureField": 1}), + ) + .unwrap(); + assert_eq!( + response.iam_role_external_id.as_deref(), + Some("trust-policy-id") + ); +} + +#[test] +fn aws_backup_external_id_create_and_properties_are_strict() { + let mut wire = serde_json::json!({ + "bucketProvider": "AWS", "bucketPath": "s3://backups", + "iamRoleArn": "arn:aws:iam::123:role/backup", "iamRoleSessionName": "backups", + "iamRoleExternalId": "trust-policy-id" + }); + let request: AwsBackupBucketPostRequestV1 = serde_json::from_value(wire.clone()).unwrap(); + assert_eq!(request.iam_role_external_id, "trust-policy-id"); + assert_eq!(serde_json::to_value(request).unwrap(), wire); + let properties: AwsBackupBucketProperties = serde_json::from_value(wire.clone()).unwrap(); + assert_eq!(properties.iam_role_external_id, "trust-policy-id"); + wire.as_object_mut().unwrap().remove("iamRoleExternalId"); + assert!(serde_json::from_value::(wire.clone()).is_err()); + assert!(serde_json::from_value::(wire.clone()).is_err()); + wire["iamRoleExternalId"] = serde_json::Value::Null; + assert!(serde_json::from_value::(wire.clone()).is_err()); + assert!(serde_json::from_value::(wire).is_err()); +} + +#[test] +fn aws_backup_external_id_update_preserves_omit_set_and_clear() { + for external_id in [ + None, + Some(serde_json::json!("trust-policy-id")), + Some(serde_json::Value::Null), + ] { + let mut wire = serde_json::json!({ + "bucketProvider": "AWS", "bucketPath": "s3://backups", + "iamRoleArn": "arn:aws:iam::123:role/backup" + }); + if let Some(value) = &external_id { + wire["iamRoleExternalId"] = value.clone(); + } + let request: AwsBackupBucketPatchRequestV1 = serde_json::from_value(wire.clone()).unwrap(); + assert_eq!( + request.iam_role_external_id, + external_id.map(|value| value.as_str().map(str::to_owned)) + ); + assert_eq!(serde_json::to_value(request).unwrap(), wire); + } + for wire in [ + r#"{"bucketProvider":"AWS","bucketPath":"s3://backups","iamRoleArn":"arn","iamRoleExternalId":42}"#, + r#"{"bucketProvider":"AWS","bucketPath":"s3://backups","iamRoleArn":"arn","iamRoleExternalId":null,"iamRoleExternalId":"id"}"#, + ] { + assert!(serde_json::from_str::(wire).is_err()); + } + assert!( + serde_json::from_value::(serde_json::json!({})).is_err() + ); +} + #[test] fn snapshot_configuration_tolerates_missing_null_and_extra_fields() { for wire in [