From 57e9beabe0ff71d710bfe932a162f31cc1f60aee Mon Sep 17 00:00:00 2001 From: Cheese Date: Mon, 17 Aug 2026 15:08:58 +0800 Subject: [PATCH] feat(fs): expose tenant metadata control plane --- AGENTS.md | 3 + README.md | 15 +- ...> 0032-homebrew-and-scoop-distribution.md} | 0 ...=> 0033-serverless-function-deployment.md} | 0 .../0012-install-and-update-distribution.md | 4 +- .../done/0028-remote-fs-resource-inventory.md | 2 + .../0031-fs-tenant-metadata-control-plane.md | 424 +++++++++++++ e2e/cli_test.go | 223 ++++++- e2e/live_test.go | 47 +- internal/api/client.go | 16 +- internal/api/error.go | 1 + internal/api/fs/admin_tenant.go | 150 +++++ internal/api/fs/admin_tenant_test.go | 107 ++++ internal/cli/commands.go | 61 +- internal/fs/control.go | 194 ++---- internal/fs/drive9_companion.go | 265 -------- internal/fs/drive9_companion_test.go | 527 +--------------- internal/fs/tenant_control.go | 462 ++++++++++++++ internal/fs/tenant_control_test.go | 588 ++++++++++++++++++ 19 files changed, 2147 insertions(+), 942 deletions(-) rename docs/spec/{0031-homebrew-and-scoop-distribution.md => 0032-homebrew-and-scoop-distribution.md} (100%) rename docs/spec/{0032-serverless-function-deployment.md => 0033-serverless-function-deployment.md} (100%) create mode 100644 docs/spec/done/0031-fs-tenant-metadata-control-plane.md create mode 100644 internal/api/fs/admin_tenant.go create mode 100644 internal/api/fs/admin_tenant_test.go create mode 100644 internal/fs/tenant_control.go create mode 100644 internal/fs/tenant_control_test.go diff --git a/AGENTS.md b/AGENTS.md index a531a9f..6473a10 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -157,6 +157,9 @@ Implemented: - region-scoped remote ti fs inventory, profile-scoped ID-keyed credentials, and legacy credential migration from `docs/spec/done/0028-remote-fs-resource-inventory.md` +- direct TiDB Cloud Filesystem tenant create/list/describe/delete control plane, + authoritative display metadata, quota output, and inventory filters from + `docs/spec/done/0031-fs-tenant-metadata-control-plane.md` - ti fs/fs-git/fs-journal/fs-vault commands routed through the bundled `ti-drive9` companion, with ti-owned profile loading, credential storage, region resolution, and output/error handling diff --git a/README.md b/README.md index 2b560f0..63d7368 100644 --- a/README.md +++ b/README.md @@ -161,7 +161,10 @@ The following example uses `jq` to extract the server-assigned ID and one-time t ```shell mkdir ~/my-workspace umask 077 -ti fs create-file-system --wait > ./filesystem.json +ti fs create-file-system \ + --display-name my-workspace \ + --label environment=development \ + --wait > ./filesystem.json export FILE_SYSTEM_ID="$(jq -r '.file_system_id' ./filesystem.json)" export TI_FS_TOKEN="$(jq -r '.fs_token' ./filesystem.json)" rm ./filesystem.json @@ -179,7 +182,15 @@ export TI_FS_FILE_SYSTEM_ID="$FILE_SYSTEM_ID" ti fs list-files ``` -`create-file-system` does not accept a user-defined name. Drive9 assigns the stable `file_system_id`, and the command returns the owner credential as `fs_token` once in its JSON result. Treat it as a secret. The example above captures both fields from one provisioning request and removes the temporary owner-only JSON file immediately. +`--display-name` and repeatable `--label key=value` flags add organization-visible metadata to the remote inventory. They do not identify a resource for later operations: Drive9 still assigns the stable `file_system_id`, and describe, delete, data-plane, mount, and token commands select by that ID. Do not store passwords, tokens, connection strings, private paths, or personal data in labels. The create response returns the owner credential as `fs_token` once; treat it as a secret. The example above captures the ID and token from one provisioning request and removes the temporary owner-only JSON file immediately. + +List results include authoritative display names, labels, status, region, quota and usage, plus the non-secret `has_local_token` hint. Filter the remote inventory by a display-name substring and one exact label without exposing token values: + +```shell +ti fs list-file-systems \ + --display-name workspace \ + --label environment=development +``` One Filesystem can have multiple independently managed tokens for different machines, CI jobs, and sandboxes. Owner tokens authorize the complete Filesystem and can issue path-and-operation-limited `fs_scoped` tokens. The remote service is the source of truth for token inventory, while each local profile stores at most one selected token for each Filesystem. Generate an additional owner token and capture its one-time plaintext response: diff --git a/docs/spec/0031-homebrew-and-scoop-distribution.md b/docs/spec/0032-homebrew-and-scoop-distribution.md similarity index 100% rename from docs/spec/0031-homebrew-and-scoop-distribution.md rename to docs/spec/0032-homebrew-and-scoop-distribution.md diff --git a/docs/spec/0032-serverless-function-deployment.md b/docs/spec/0033-serverless-function-deployment.md similarity index 100% rename from docs/spec/0032-serverless-function-deployment.md rename to docs/spec/0033-serverless-function-deployment.md diff --git a/docs/spec/done/0012-install-and-update-distribution.md b/docs/spec/done/0012-install-and-update-distribution.md index e480889..c1ea241 100644 --- a/docs/spec/done/0012-install-and-update-distribution.md +++ b/docs/spec/done/0012-install-and-update-distribution.md @@ -2,7 +2,7 @@ ## Goal -Make `tdc` installable and updatable through deterministic GitHub Releases artifacts. The MVP channel is GoReleaser plus GitHub Releases, with shell and PowerShell installers. Homebrew and Scoop are intentionally deferred to `docs/spec/0031-homebrew-and-scoop-distribution.md`. +Make `tdc` installable and updatable through deterministic GitHub Releases artifacts. The MVP channel is GoReleaser plus GitHub Releases, with shell and PowerShell installers. Homebrew and Scoop are intentionally deferred to `docs/spec/0032-homebrew-and-scoop-distribution.md`. ## User-facing Commands @@ -266,7 +266,7 @@ Installer scripts: - Silent auto-update. - Updating TiDB Cloud credentials or DB SQL credentials. - Config migrations that modify user config during update. -- Homebrew tap and Scoop bucket publishing. See `0031-homebrew-and-scoop-distribution.md`. +- Homebrew tap and Scoop bucket publishing. See `0032-homebrew-and-scoop-distribution.md`. - Linux apt/yum repositories. - Winget publishing. - Notarization or binary signing beyond SHA-256 checksums for MVP. diff --git a/docs/spec/done/0028-remote-fs-resource-inventory.md b/docs/spec/done/0028-remote-fs-resource-inventory.md index 5980e5e..3b8448c 100644 --- a/docs/spec/done/0028-remote-fs-resource-inventory.md +++ b/docs/spec/done/0028-remote-fs-resource-inventory.md @@ -1,5 +1,7 @@ # Remote File System Resource Inventory +> **Superseding note:** `docs/spec/done/0031-fs-tenant-metadata-control-plane.md` later moved create, list, describe, and delete from companion commands to the typed admin tenant HTTP API and added organization-visible display names and labels. This document remains authoritative for ID-based selection, remote inventory ownership, local credential migration, and configuration-free access; its no-name and companion control-plane statements are historical. + ## Goal Make the Drive9 backend the source of truth for TiDB Cloud Filesystem resource inventory. Replace locally assigned file system names with one stable public identifier, `file_system_id`, whose value is the Drive9 tenant ID returned by provisioning. diff --git a/docs/spec/done/0031-fs-tenant-metadata-control-plane.md b/docs/spec/done/0031-fs-tenant-metadata-control-plane.md new file mode 100644 index 0000000..447e5bb --- /dev/null +++ b/docs/spec/done/0031-fs-tenant-metadata-control-plane.md @@ -0,0 +1,424 @@ +# FS Tenant Metadata And Direct Control Plane + +## Goal + +Expose the Drive9 server's organization-scoped File System `display_name` and `label` metadata through `ti` without waiting for or modifying the Drive9 CLI companion. The four remote File System inventory operations move to a typed HTTP client owned by `ti`, while all data-plane, mount, Git, journal, vault, layer, and other filesystem runtime behavior remains delegated to the bundled `ti-drive9` companion. + +This spec depends on the hosted File System backend behavior introduced by `tidbcloud/fs` PR #61. It changes only the `ti` repository. Code under `ref/` remains reference-only and must not become a source, build, test, or runtime dependency. + +## Motivation + +The backend now stores a user-facing display name and labels on each tenant and returns them from the admin tenant create, list, and get APIs. The current `ti` path cannot expose these fields: + +1. `ti fs create-file-system` invokes `ti-drive9 create --json`, which calls `POST /v1/provision`. That endpoint does not accept or return tenant metadata. +2. `ti fs list-file-systems` and `ti fs describe-file-system` invoke the Drive9 admin tenant CLI. The current companion decodes the backend response into client models that do not contain `display_name` or `label`, so those fields are discarded before `ti` receives JSON. +3. The backend list API supports server-side display-name and label filters, but the current companion does not expose them. + +Unknown response fields do not currently break existing commands, but silently discarding authoritative server metadata makes the new backend feature unusable. Updating only `ti` therefore requires bypassing the companion for the bounded organization control-plane surface instead of attempting to parse companion output that no longer contains the fields. + +## API Choice + +The two creation endpoints share the same underlying tenant provisioning, owner-token generation, free-plan accounting, and prewarmed-pool claim implementation, but they have different contracts. + +| Behavior | `POST /v1/provision` | `POST /v1/admin/tenants` | +| --- | --- | --- | +| Purpose | Generic and legacy provisioning entry point | TiDB Cloud organization management entry point | +| Credentials | Can support empty-body, anonymous, or server-default modes depending on deployment | Requires valid TiDB Cloud API credentials | +| Providers | Can route through the deployment's default provider and local shims | Limited to TiDB Cloud native and native-shared tenants | +| Display name | Unsupported | Supported | +| Labels | Unsupported | Supported | +| Name conflict check | None | Best-effort organization-scoped conflict check | +| Owner token response | Yes | Yes | +| Metadata response | No | Yes | +| Successful status | `202 Accepted` | `202 Accepted` | + +`ti fs create-file-system` already requires TiDB Cloud API credentials and does not expose anonymous provisioning. Moving it to `POST /v1/admin/tenants` therefore removes no supported `ti` workflow. It aligns create with the admin list, get, and delete APIs that already define the authoritative organization inventory. + +## Scope + +Move these operations from companion admin/provision commands to direct typed HTTPS requests: + +```text +ti fs create-file-system +ti fs list-file-systems +ti fs describe-file-system +ti fs delete-file-system +``` + +Keep these operations and all related aliases delegated to `ti-drive9`: + +- File reads, writes, copies, moves, searches, metadata operations, links, and directories. +- FUSE and WebDAV mount, drain, and unmount. +- Layers, pack, and unpack. +- `ti fs-git`, `ti fs-journal`, and `ti fs-vault`. +- File System token data-plane use and runtime context construction. +- `ti fs check-file-system`, including its companion and remote-root checks. + +The direct control-plane client is not a replacement for the Drive9 filesystem client. It is a small organization inventory client for four backend endpoints. + +## User-Facing Commands + +Existing commands remain valid: + +```bash +ti fs create-file-system +ti fs create-file-system --wait +ti fs list-file-systems +ti fs describe-file-system --file-system-id +ti fs delete-file-system --file-system-id +``` + +Add optional metadata to creation: + +```bash +ti fs create-file-system --display-name agent-workspace +ti fs create-file-system \ + --display-name agent-workspace \ + --label environment=production \ + --label team=ai \ + --wait +``` + +Add server-side inventory filters: + +```bash +ti fs list-file-systems --display-name workspace +ti fs list-file-systems --label environment=production +ti fs list-file-systems --display-name workspace --label environment=production +``` + +`--display-name` and `--label` are metadata inputs and filters. They never select a resource for describe, delete, data-plane, mount, token, Git, journal, or vault operations. Existing resources continue to be selected by `--file-system-id`, `TI_FS_FILE_SYSTEM_ID`, or a verified token-derived ID according to the existing command contract. + +## Display Name Contract + +Creation accepts an optional `--display-name `. + +- An omitted display name sends no explicit name. The backend presents the assigned tenant ID as the effective display name. +- A non-empty display name must contain 4–64 ASCII letters, digits, or hyphens, start and end with a letter or digit, and match `^[A-Za-z0-9][-A-Za-z0-9]{2,62}[A-Za-z0-9]$`. +- Leading or trailing whitespace is invalid and must not be silently removed. +- An explicitly supplied empty flag value is a usage error rather than an omitted value. +- `ti` performs the same validation locally so `--dry-run` and normal execution fail consistently before any request. +- The backend remains authoritative and can reject a value even after local validation. +- A backend HTTP 409 name collision maps to a stable `fs.display_name_conflict` error. + +Backend display-name uniqueness is best-effort. The preflight duplicate query and tenant insert are not one atomic operation, and no database unique constraint exists. Two concurrent create requests can therefore produce duplicate display names. `ti` must not claim strong uniqueness, resolve a resource by display name, or retry a create under a different name automatically. + +The list form `--display-name ` applies a server-side contains filter to the backend's effective display name. `ti` rejects `%`, `_`, control characters, and empty values so users cannot accidentally invoke SQL `LIKE` wildcard behavior. The filter is not an exact lookup and does not change pagination semantics. + +## Label Contract + +Creation accepts repeatable `--label ` flags. + +- At most 30 labels are allowed. +- Duplicate keys are rejected locally. Do not silently choose the first or last value. +- A key uses the backend's Kubernetes qualified-name contract: an optional lowercase DNS prefix of at most 253 bytes and `/`, followed by a 1–63 byte name using ASCII letters, digits, `-`, `_`, or `.`, starting and ending with a letter or digit. +- A value may be empty. A non-empty value is at most 63 bytes and uses ASCII letters, digits, `-`, `_`, or `.`, starting and ending with a letter or digit. +- `ti` validates labels locally for deterministic dry-run behavior; the backend remains authoritative. +- Labels are metadata visible through organization inventory. Documentation must tell users not to store passwords, tokens, connection strings, private paths, or other secrets in labels. + +List accepts at most one `--label ` filter because the backend currently exposes one exact key/value query. `ti` converts it to the backend query syntax `label===`. Multiple label filters are out of scope until the server defines their AND/OR semantics. + +## Direct HTTP Contracts + +Add typed requests and responses under `internal/api/fs`, preferably in `admin_tenant.go`. Do not reuse untyped maps or parse JSON with ad hoc string operations. + +### Create + +```http +POST /v1/admin/tenants +X-TiDBCloud-Public-Key: +X-TiDBCloud-Private-Key: +Content-Type: application/json +``` + +```json +{ + "display_name": "agent-workspace", + "label": { + "environment": "production", + "team": "ai" + } +} +``` + +Credentials are sent through the existing TiDB Cloud credential headers. Do not duplicate them in the JSON body. Empty optional metadata fields should be omitted when practical; the client must accept the backend's effective display name and normalized empty label object in the response. + +Expected response: + +```json +{ + "tenant_id": "", + "display_name": "agent-workspace", + "label": { + "environment": "production", + "team": "ai" + }, + "api_key": "", + "status": "provisioning", + "cloud_provider": "aws", + "region": "ap-southeast-1" +} +``` + +`ti` maps `tenant_id` to `file_system_id`, `api_key` to `fs_token`, and backend `label` to the user-facing `labels` object. + +### List + +```http +GET /v1/admin/tenants?page_size=100&page=1&display_name=workspace&label=environment%3D%3Dproduction +X-TiDBCloud-Public-Key: +X-TiDBCloud-Private-Key: +``` + +The client follows `next_page` until zero using the existing repeated/regressing-page protections. Every page receives the same filters. It joins the remote inventory with the local ID-keyed credential store only to compute `has_local_token`; local state never adds, removes, or renames remote resources. + +Quota and usage are returned by the backend for every list item. `ti` does not send or expose an `include_quota` request switch. + +### Describe + +```http +GET /v1/admin/tenants/ +X-TiDBCloud-Public-Key: +X-TiDBCloud-Private-Key: +``` + +The requested ID must equal the returned `tenant_id`. A mismatch is a response-contract error. `has_local_token` is derived locally without changing the remote result. + +### Delete + +```http +DELETE /v1/admin/tenants/ +X-TiDBCloud-Public-Key: +X-TiDBCloud-Private-Key: +``` + +Use credential headers and no credential body. Preserve the current behavior: remove matching local credentials only after the backend accepts deletion, and render the asynchronous status as `deleting`. Metadata does not change deletion selection or confirmation behavior. + +## Creation Flow + +The new creation flow is: + +1. Load the selected profile and validate TiDB Cloud public/private keys. +2. Resolve the effective canonical region and hosted File System endpoint. +3. Parse and validate display name and labels. +4. Send `POST /v1/admin/tenants` directly from `ti`. +5. Validate the returned tenant ID and non-empty owner token. +6. Persist the owner token in the existing ID-keyed credential store with mode `0600`. +7. Return the one-time token in the command result exactly as today. +8. If `--wait` is present, invoke the existing resource-scoped `ti-drive9` root-stat readiness loop with the newly stored token. + +Direct creation no longer needs a temporary Drive9 HOME or a temporary Drive9 context. A readiness failure retains the created resource and local credential and includes the file system ID in the error, matching the existing `--wait` safety contract. + +The command must not expose backend quota or spending-limit inputs. Existing product policy that users cannot specify a TiDB Cloud spending limit for File System provisioning remains unchanged. + +## Output + +Create JSON adds metadata while retaining the existing token contract: + +```json +{ + "file_system_id": "", + "display_name": "agent-workspace", + "labels": { + "environment": "production" + }, + "region_code": "aws-ap-southeast-1", + "fs_token": "", + "status": "provisioning", + "credentials_stored": true +} +``` + +List and describe always render a non-empty effective `display_name` and a non-null `labels` object. An old resource with no explicit metadata appears with `display_name` equal to its ID and `labels: {}`. No client-side migration or synthesized local name is needed. + +Text list output becomes: + +```text +FILE_SYSTEM_ID DISPLAY_NAME REGION STATUS KIND LOCAL_TOKEN + agent-workspace aws-ap-southeast-1 active live true +``` + +Do not add labels as a list-table column because arbitrary key/value maps make the table unstable and excessively wide. Text describe output includes labels in deterministic key order, for example: + +```text +File system ID: +Display name: agent-workspace +Labels: environment=production, team=ai +Region: aws-ap-southeast-1 +Status: active +Kind: live +Local token: true +``` + +An empty map renders as `Labels: none` in text output. + +## Dry Run + +`ti fs create-file-system --dry-run` changes its planned request from `/v1/provision` to `/v1/admin/tenants`. It validates credentials, endpoint resolution, display name, labels, and local credential-store readiness without sending a request or writing files. + +The dry-run request body may show `display_name` and `label`, but it must not contain public/private key values. The result describes TiDB Cloud API-key authentication without rendering credential headers. `--wait` is reported as a post-create readiness action and does not run during dry-run. + +Delete dry-run keeps its existing `/v1/admin/tenants/` path and local credential-removal plan, but no longer describes companion execution. + +Read-only list and describe continue to reject `--dry-run`. + +## Error Handling + +Map backend and local failures to stable `ti` errors without changing JSON success output: + +| Condition | Expected behavior | +| --- | --- | +| Invalid display name | `fs.invalid_display_name`, usage exit code `2` | +| Invalid label syntax, key, value, count, or duplicate | `fs.invalid_label`, usage exit code `2` | +| Missing TiDB Cloud credentials | Existing authentication-required error | +| HTTP 401 | Existing TiDB Cloud authentication error | +| HTTP 403 | Existing FS authorization/quota error mapping with backend detail | +| HTTP 404 for admin API root | `fs.control_plane_unavailable` with region context | +| HTTP 404 for an item | Existing `fs.resource_not_found` behavior | +| HTTP 409 display-name conflict | `fs.display_name_conflict` | +| HTTP 429 | Retryable remote API error; do not retry mutation automatically | +| HTTP 5xx | Remote API error preserving request ID when available | +| Invalid or mismatched response ID | `fs.api_contract` | +| Missing create token | `fs.api_contract`; do not write local state | + +Do not fall back to `/v1/provision` when the admin endpoint is unavailable. A fallback could create an unnamed and unlabeled resource after the user explicitly requested metadata, making the operation nondeterministic across regions. It could also turn an ambiguous network retry into a duplicate resource. Report the failure and let the user decide whether to retry. + +Create remains non-idempotent unless the backend later adds an idempotency contract. `ti` must not automatically retry a request after it may have reached the backend. + +## Authentication And Security + +- Load TiDB Cloud keys through existing profile/environment precedence. Do not add new credential files or environment variables. +- Reuse the existing `X-TiDBCloud-Public-Key` and `X-TiDBCloud-Private-Key` header contract already used by FS token management. +- Ensure HTTP debug logging, operation logging, errors, telemetry, and dry-run output never contain credential header values or the returned owner token. +- Telemetry may record command path and flag names according to the existing policy. It must not record display names, label keys, label values, file system IDs, or filters. +- The create result intentionally returns the one-time owner token to stdout. This existing behavior remains the only supported plaintext delivery path in addition to mode-`0600` local credential persistence. +- Metadata is organization-visible and is not a secret store. + +## Local State And Migration + +Do not persist display names or labels in `~/.ti/config`, `~/.ti/credentials`, or the File System credential registry. The backend is authoritative and list/describe read current metadata remotely. + +The existing credential layout remains unchanged: + +```text +~/.ti/fs_credentials///credentials +``` + +No migration is required: + +- Existing local credentials remain keyed by immutable file system ID. +- Existing remote resources receive the backend's tenant-ID display fallback and empty labels. +- A clean machine can list and describe metadata using only TiDB Cloud credentials and region selection. +- Losing local state does not lose metadata. + +## Package Design + +### `internal/api/fs` + +- Add typed admin tenant request, response, pagination, and filter models. +- Add methods for create, list, get, and delete. +- Centralize TiDB Cloud credential-header injection with the existing FS token-management header contract. +- Encode query parameters with `net/url`; never concatenate filter strings into URLs manually. +- Use the shared `internal/api.Client` request, response, request-ID, and error handling. + +### `internal/fs` + +- Extend `CreateFileSystemOptions` with display name and parsed labels. +- Introduce list options containing optional display-name and label filters. +- Extend `FileSystemResult` and `FileSystemSummary` with display name and labels. +- Move create/list/describe/delete orchestration from companion methods to direct API methods. +- Keep local credential joining, credential deletion, readiness waiting, endpoint resolution, and text formatting in the existing ownership boundaries. +- Remove unused companion-only create temporary-HOME code and admin inventory parsing after the direct path is covered. + +### `internal/cli` + +- Add optional `--display-name` and repeatable `--label` flags to create. +- Add optional `--display-name` and single `--label` filters to list. +- Parse flags through command context helpers and pass typed options to `internal/fs`. +- Keep permissions unchanged: create uses `FSVolumeCreate`, list/describe use `FSVolumeRead`, and delete uses `FSVolumeDelete`. + +### Documentation + +- Update `README.md` whenever code is implemented. +- Update PingCAP Preview command reference pages for create, list, and describe. +- Explain that display name is presentation metadata, not a resource selector. +- Document label validation, organization visibility, and the absence of metadata updates. +- Update completed FS inventory documentation only through an explicit superseding note if historical behavior would otherwise mislead current readers; do not rewrite historical implementation records mechanically. + +## Dependencies And Portability + +No new third-party Go package is required. Use the existing shared HTTP client, endpoint resolver, profile loader, API error model, and Go standard packages such as `net/http`, `net/url`, `regexp`, `sort`, and `encoding/json`. + +The change adds no cgo dependency, daemon, mount requirement, or platform-specific implementation. Direct control-plane behavior must work identically on macOS, Linux, and Windows. The Drive9 companion remains required for data-plane and mount workflows but is no longer required merely to create, list, describe, or delete a remote File System. + +`ref/fs` and `ref/drive9` remain reference-only. Do not import their packages or copy them into the module dependency graph. + +## Testing + +### API Client Tests + +- Create uses `POST /v1/admin/tenants`, credential headers, metadata-only JSON, and decodes display name, labels, token, provider, region, and status. +- List encodes page, page size, display-name substring, and exact label query correctly. +- Get and delete encode escaped item paths and credential headers. +- Credential values never appear in request summaries or returned errors. +- Unknown response fields remain forward-compatible; required identity/token fields are validated by the service layer. + +### Service Tests + +- Create without metadata stores the returned token and accepts ID fallback display names. +- Create with metadata preserves normalized response metadata. +- Duplicate label keys, invalid keys/values, too many labels, invalid names, and explicit empty flags fail before network access. +- A failed credential write reports the created ID/token result according to the existing partial-success contract and never deletes the remote resource. +- `--wait` uses the created token and preserves credentials after timeout. +- List exhausts pagination, applies filters to every page, rejects repeated pages/IDs, sorts results deterministically, and joins local token state by ID. +- Describe rejects a mismatched response ID and renders empty labels deterministically. +- Delete removes only the selected local credential after HTTP 202 and preserves it on failure. +- HTTP 409, item 404, admin-root 404, auth, quota, and 5xx errors map to stable codes. +- No test imports or executes code under `ref/`. + +### CLI And Black-Box Tests + +- Help shows optional metadata/filter flags with the repository's required/optional usage formatting. +- JSON, text, and JMESPath query output include display name and labels correctly. +- Dry-run uses the admin path, validates metadata, and contains no credentials. +- Existing create/list/describe/delete invocations without new flags remain valid. +- Fake-server tests verify the direct API path and prove `ti-drive9` is not invoked for the four migrated control-plane commands. +- Existing data-plane and mount tests prove companion routing is unchanged. + +### Live E2E + +Extend the existing `make live-e2e-fs` lifecycle without deleting pre-existing resources: + +1. Create one uniquely named `ti-e2e-fs-*` resource with labels identifying the test run and use `--wait`. +2. Verify the create response returns the same effective display name and labels plus a non-empty one-time token. +3. List by display-name substring and exact label and find only the created resource among matching results. +4. Describe by ID and verify metadata, status, quota presence, and local-token state. +5. Exercise the existing data-plane/mount flow through the companion to prove the directly created owner token is compatible. +6. Delete only that resource and verify local credentials are removed after acceptance. + +If a regional backend has not deployed the admin metadata contract, the live test must fail with the explicit control-plane-unavailable error. It must not silently fall back or skip metadata assertions. + +## Acceptance Criteria + +- `ti fs create-file-system` uses `POST /v1/admin/tenants` directly and no longer runs `ti-drive9 create`. +- `ti fs list-file-systems`, `describe-file-system`, and `delete-file-system` use the direct admin tenant API. +- Create accepts optional validated display name and repeated labels. +- List supports server-side display-name and exact single-label filters. +- JSON and text output expose backend display metadata without persisting a second local inventory. +- Existing resources render with ID fallback display names and empty labels without migration. +- Resource selection remains ID/token based; display name is never accepted as identity. +- No create fallback can silently discard metadata. +- Data-plane, mount, layers, Git, journal, vault, pack, and unpack remain delegated to `ti-drive9`. +- Tests cover API contracts, validation, pagination, output, credential safety, no-companion control-plane routing, and a real create-to-delete lifecycle. + +## Out Of Scope + +- Updating display name or labels after creation; the backend exposes no PATCH contract. +- Strong concurrent display-name uniqueness. +- Selecting, mounting, deleting, or issuing tokens by display name. +- Multiple label predicates or arbitrary label expressions in one list request. +- Client-side metadata caching or persistence. +- Adding quota or spending-limit flags to File System creation. +- Anonymous File System provisioning. +- Modifying or releasing Drive9 CLI binaries. +- Reimplementing any filesystem data-plane or mount behavior in `ti`. diff --git a/e2e/cli_test.go b/e2e/cli_test.go index fad2833..be6c5f3 100644 --- a/e2e/cli_test.go +++ b/e2e/cli_test.go @@ -13,7 +13,9 @@ import ( "path/filepath" "reflect" "runtime" + "sort" "strings" + "sync" "testing" "time" @@ -77,6 +79,14 @@ func TestHelpAndVersion(t *testing.T) { deleteFileSystem.wantStdoutContains("--file-system-id") deleteFileSystem.wantStdoutNotContains("--file-system-name") deleteFileSystem.wantStdoutNotContains("--confirm-file-system-name") + createFileSystem := runTI(t, bin, "fs", "create-file-system", "help") + createFileSystem.wantExitCode(0) + createFileSystem.wantStdoutContains("[--display-name ]") + createFileSystem.wantStdoutContains("[--label ]") + listFileSystems := runTI(t, bin, "fs", "list-file-systems", "help") + listFileSystems.wantExitCode(0) + listFileSystems.wantStdoutContains("[--display-name ]") + listFileSystems.wantStdoutContains("[--label ]") createDBCluster := runTI(t, bin, "db", "create-db-cluster", "help") createDBCluster.wantExitCode(0) @@ -640,16 +650,18 @@ func TestFSRemoteInventoryAndIDCredentialSelectionAcrossCommandFamilies(t *testi t.Fatalf("build fake Drive9 companion: %v\n%s", err, output) } recordPath := filepath.Join(t.TempDir(), "calls.jsonl") - statePath := filepath.Join(t.TempDir(), "state.json") + eastControl := newFakeFSTenantControlPlane(t, "aws-us-east-1", "tenant-aws-us-east-1") + defer eastControl.close() + westControl := newFakeFSTenantControlPlane(t, "aws-us-west-2", "tenant-aws-us-west-2") + defer westControl.close() manifestServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - _, _ = fmt.Fprint(w, `{"service":"drive9","regions":[{"region_code":"aws-us-east-1","mode":"tidb_cloud_native","server_url":"https://fs-east.test","cloud_provider":"aws","tidb_region":"us-east-1"},{"region_code":"aws-us-west-2","mode":"tidb_cloud_native","server_url":"https://fs-west.test","cloud_provider":"aws","tidb_region":"us-west-2"}]}`) + _, _ = fmt.Fprintf(w, `{"service":"drive9","regions":[{"region_code":"aws-us-east-1","mode":"tidb_cloud_native","server_url":%q,"cloud_provider":"aws","tidb_region":"us-east-1"},{"region_code":"aws-us-west-2","mode":"tidb_cloud_native","server_url":%q,"cloud_provider":"aws","tidb_region":"us-west-2"}]}`, eastControl.URL(), westControl.URL()) })) defer manifestServer.Close() baseEnv := []string{ "HOME=" + home, "TI_DRIVE9_BIN=" + companion, "FAKE_DRIVE9_RECORD=" + recordPath, - "FAKE_DRIVE9_STATE=" + statePath, "TI_ALLOW_TEST_ENDPOINTS=1", "TI_TEST_FS_MANIFEST_URL=" + manifestServer.URL, } @@ -659,15 +671,28 @@ func TestFSRemoteInventoryAndIDCredentialSelectionAcrossCommandFamilies(t *testi "TIDB_CLOUD_PRIVATE_KEY=e2e-private", ), "configure", "--profile", "stage", "--non-interactive") configured.wantExitCode(0) + invalidDisplayName := runTIWithInput(t, bin, "", baseEnv, "--profile", "stage", "fs", "create-file-system", "--display-name", "bad name") + invalidDisplayName.wantExitCode(2) + invalidDisplayName.wantStderrContains("--display-name must be 4-64 characters") + duplicateLabel := runTIWithInput(t, bin, "", baseEnv, "--profile", "stage", "fs", "create-file-system", "--label", "team=ai", "--label", "team=data") + duplicateLabel.wantExitCode(2) + duplicateLabel.wantStderrContains("duplicate label key") + multipleListLabels := runTIWithInput(t, bin, "", baseEnv, "--profile", "stage", "fs", "list-file-systems", "--label", "team=ai", "--label", "environment=test") + multipleListLabels.wantExitCode(2) + multipleListLabels.wantStderrContains("--label can be provided at most once") missingWithZeroResources := runTIWithInput(t, bin, "", baseEnv, "--profile", "stage", "fs", "list-files", "--path", "/") missingWithZeroResources.wantExitCode(2) missingWithZeroResources.wantStderrContains("file system ID is required") - createWorkspace := runTIWithInput(t, bin, "", baseEnv, "--profile", "stage", "fs", "create-file-system", "--wait") + createWorkspace := runTIWithInput(t, bin, "", baseEnv, "--profile", "stage", "fs", "create-file-system", + "--display-name", "agent-workspace", "--label", "environment=production", "--label", "team=ai", "--wait") createWorkspace.wantExitCode(0) createWorkspace.wantStdoutContains(`"status": "ready"`) createWorkspace.wantStdoutContains(`"credentials_stored": true`) createWorkspace.wantStdoutContains(`"file_system_id": "tenant-aws-us-east-1"`) + createWorkspace.wantStdoutContains(`"display_name": "agent-workspace"`) + createWorkspace.wantStdoutContains(`"environment": "production"`) + createWorkspace.wantStdoutContains(`"team": "ai"`) missingWithOneResource := runTIWithInput(t, bin, "", baseEnv, "--profile", "stage", "fs", "list-files", "--path", "/") missingWithOneResource.wantExitCode(2) missingWithOneResource.wantStderrContains("file system ID is required") @@ -675,20 +700,43 @@ func TestFSRemoteInventoryAndIDCredentialSelectionAcrossCommandFamilies(t *testi createScratch.wantExitCode(0) createScratch.wantStdoutContains(`"status": "ready"`) createScratch.wantStdoutContains(`"credentials_stored": true`) + createScratch.wantStdoutContains(`"display_name": "tenant-aws-us-west-2"`) + createScratch.wantStdoutContains(`"labels": {}`) list := runTIWithInput(t, bin, "", baseEnv, "--profile", "stage", "fs", "list-file-systems") list.wantExitCode(0) list.wantStdoutContains(`"file_system_id": "tenant-aws-us-east-1"`) list.wantStdoutNotContains(`"file_system_id": "tenant-aws-us-west-2"`) list.wantStdoutContains(`"has_local_token": true`) + list.wantStdoutContains(`"display_name": "agent-workspace"`) + list.wantStdoutContains(`"labels": {`) list.wantStdoutNotContains("drive9_") list.wantStdoutNotContains("default_file_system_name") list.wantStdoutNotContains("is_default") textList := runTIWithInput(t, bin, "", baseEnv, "--profile", "stage", "fs", "list-file-systems", "--output", "text") textList.wantExitCode(0) textList.wantStdoutContains("FILE_SYSTEM_ID") + textList.wantStdoutContains("DISPLAY_NAME") + textList.wantStdoutContains("agent-workspace") textList.wantStdoutContains("tenant-aws-us-east-1") textList.wantStdoutNotContains(`"file_system_id"`) + filteredList := runTIWithInput(t, bin, "", baseEnv, "--profile", "stage", "fs", "list-file-systems", + "--display-name", "agent", "--label", "environment=production") + filteredList.wantExitCode(0) + filteredList.wantStdoutContains(`"file_system_id": "tenant-aws-us-east-1"`) + filteredList.wantStdoutNotContains(`"file_system_id": "tenant-aws-us-west-2"`) + queriedList := runTIWithInput(t, bin, "", baseEnv, "--profile", "stage", "fs", "list-file-systems", + "--label", "team=ai", "--query", "file_systems[0].display_name") + queriedList.wantExitCode(0) + queriedList.wantStdoutContains(`"agent-workspace"`) + dryRunCreate := runTIWithInput(t, bin, "", baseEnv, "--profile", "stage", "fs", "create-file-system", + "--display-name", "dry-run-workspace", "--label", "environment=test", "--wait", "--dry-run") + dryRunCreate.wantExitCode(0) + dryRunCreate.wantStdoutContains(`"path": "/v1/admin/tenants"`) + dryRunCreate.wantStdoutContains(`"display_name": "dry-run-workspace"`) + dryRunCreate.wantStdoutContains(`"environment": "test"`) + dryRunCreate.wantStdoutNotContains("e2e-public") + dryRunCreate.wantStdoutNotContains("e2e-private") westList := runTIWithInput(t, bin, "", baseEnv, "--profile", "stage", "--region", "aws-us-west-2", "fs", "list-file-systems") westList.wantExitCode(0) westList.wantStdoutContains(`"file_system_id": "tenant-aws-us-west-2"`) @@ -696,11 +744,16 @@ func TestFSRemoteInventoryAndIDCredentialSelectionAcrossCommandFamilies(t *testi describe := runTIWithInput(t, bin, "", baseEnv, "--profile", "stage", "--region", "aws-us-west-2", "fs", "describe-file-system", "--file-system-id", "tenant-aws-us-west-2") describe.wantExitCode(0) describe.wantStdoutContains(`"file_system_id": "tenant-aws-us-west-2"`) + describe.wantStdoutContains(`"display_name": "tenant-aws-us-west-2"`) + describe.wantStdoutContains(`"labels": {}`) + describe.wantStdoutContains(`"quota": {`) describe.wantStdoutContains(`"region_code": "aws-us-west-2"`) describe.wantStdoutNotContains("drive9_") textDescribe := runTIWithInput(t, bin, "", baseEnv, "--profile", "stage", "--region", "aws-us-west-2", "fs", "describe-file-system", "--file-system-id", "tenant-aws-us-west-2", "--output", "text") textDescribe.wantExitCode(0) textDescribe.wantStdoutContains("File system ID: tenant-aws-us-west-2") + textDescribe.wantStdoutContains("Display name: tenant-aws-us-west-2") + textDescribe.wantStdoutContains("Labels: none") textDescribe.wantStdoutNotContains(`"file_system_id"`) callsBeforeMissingSelectorCommands := len(readFakeDrive9Calls(t, recordPath)) for _, args := range [][]string{ @@ -732,10 +785,18 @@ func TestFSRemoteInventoryAndIDCredentialSelectionAcrossCommandFamilies(t *testi mount.wantExitCode(0) calls := readFakeDrive9Calls(t, recordPath) - assertFakeDrive9TransientCall(t, calls, []string{"create"}, "", home, "https://fs-east.test", "aws-us-east-1") - assertFakeDrive9Call(t, calls, []string{"admin", "tenant", "list"}, "", home, "stage", "_control-plane", "https://fs-east.test", "aws-us-east-1") - assertFakeDrive9Call(t, calls, []string{"fs", "ls"}, drive9TestToken("tenant-aws-us-west-2"), home, "stage", "tenant-aws-us-west-2", "https://fs-west.test", "aws-us-west-2") - assertFakeDrive9Call(t, calls, []string{"vault", "ls"}, drive9TestToken("tenant-aws-us-east-1"), home, "stage", "tenant-aws-us-east-1", "https://fs-east.test", "aws-us-east-1") + for _, call := range calls { + if len(call.Args) > 0 && call.Args[0] == "create" || len(call.Args) >= 3 && call.Args[0] == "admin" && call.Args[1] == "tenant" { + t.Fatalf("direct file system control plane unexpectedly invoked ti-drive9: %#v", call.Args) + } + } + assertFakeDrive9Call(t, calls, []string{"fs", "ls"}, drive9TestToken("tenant-aws-us-west-2"), home, "stage", "tenant-aws-us-west-2", westControl.URL(), "aws-us-west-2") + assertFakeDrive9Call(t, calls, []string{"vault", "ls"}, drive9TestToken("tenant-aws-us-east-1"), home, "stage", "tenant-aws-us-east-1", eastControl.URL(), "aws-us-east-1") + if !eastControl.hasRequest(http.MethodPost, "/v1/admin/tenants") || + !eastControl.hasRequest(http.MethodGet, "/v1/admin/tenants", "display_name=agent", "label=environment%3D%3Dproduction") || + !westControl.hasRequest(http.MethodGet, "/v1/admin/tenants/tenant-aws-us-west-2") { + t.Fatalf("direct control-plane requests were incomplete: east=%#v west=%#v", eastControl.requests, westControl.requests) + } deleteScratch := runTIWithInput(t, bin, "", baseEnv, "--profile", "stage", "--region", "aws-us-west-2", "fs", "delete-file-system", "--file-system-id", "tenant-aws-us-west-2") deleteScratch.wantExitCode(0) @@ -750,7 +811,9 @@ func TestFSRemoteInventoryAndIDCredentialSelectionAcrossCommandFamilies(t *testi stillMissingAfterDelete := runTIWithInput(t, bin, "", baseEnv, "--profile", "stage", "fs", "list-files", "--path", "/") stillMissingAfterDelete.wantExitCode(2) stillMissingAfterDelete.wantStderrContains("file system ID is required") - assertFakeDrive9Call(t, readFakeDrive9Calls(t, recordPath), []string{"admin", "tenant", "delete"}, "", home, "stage", "_control-plane", "https://fs-west.test", "aws-us-west-2") + if !westControl.hasRequest(http.MethodDelete, "/v1/admin/tenants/tenant-aws-us-west-2") { + t.Fatalf("delete did not use the direct control plane: %#v", westControl.requests) + } for _, args := range [][]string{ {"--profile", "stage", "fs", "set-default-file-system"}, @@ -1103,6 +1166,148 @@ func drive9TestTokenWithVersion(fileSystemID string, version int) string { return "drive9_" + base64.RawURLEncoding.EncodeToString([]byte(jwt)) } +type fakeFSTenant struct { + TenantID string `json:"tenant_id"` + DisplayName string `json:"display_name"` + Labels map[string]string `json:"label"` + Status string `json:"status"` + Kind string `json:"kind"` + Quota map[string]any `json:"quota"` +} + +type fakeFSTenantRequest struct { + Method string + Path string + Query string +} + +type fakeFSTenantControlPlane struct { + t *testing.T + regionCode string + tenantID string + server *httptest.Server + mu sync.Mutex + tenants map[string]fakeFSTenant + requests []fakeFSTenantRequest +} + +func newFakeFSTenantControlPlane(t *testing.T, regionCode, tenantID string) *fakeFSTenantControlPlane { + t.Helper() + fake := &fakeFSTenantControlPlane{t: t, regionCode: regionCode, tenantID: tenantID, tenants: map[string]fakeFSTenant{}} + fake.server = httptest.NewServer(http.HandlerFunc(fake.serveHTTP)) + return fake +} + +func (f *fakeFSTenantControlPlane) close() { + f.server.Close() +} + +func (f *fakeFSTenantControlPlane) URL() string { + return f.server.URL +} + +func (f *fakeFSTenantControlPlane) serveHTTP(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + f.requests = append(f.requests, fakeFSTenantRequest{Method: r.Method, Path: r.URL.Path, Query: r.URL.RawQuery}) + if r.Header.Get("X-TiDBCloud-Public-Key") != "e2e-public" || r.Header.Get("X-TiDBCloud-Private-Key") != "e2e-private" { + http.Error(w, `{"error":"missing TiDB Cloud credentials"}`, http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodPost && r.URL.Path == "/v1/admin/tenants": + var body struct { + DisplayName string `json:"display_name"` + Labels map[string]string `json:"label"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + displayName := body.DisplayName + if displayName == "" { + displayName = f.tenantID + } + labels := map[string]string{} + for key, value := range body.Labels { + labels[key] = value + } + tenant := fakeFSTenant{ + TenantID: f.tenantID, DisplayName: displayName, Labels: labels, Status: "active", Kind: "live", Quota: fakeFSTenantQuota(), + } + f.tenants[tenant.TenantID] = tenant + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(map[string]any{ + "tenant_id": tenant.TenantID, "display_name": tenant.DisplayName, "label": tenant.Labels, "api_key": drive9TestToken(tenant.TenantID), + "status": "provisioning", "cloud_provider": "aws", "region": strings.TrimPrefix(f.regionCode, "aws-"), + }) + case r.Method == http.MethodGet && r.URL.Path == "/v1/admin/tenants": + items := make([]fakeFSTenant, 0, len(f.tenants)) + displayFilter := r.URL.Query().Get("display_name") + labelFilter := r.URL.Query().Get("label") + labelKey, labelValue, hasLabelFilter := strings.Cut(labelFilter, "==") + for _, tenant := range f.tenants { + if displayFilter != "" && !strings.Contains(tenant.DisplayName, displayFilter) { + continue + } + if hasLabelFilter && tenant.Labels[labelKey] != labelValue { + continue + } + items = append(items, tenant) + } + sort.Slice(items, func(i, j int) bool { return items[i].TenantID < items[j].TenantID }) + _ = json.NewEncoder(w).Encode(map[string]any{"tenants": items, "page": 1, "page_size": 100, "next_page": 0}) + case strings.HasPrefix(r.URL.Path, "/v1/admin/tenants/"): + id := strings.TrimPrefix(r.URL.Path, "/v1/admin/tenants/") + tenant, ok := f.tenants[id] + if !ok { + http.Error(w, `{"error":"tenant not found"}`, http.StatusNotFound) + return + } + switch r.Method { + case http.MethodGet: + _ = json.NewEncoder(w).Encode(tenant) + case http.MethodDelete: + delete(f.tenants, id) + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(map[string]string{"tenant_id": id, "status": "deleting"}) + default: + http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) + } + default: + http.NotFound(w, r) + } +} + +func (f *fakeFSTenantControlPlane) hasRequest(method, path string, queryParts ...string) bool { + f.mu.Lock() + defer f.mu.Unlock() + for _, request := range f.requests { + if request.Method != method || request.Path != path { + continue + } + matched := true + for _, part := range queryParts { + if !strings.Contains(request.Query, part) { + matched = false + break + } + } + if matched { + return true + } + } + return false +} + +func fakeFSTenantQuota() map[string]any { + return map[string]any{ + "config": map[string]any{"max_storage_size": 1024, "max_file_size": 128, "max_file_count": 1000, "tidbcloud_spending_limit": nil}, + "usage": map[string]any{"storage_bytes": 0, "reserved_bytes": 0, "file_count": 0}, + } +} + type fakeDrive9Call struct { Args []string `json:"args"` Home string `json:"home"` diff --git a/e2e/live_test.go b/e2e/live_test.go index 2fd4668..fe4cc1e 100644 --- a/e2e/live_test.go +++ b/e2e/live_test.go @@ -84,30 +84,62 @@ func TestLiveFSRemoteInventoryLifecycle(t *testing.T) { bin := tiBinary(t) profileName := liveProfileName(t) + displayName := fmt.Sprintf("ti-e2e-fs-%d-%d", os.Getpid(), time.Now().UnixNano()) + labelKey := "ti-e2e-run" + labelValue := fmt.Sprintf("run-%d-%d", os.Getpid(), time.Now().UnixNano()) preflightList := runTI(t, bin, "--profile", profileName, "fs", "list-file-systems") preflightList.wantExitCode(0) - create := runTI(t, bin, "--profile", profileName, "fs", "create-file-system", "--wait") + create := runTI(t, bin, "--profile", profileName, "fs", "create-file-system", + "--display-name", displayName, + "--label", labelKey+"="+labelValue, + "--wait") if create.exitCode != 0 && isLiveFSQuotaError(create.stderr) { t.Skipf("tdc fs live inventory lifecycle requires one free Starter slot: %s", strings.TrimSpace(create.stderr)) } create.wantExitCode(0) var created struct { - FileSystemID string `json:"file_system_id"` - FSToken string `json:"fs_token"` + FileSystemID string `json:"file_system_id"` + DisplayName string `json:"display_name"` + Labels map[string]string `json:"labels"` + FSToken string `json:"fs_token"` + Status string `json:"status"` + CredentialsStored bool `json:"credentials_stored"` } if err := json.Unmarshal([]byte(create.stdout), &created); err != nil || created.FileSystemID == "" || created.FSToken == "" { t.Fatalf("decode live tdc fs create result: %v", err) } + if created.DisplayName != displayName || created.Labels[labelKey] != labelValue { + t.Fatalf("created live FS metadata mismatch: display_name=%q labels=%v", created.DisplayName, created.Labels) + } + if created.Status != "ready" || !created.CredentialsStored { + t.Fatalf("created live FS readiness mismatch: status=%q credentials_stored=%t", created.Status, created.CredentialsStored) + } liveFSResourceMu.Lock() liveFSResourceAutoCreatedID = created.FileSystemID liveFSSelectedID = created.FileSystemID liveFSResourceMu.Unlock() - list := runTI(t, bin, "--profile", profileName, "fs", "list-file-systems") + list := runTI(t, bin, "--profile", profileName, "fs", "list-file-systems", + "--display-name", displayName, + "--label", labelKey+"="+labelValue) list.wantExitCode(0) list.wantStdoutContains(`"file_system_id": "` + created.FileSystemID + `"`) + list.wantStdoutContains(`"display_name": "` + displayName + `"`) + list.wantStdoutContains(`"` + labelKey + `": "` + labelValue + `"`) + list.wantStdoutContains(`"quota":`) list.wantStdoutContains(`"has_local_token": true`) list.wantStdoutNotContains(created.FSToken) + var filtered struct { + FileSystems []struct { + FileSystemID string `json:"file_system_id"` + } `json:"file_systems"` + } + if err := json.Unmarshal([]byte(list.stdout), &filtered); err != nil { + t.Fatalf("decode filtered live FS inventory: %v", err) + } + if len(filtered.FileSystems) != 1 || filtered.FileSystems[0].FileSystemID != created.FileSystemID { + t.Fatalf("filtered live FS inventory mismatch: %#v", filtered.FileSystems) + } missingSelector := runTIWithInput(t, bin, "", []string{"TI_FS_FILE_SYSTEM_ID="}, "--profile", profileName, "fs", "check-file-system") missingSelector.wantExitCode(2) @@ -121,6 +153,11 @@ func TestLiveFSRemoteInventoryLifecycle(t *testing.T) { describe := runTI(t, bin, "--profile", profileName, "fs", "describe-file-system", "--file-system-id", created.FileSystemID) describe.wantExitCode(0) describe.wantStdoutContains(`"file_system_id": "` + created.FileSystemID + `"`) + describe.wantStdoutContains(`"display_name": "` + displayName + `"`) + describe.wantStdoutContains(`"` + labelKey + `": "` + labelValue + `"`) + describe.wantStdoutContains(`"quota":`) + describe.wantStdoutContains(`"has_local_token": true`) + describe.wantStdoutNotContains(created.FSToken) } func TestLiveCLICommandSurface(t *testing.T) { @@ -221,7 +258,7 @@ func TestLiveFSCommandSurface(t *testing.T) { {"fs", "mount", "help"}, {"fs", "drain", "help"}, {"fs", "umount", "help"}, }) testLiveMutatingDryRuns(t, bin, profileName, [][]string{ - {"fs", "create-file-system", "--wait"}, + {"fs", "create-file-system", "--display-name", "ti-e2e-dry-run", "--label", "environment=test", "--wait"}, {"fs", "delete-file-system", "--file-system-id", selected.FSTenantID}, {"fs", "create-layer", "--layer-id", "layer-1", "--base-root-path", "/workspace", "--layer-name", "dev"}, {"fs", "create-layer-checkpoint", "--layer-id", "layer-1", "--checkpoint-id", "cp-1"}, diff --git a/internal/api/client.go b/internal/api/client.go index 24f735c..ef3734a 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -303,6 +303,7 @@ func (c *Client) statusError(req *http.Request, res *http.Response) error { body, _ := io.ReadAll(io.LimitReader(res.Body, 64*1024)) body = []byte(c.Redactor.Redact(string(body))) apiMessage := responseMessage(body) + requestID := responseRequestID(res.Header) switch res.StatusCode { case http.StatusBadRequest: message := messageOrDefault(apiMessage, "remote API rejected the request: check command flags and try again") @@ -317,13 +318,14 @@ func (c *Client) statusError(req *http.Request, res *http.Response) error { Category: "api", ExitCode: 2, StatusCode: res.StatusCode, + RequestID: requestID, Message: message, Body: string(body), } case http.StatusUnauthorized: message := fmt.Sprintf("authentication failed: TiDB Cloud rejected the API key pair for profile %q. Check ~/.ti/credentials or create a new API key.", profileName(c.ProfileName)) if c.Service == endpoints.ServiceFS { - if !c.BearerAuth && strings.HasPrefix(string(c.Permission), "fs.token.") && c.Permission != authz.FSTokenRefresh { + if !c.BearerAuth { message = fmt.Sprintf("authentication failed: TiDB Cloud rejected the API key pair for profile %q. Check ~/.ti/credentials or create a new API key.", profileName(c.ProfileName)) } else { message = fmt.Sprintf("authentication failed: ti fs rejected the selected token for profile %q. It might be disabled, expired, refreshed elsewhere, or revoked; generate or import a valid token and try again.", profileName(c.ProfileName)) @@ -334,6 +336,7 @@ func (c *Client) statusError(req *http.Request, res *http.Response) error { Category: "authentication", ExitCode: 3, StatusCode: res.StatusCode, + RequestID: requestID, Message: message, Body: string(body), } @@ -354,12 +357,16 @@ func (c *Client) statusError(req *http.Request, res *http.Response) error { case authz.FSFileRead, authz.FSFileWrite, authz.FSMount: message = "permission denied: the selected FS token does not allow this operation or path; use an owner token or a scoped token whose prefix and operations include the request" } + if !c.BearerAuth && apiMessage != "" { + message += " Backend: " + apiMessage + } } return &Error{ Code: "authz.permission_denied", Category: "authorization", ExitCode: 4, StatusCode: res.StatusCode, + RequestID: requestID, Message: message, Body: string(body), } @@ -369,6 +376,7 @@ func (c *Client) statusError(req *http.Request, res *http.Response) error { Category: "api", ExitCode: 5, StatusCode: res.StatusCode, + RequestID: requestID, Message: fmt.Sprintf("remote resource not found: %s %s", req.Method, req.URL.Path), Body: string(body), } @@ -378,6 +386,7 @@ func (c *Client) statusError(req *http.Request, res *http.Response) error { Category: "api", ExitCode: 1, StatusCode: res.StatusCode, + RequestID: requestID, Message: fmt.Sprintf("API gap: %s %s is not available from the remote service; keep this command behind its service-specific client until the API contract is confirmed", req.Method, req.URL.Path), Body: string(body), } @@ -387,6 +396,7 @@ func (c *Client) statusError(req *http.Request, res *http.Response) error { Category: "api", ExitCode: 1, StatusCode: res.StatusCode, + RequestID: requestID, Message: "payment required: " + messageOrDefault(apiMessage, "the remote service rejected the request because payment could not be processed"), Body: string(body), } @@ -396,6 +406,7 @@ func (c *Client) statusError(req *http.Request, res *http.Response) error { Category: "api", ExitCode: 1, StatusCode: res.StatusCode, + RequestID: requestID, Message: messageOrDefault(apiMessage, "API rate limit exceeded: retry later"), Body: string(body), } @@ -404,13 +415,14 @@ func (c *Client) statusError(req *http.Request, res *http.Response) error { if c.Permission == authz.FSTokenEnable && strings.Contains(strings.ToLower(message), "expired") { message = "the token is expired and cannot be enabled; generate a new token instead" } - return &Error{Code: "api.conflict", Category: "api", ExitCode: 1, StatusCode: res.StatusCode, Message: message, Body: string(body)} + return &Error{Code: "api.conflict", Category: "api", ExitCode: 1, StatusCode: res.StatusCode, RequestID: requestID, Message: message, Body: string(body)} default: return &Error{ Code: "api.remote_error", Category: "api", ExitCode: 1, StatusCode: res.StatusCode, + RequestID: requestID, Message: messageOrDefault(apiMessage, fmt.Sprintf("API request failed with HTTP %d", res.StatusCode)), Body: string(body), } diff --git a/internal/api/error.go b/internal/api/error.go index 05aebcf..61f1e55 100644 --- a/internal/api/error.go +++ b/internal/api/error.go @@ -11,6 +11,7 @@ type Error struct { Category string ExitCode int StatusCode int + RequestID string Message string Body string Cause error diff --git a/internal/api/fs/admin_tenant.go b/internal/api/fs/admin_tenant.go new file mode 100644 index 0000000..e9fffba --- /dev/null +++ b/internal/api/fs/admin_tenant.go @@ -0,0 +1,150 @@ +package fs + +import ( + "context" + "net/http" + "net/url" + "strconv" +) + +type AdminTenantCreateRequest struct { + DisplayName string `json:"display_name,omitempty"` + Labels map[string]string `json:"label,omitempty"` +} + +type AdminTenantCreateResponse struct { + TenantID string `json:"tenant_id"` + DisplayName string `json:"display_name"` + Labels map[string]string `json:"label"` + APIKey string `json:"api_key"` + Status string `json:"status"` + CloudProvider string `json:"cloud_provider,omitempty"` + Region string `json:"region,omitempty"` +} + +type AdminTenantQuotaConfig struct { + MaxStorageSize int64 `json:"max_storage_size"` + MaxFileSize int64 `json:"max_file_size"` + MaxFileCount int64 `json:"max_file_count"` + TiDBCloudSpendingLimit *int64 `json:"tidbcloud_spending_limit"` +} + +type AdminTenantQuotaUsage struct { + StorageBytes int64 `json:"storage_bytes"` + ReservedBytes int64 `json:"reserved_bytes"` + FileCount int64 `json:"file_count"` +} + +type AdminTenantQuota struct { + Config AdminTenantQuotaConfig `json:"config"` + Usage AdminTenantQuotaUsage `json:"usage"` +} + +type AdminTenant struct { + TenantID string `json:"tenant_id"` + DisplayName string `json:"display_name"` + Labels map[string]string `json:"label"` + Status string `json:"status"` + Kind string `json:"kind"` + Quota *AdminTenantQuota `json:"quota"` +} + +type AdminTenantLabelFilter struct { + Key string + Value string +} + +type ListAdminTenantsOptions struct { + Page int + PageSize int + DisplayName string + Label *AdminTenantLabelFilter +} + +type ListAdminTenantsResponse struct { + Tenants []AdminTenant `json:"tenants"` + Page int `json:"page"` + PageSize int `json:"page_size"` + NextPage int `json:"next_page,omitempty"` +} + +type DeleteAdminTenantResponse struct { + TenantID string `json:"tenant_id"` + Status string `json:"status"` +} + +func (c *Client) CreateAdminTenant(ctx context.Context, creds TiDBCloudCredentials, input AdminTenantCreateRequest) (AdminTenantCreateResponse, error) { + req, err := c.api.NewRequest(ctx, http.MethodPost, "/v1/admin/tenants", input) + if err != nil { + return AdminTenantCreateResponse{}, err + } + setTiDBCloudCredentialHeaders(req, creds) + var response AdminTenantCreateResponse + if err := c.api.DoJSON(req, &response); err != nil { + return AdminTenantCreateResponse{}, err + } + response.Labels = nonNilLabels(response.Labels) + return response, nil +} + +func (c *Client) ListAdminTenants(ctx context.Context, creds TiDBCloudCredentials, opts ListAdminTenantsOptions) (ListAdminTenantsResponse, error) { + query := url.Values{} + query.Set("page", strconv.Itoa(opts.Page)) + query.Set("page_size", strconv.Itoa(opts.PageSize)) + if opts.DisplayName != "" { + query.Set("display_name", opts.DisplayName) + } + if opts.Label != nil { + query.Set("label", opts.Label.Key+"=="+opts.Label.Value) + } + req, err := c.api.NewRequest(ctx, http.MethodGet, "/v1/admin/tenants?"+query.Encode(), nil) + if err != nil { + return ListAdminTenantsResponse{}, err + } + setTiDBCloudCredentialHeaders(req, creds) + var response ListAdminTenantsResponse + if err := c.api.DoJSON(req, &response); err != nil { + return ListAdminTenantsResponse{}, err + } + if response.Tenants == nil { + response.Tenants = []AdminTenant{} + } + for i := range response.Tenants { + response.Tenants[i].Labels = nonNilLabels(response.Tenants[i].Labels) + } + return response, nil +} + +func (c *Client) GetAdminTenant(ctx context.Context, creds TiDBCloudCredentials, fileSystemID string) (AdminTenant, error) { + req, err := c.api.NewRequest(ctx, http.MethodGet, "/v1/admin/tenants/"+url.PathEscape(fileSystemID), nil) + if err != nil { + return AdminTenant{}, err + } + setTiDBCloudCredentialHeaders(req, creds) + var response AdminTenant + if err := c.api.DoJSON(req, &response); err != nil { + return AdminTenant{}, err + } + response.Labels = nonNilLabels(response.Labels) + return response, nil +} + +func (c *Client) DeleteAdminTenant(ctx context.Context, creds TiDBCloudCredentials, fileSystemID string) (DeleteAdminTenantResponse, error) { + req, err := c.api.NewRequest(ctx, http.MethodDelete, "/v1/admin/tenants/"+url.PathEscape(fileSystemID), nil) + if err != nil { + return DeleteAdminTenantResponse{}, err + } + setTiDBCloudCredentialHeaders(req, creds) + var response DeleteAdminTenantResponse + if err := c.api.DoJSON(req, &response); err != nil { + return DeleteAdminTenantResponse{}, err + } + return response, nil +} + +func nonNilLabels(labels map[string]string) map[string]string { + if labels == nil { + return map[string]string{} + } + return labels +} diff --git a/internal/api/fs/admin_tenant_test.go b/internal/api/fs/admin_tenant_test.go new file mode 100644 index 0000000..3c8dd7b --- /dev/null +++ b/internal/api/fs/admin_tenant_test.go @@ -0,0 +1,107 @@ +package fs + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestAdminTenantClientContracts(t *testing.T) { + const publicKey = "public-secret" + const privateKey = "private-secret" + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + if r.Header.Get(tidbCloudPublicKeyHeader) != publicKey || r.Header.Get(tidbCloudPrivateKeyHeader) != privateKey { + t.Fatalf("credential headers = %q/%q", r.Header.Get(tidbCloudPublicKeyHeader), r.Header.Get(tidbCloudPrivateKeyHeader)) + } + switch { + case r.Method == http.MethodPost && r.URL.Path == "/v1/admin/tenants": + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if len(body) != 2 || body["display_name"] != "agent-workspace" { + t.Fatalf("create body = %#v", body) + } + if _, ok := body["public_key"]; ok { + t.Fatalf("create body leaked credentials: %#v", body) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "tenant_id": "tenant-1", "display_name": "agent-workspace", "label": map[string]string{"environment": "production"}, + "api_key": "owner-token", "status": "provisioning", "cloud_provider": "aws", "region": "ap-southeast-1", "future": true, + }) + case r.Method == http.MethodGet && r.URL.Path == "/v1/admin/tenants": + query := r.URL.Query() + if query.Get("page") != "2" || query.Get("page_size") != "100" || query.Get("display_name") != "workspace" || query.Get("label") != "environment==production" { + t.Fatalf("list query = %q", r.URL.RawQuery) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "tenants": []map[string]any{{"tenant_id": "tenant-1", "display_name": "agent-workspace", "label": nil, "status": "active", "kind": "live"}}, + "page": 2, "page_size": 100, "next_page": 3, + }) + case r.Method == http.MethodGet && r.URL.Path == "/v1/admin/tenants/tenant-1": + _ = json.NewEncoder(w).Encode(AdminTenant{TenantID: "tenant-1", DisplayName: "agent-workspace", Status: "active", Kind: "live"}) + case r.Method == http.MethodDelete && r.URL.Path == "/v1/admin/tenants/tenant-1": + _ = json.NewEncoder(w).Encode(DeleteAdminTenantResponse{TenantID: "tenant-1", Status: "deleting"}) + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.RequestURI()) + } + })) + defer server.Close() + + client := testClient(t, server.URL) + creds := TiDBCloudCredentials{PublicKey: publicKey, PrivateKey: privateKey} + created, err := client.CreateAdminTenant(context.Background(), creds, AdminTenantCreateRequest{DisplayName: "agent-workspace", Labels: map[string]string{"environment": "production"}}) + if err != nil { + t.Fatal(err) + } + if created.TenantID != "tenant-1" || created.APIKey != "owner-token" || created.Labels["environment"] != "production" { + t.Fatalf("create response = %#v", created) + } + listed, err := client.ListAdminTenants(context.Background(), creds, ListAdminTenantsOptions{Page: 2, PageSize: 100, DisplayName: "workspace", Label: &AdminTenantLabelFilter{Key: "environment", Value: "production"}}) + if err != nil { + t.Fatal(err) + } + if len(listed.Tenants) != 1 || listed.NextPage != 3 || listed.Tenants[0].Labels == nil { + t.Fatalf("list response = %#v", listed) + } + described, err := client.GetAdminTenant(context.Background(), creds, "tenant-1") + if err != nil { + t.Fatal(err) + } + if described.TenantID != "tenant-1" || described.Labels == nil { + t.Fatalf("get response = %#v", described) + } + deleted, err := client.DeleteAdminTenant(context.Background(), creds, "tenant-1") + if err != nil { + t.Fatal(err) + } + if deleted.Status != "deleting" || requests != 4 { + t.Fatalf("delete response = %#v, requests = %d", deleted, requests) + } +} + +func TestAdminTenantCreateOmitsEmptyMetadata(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if len(body) != 0 { + t.Fatalf("body = %#v, want empty metadata object", body) + } + _ = json.NewEncoder(w).Encode(AdminTenantCreateResponse{TenantID: "tenant-1", DisplayName: "tenant-1", APIKey: "owner-token", Status: "active"}) + })) + defer server.Close() + + response, err := testClient(t, server.URL).CreateAdminTenant(context.Background(), TiDBCloudCredentials{PublicKey: "public", PrivateKey: "private"}, AdminTenantCreateRequest{}) + if err != nil { + t.Fatal(err) + } + if response.Labels == nil { + t.Fatal("labels must be normalized to an empty object") + } +} diff --git a/internal/cli/commands.go b/internal/cli/commands.go index c130c18..5789caa 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -1289,7 +1289,7 @@ func addFSAuthFlags(commands []*cobra.Command, excluded ...string) { func newFSCreateFileSystemCommand(info version.Info) *cobra.Command { cmd := newControlPlaneCommand(controlPlaneCommandSpec{ Use: "create-file-system", - Short: "Create a file system (agentFS) in TiDB Cloud.", + Short: "Create a TiDB Cloud Filesystem.", Mutation: mutatingCommand, Permission: authz.FSVolumeCreate, Run: func(ctx commandContext) (any, error) { @@ -1300,36 +1300,32 @@ func newFSCreateFileSystemCommand(info version.Info) *cobra.Command { if err := fscred.MigrateNameRegistry(profile.HomeDir, profile); err != nil { return nil, err } - waitUntilReady, err := ctx.BoolFlag("wait") + opts, err := fsCreateFileSystemOptions(ctx, profile) if err != nil { return nil, err } - return service.CreateFileSystem(ctx.cmd.Context(), tifs.CreateFileSystemOptions{ - Profile: profile, - WaitUntilReady: waitUntilReady, - }) + return service.CreateFileSystem(ctx.cmd.Context(), opts) }, DryRun: func(ctx commandContext) (dryrun.Result, error) { service, profile, err := fsTIServiceAndProfile(ctx) if err != nil { return dryrun.Result{}, err } - waitUntilReady, err := ctx.BoolFlag("wait") + opts, err := fsCreateFileSystemOptions(ctx, profile) if err != nil { return dryrun.Result{}, err } - return service.DryRunCreateFileSystem(ctx.cmd.Context(), ctx.CommandPath(), tifs.CreateFileSystemOptions{ - Profile: profile, - WaitUntilReady: waitUntilReady, - }) + return service.DryRunCreateFileSystem(ctx.cmd.Context(), ctx.CommandPath(), opts) }, }, info) + cmd.Flags().String("display-name", "", "Organization-visible file system display name. It is metadata, not a resource selector.") + cmd.Flags().StringArray("label", nil, "Organization-visible metadata label in key=value form. Repeat up to 30 times; do not store secrets.") cmd.Flags().Bool("wait", false, "Wait until the created file system is active.") return cmd } func newFSListFileSystemsCommand(info version.Info) *cobra.Command { - return newControlPlaneCommand(controlPlaneCommandSpec{ + cmd := newControlPlaneCommand(controlPlaneCommandSpec{ Use: "list-file-systems", Short: "List remote file systems in the selected region. (preview)", Mutation: readOnlyCommand, @@ -1339,9 +1335,16 @@ func newFSListFileSystemsCommand(info version.Info) *cobra.Command { if err != nil { return nil, err } - return service.ListFileSystems(ctx.cmd.Context(), profile) + displayName, label, err := fsListFileSystemFilters(ctx) + if err != nil { + return nil, err + } + return service.ListFileSystems(ctx.cmd.Context(), tifs.ListFileSystemsOptions{Profile: profile, DisplayName: displayName, Label: label}) }, }, info) + cmd.Flags().String("display-name", "", "Filter by a substring of the effective display name.") + cmd.Flags().StringArray("label", nil, "Filter by one exact organization-visible label in key=value form.") + return cmd } func newFSDescribeFileSystemCommand(info version.Info) *cobra.Command { @@ -2803,6 +2806,38 @@ func fsServiceAndProfile(ctx commandContext) (tifs.Service, *config.Profile, err return fsAuthenticatedServiceAndProfile(ctx, true) } +func fsCreateFileSystemOptions(ctx commandContext, profile *config.Profile) (tifs.CreateFileSystemOptions, error) { + waitUntilReady, err := ctx.BoolFlag("wait") + if err != nil { + return tifs.CreateFileSystemOptions{}, err + } + displayName, err := ctx.StringFlag("display-name") + if err != nil { + return tifs.CreateFileSystemOptions{}, err + } + labels, err := ctx.StringArrayFlag("label") + if err != nil { + return tifs.CreateFileSystemOptions{}, err + } + parsedDisplayName, parsedLabels, err := tifs.ParseTenantMetadata(displayName, ctx.FlagChanged("display-name"), labels) + if err != nil { + return tifs.CreateFileSystemOptions{}, err + } + return tifs.CreateFileSystemOptions{Profile: profile, WaitUntilReady: waitUntilReady, DisplayName: parsedDisplayName, Labels: parsedLabels}, nil +} + +func fsListFileSystemFilters(ctx commandContext) (*string, *tifs.LabelFilter, error) { + displayName, err := ctx.StringFlag("display-name") + if err != nil { + return nil, nil, err + } + labels, err := ctx.StringArrayFlag("label") + if err != nil { + return nil, nil, err + } + return tifs.ParseTenantListFilters(displayName, ctx.FlagChanged("display-name"), labels) +} + func fsAuthenticatedServiceAndProfile(ctx commandContext, tokenRequired bool) (tifs.Service, *config.Profile, error) { service, profile, err := fsLocalServiceAndProfile(ctx) if err != nil { diff --git a/internal/fs/control.go b/internal/fs/control.go index ac16c56..572a09a 100644 --- a/internal/fs/control.go +++ b/internal/fs/control.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "os" + "sort" "strings" "text/tabwriter" "time" @@ -41,6 +42,19 @@ type Service struct { type CreateFileSystemOptions struct { Profile *config.Profile WaitUntilReady bool + DisplayName *string + Labels map[string]string +} + +type LabelFilter struct { + Key string + Value string +} + +type ListFileSystemsOptions struct { + Profile *config.Profile + DisplayName *string + Label *LabelFilter } type DeleteFileSystemOptions struct { @@ -60,12 +74,14 @@ type ImportFileSystemTokenOptions struct { } type FileSystemSummary struct { - FileSystemID string `json:"file_system_id"` - RegionCode string `json:"region_code,omitempty"` - Status string `json:"status,omitempty"` - Kind string `json:"kind,omitempty"` - Quota any `json:"quota,omitempty"` - HasLocalToken bool `json:"has_local_token"` + FileSystemID string `json:"file_system_id"` + DisplayName string `json:"display_name"` + Labels map[string]string `json:"labels"` + RegionCode string `json:"region_code,omitempty"` + Status string `json:"status,omitempty"` + Kind string `json:"kind,omitempty"` + Quota *apifs.AdminTenantQuota `json:"quota,omitempty"` + HasLocalToken bool `json:"has_local_token"` } type ListFileSystemsResult struct { @@ -78,11 +94,13 @@ type DescribeFileSystemResult struct { } type FileSystemResult struct { - FileSystemID string `json:"file_system_id"` - RegionCode string `json:"region_code,omitempty"` - FSToken string `json:"fs_token,omitempty"` - Status string `json:"status"` - CredentialsStored bool `json:"credentials_stored"` + FileSystemID string `json:"file_system_id"` + DisplayName string `json:"display_name"` + Labels map[string]string `json:"labels"` + RegionCode string `json:"region_code,omitempty"` + FSToken string `json:"fs_token,omitempty"` + Status string `json:"status"` + CredentialsStored bool `json:"credentials_stored"` } type DeleteResult struct { @@ -115,23 +133,23 @@ type Check struct { } func (s Service) CreateFileSystem(ctx context.Context, opts CreateFileSystemOptions) (FileSystemResult, error) { - return s.drive9CreateFileSystem(ctx, opts) + return s.createFileSystem(ctx, opts) } func (s Service) DeleteFileSystem(ctx context.Context, opts DeleteFileSystemOptions) (DeleteResult, error) { - return s.drive9DeleteFileSystem(ctx, opts) + return s.deleteFileSystem(ctx, opts) } func (s Service) CheckFileSystem(ctx context.Context, opts CheckFileSystemOptions) (CheckResult, error) { return s.drive9CheckFileSystem(ctx, opts) } -func (s Service) ListFileSystems(ctx context.Context, profile *config.Profile) (ListFileSystemsResult, error) { - return s.drive9ListFileSystems(ctx, profile) +func (s Service) ListFileSystems(ctx context.Context, opts ListFileSystemsOptions) (ListFileSystemsResult, error) { + return s.listFileSystems(ctx, opts) } func (s Service) DescribeFileSystem(ctx context.Context, profile *config.Profile, fileSystemID string) (DescribeFileSystemResult, error) { - return s.drive9DescribeFileSystem(ctx, profile, fileSystemID) + return s.describeFileSystem(ctx, profile, fileSystemID) } func (s Service) ImportFileSystemToken(ctx context.Context, opts ImportFileSystemTokenOptions) (ImportFileSystemTokenResult, error) { @@ -157,16 +175,16 @@ func (s Service) DryRunImportFileSystemToken(ctx context.Context, commandPath st } func (s Service) DryRunCreateFileSystem(ctx context.Context, commandPath string, opts CreateFileSystemOptions) (dryrun.Result, error) { - request, endpoint, endpointErr, err := s.createDryRunInputs(opts) + request, _, _, endpoint, err := s.adminCreateInputs(opts) if err != nil { return dryrun.Result{}, err } checks := []dryrun.Check{ {Name: "config_and_credentials", Status: "passed", Message: fmt.Sprintf("profile %q loaded", profileName(opts.Profile))}, {Name: "permission_requirement", Status: "passed", Message: string(authz.FSVolumeCreate)}, - {Name: "remote_identity", Status: "passed", Message: "Drive9 assigns file_system_id"}, + {Name: "remote_identity", Status: "passed", Message: "the File System backend assigns file_system_id"}, } - checks = append(checks, endpointDryRunCheck(endpoint, endpointErr)) + checks = append(checks, endpointDryRunCheck(endpoint, nil)) if opts.WaitUntilReady { checks = append(checks, dryrun.Check{ Name: "post_create_wait", @@ -178,16 +196,17 @@ func (s Service) DryRunCreateFileSystem(ctx context.Context, commandPath string, commandPath, "create_file_system", dryrun.RequestSummary{ - Method: http.MethodPost, - Path: "/v1/provision", - Body: redactedProvisionRequest(request), + Method: http.MethodPost, + Path: "/v1/admin/tenants", + Body: request, + Description: "normal execution authenticates with TiDB Cloud API-key headers; credential values are not included in the request body or dry-run output", }, checks..., ), nil } func (s Service) DryRunDeleteFileSystem(ctx context.Context, commandPath string, opts DeleteFileSystemOptions) (dryrun.Result, error) { - fileSystemID, endpoint, endpointErr, err := s.deleteDryRunInputs(opts) + fileSystemID, _, _, endpoint, err := s.adminDeleteInputs(opts) if err != nil { return dryrun.Result{}, err } @@ -209,71 +228,19 @@ func (s Service) DryRunDeleteFileSystem(ctx context.Context, commandPath string, Status: "passed", Message: fmt.Sprintf("would remove %s after Drive9 accepts deletion if it exists", credentialPaths.Credentials), }) - checks = append(checks, endpointDryRunCheck(endpoint, endpointErr)) - body, bodyErr := deprovisionRequest(opts.Profile) - if bodyErr != nil { - return dryrun.Result{}, bodyErr - } + checks = append(checks, endpointDryRunCheck(endpoint, nil)) return dryrun.New( commandPath, "delete_file_system", dryrun.RequestSummary{ Method: http.MethodDelete, Path: "/v1/admin/tenants/" + fileSystemID, - Body: redactedDeprovisionRequest(body), - Description: "normal execution uses TiDB Cloud credentials and removes matching local credentials only after Drive9 accepts deletion", + Description: "normal execution authenticates with TiDB Cloud API-key headers and removes matching local credentials only after the backend accepts deletion", }, checks..., ), nil } -func (s Service) createRequestAndEndpoint(opts CreateFileSystemOptions, requireEndpoint bool) (apifs.ProvisionRequest, endpoints.Endpoint, error) { - request, endpoint, endpointErr, err := s.createDryRunInputs(opts) - if err != nil { - return apifs.ProvisionRequest{}, endpoints.Endpoint{}, err - } - if endpointErr != nil && requireEndpoint { - return apifs.ProvisionRequest{}, endpoints.Endpoint{}, endpointErr - } - return request, endpoint, nil -} - -func (s Service) createDryRunInputs(opts CreateFileSystemOptions) (apifs.ProvisionRequest, endpoints.Endpoint, error, error) { - creds, err := auth.ValidateProfile(opts.Profile) - if err != nil { - return apifs.ProvisionRequest{}, endpoints.Endpoint{}, nil, err - } - endpoint, endpointErr := s.resolveFS(opts.Profile) - request := apifs.ProvisionRequest{ - PublicKey: creds.PublicKey, - PrivateKey: creds.PrivateKey, - } - return request, endpoint, endpointErr, nil -} - -func (s Service) deleteInputsAndEndpoint(opts DeleteFileSystemOptions, requireEndpoint bool) (string, endpoints.Endpoint, error) { - fileSystemID, endpoint, endpointErr, err := s.deleteDryRunInputs(opts) - if err != nil { - return "", endpoints.Endpoint{}, err - } - if endpointErr != nil && requireEndpoint { - return "", endpoints.Endpoint{}, endpointErr - } - return fileSystemID, endpoint, nil -} - -func (s Service) deleteDryRunInputs(opts DeleteFileSystemOptions) (string, endpoints.Endpoint, error, error) { - if err := validateProfile(opts.Profile); err != nil { - return "", endpoints.Endpoint{}, nil, err - } - fileSystemID, err := fscred.ValidateFileSystemID(opts.FileSystemID) - if err != nil { - return "", endpoints.Endpoint{}, nil, err - } - endpoint, endpointErr := s.resolveFS(opts.Profile) - return fileSystemID, endpoint, endpointErr, nil -} - func (s Service) resolveFS(profile *config.Profile) (endpoints.Endpoint, error) { provider := profile.FSCloudProvider regionCode := profile.FSRegionCode @@ -312,55 +279,6 @@ func (s Service) resolver() endpoints.Resolver { return s.Resolver } -func deprovisionRequest(profile *config.Profile) (apifs.DeprovisionRequest, error) { - creds, err := auth.ValidateProfile(profile) - if err != nil { - return apifs.DeprovisionRequest{}, err - } - return apifs.DeprovisionRequest{ - PublicKey: creds.PublicKey, - PrivateKey: creds.PrivateKey, - }, nil -} - -type redactedProvisionBody struct { - PublicKey string `json:"public_key,omitempty"` - PrivateKey string `json:"private_key,omitempty"` -} - -type redactedDeprovisionBody struct { - PublicKey string `json:"public_key,omitempty"` - PrivateKey string `json:"private_key,omitempty"` -} - -func redactedProvisionRequest(request apifs.ProvisionRequest) redactedProvisionBody { - return redactedProvisionBody{ - PublicKey: redactedConfiguredValue(request.PublicKey), - PrivateKey: redactedSecretValue(request.PrivateKey), - } -} - -func redactedDeprovisionRequest(request apifs.DeprovisionRequest) redactedDeprovisionBody { - return redactedDeprovisionBody{ - PublicKey: redactedConfiguredValue(request.PublicKey), - PrivateKey: redactedSecretValue(request.PrivateKey), - } -} - -func redactedConfiguredValue(value string) string { - if strings.TrimSpace(value) == "" { - return "" - } - return "[configured]" -} - -func redactedSecretValue(value string) string { - if strings.TrimSpace(value) == "" { - return "" - } - return "[redacted]" -} - func (s Service) homeDir() (string, error) { if s.HomeDir != "" { return s.HomeDir, nil @@ -425,6 +343,8 @@ func profileName(profile *config.Profile) string { func (r FileSystemResult) Human() string { lines := []string{ "File system ID: " + r.FileSystemID, + "Display name: " + r.DisplayName, + "Labels: " + humanLabels(r.Labels), "Status: " + r.Status, } if r.RegionCode != "" { @@ -442,9 +362,9 @@ func (r FileSystemResult) Human() string { func (r ListFileSystemsResult) Human() string { var out strings.Builder writer := tabwriter.NewWriter(&out, 0, 0, 2, ' ', 0) - _, _ = fmt.Fprintln(writer, "FILE_SYSTEM_ID\tREGION\tSTATUS\tKIND\tLOCAL_TOKEN") + _, _ = fmt.Fprintln(writer, "FILE_SYSTEM_ID\tDISPLAY_NAME\tREGION\tSTATUS\tKIND\tLOCAL_TOKEN") for _, fileSystem := range r.FileSystems { - _, _ = fmt.Fprintf(writer, "%s\t%s\t%s\t%s\t%t\n", fileSystem.FileSystemID, fileSystem.RegionCode, fileSystem.Status, fileSystem.Kind, fileSystem.HasLocalToken) + _, _ = fmt.Fprintf(writer, "%s\t%s\t%s\t%s\t%s\t%t\n", fileSystem.FileSystemID, fileSystem.DisplayName, fileSystem.RegionCode, fileSystem.Status, fileSystem.Kind, fileSystem.HasLocalToken) } _ = writer.Flush() return strings.TrimRight(out.String(), "\n") @@ -453,6 +373,8 @@ func (r ListFileSystemsResult) Human() string { func (r DescribeFileSystemResult) Human() string { lines := []string{ "File system ID: " + r.FileSystemID, + "Display name: " + r.DisplayName, + "Labels: " + humanLabels(r.Labels), "Region: " + r.RegionCode, "Status: " + r.Status, "Kind: " + r.Kind, @@ -464,6 +386,22 @@ func (r DescribeFileSystemResult) Human() string { return strings.Join(lines, "\n") } +func humanLabels(labels map[string]string) string { + if len(labels) == 0 { + return "none" + } + keys := make([]string, 0, len(labels)) + for key := range labels { + keys = append(keys, key) + } + sort.Strings(keys) + values := make([]string, 0, len(keys)) + for _, key := range keys { + values = append(values, key+"="+labels[key]) + } + return strings.Join(values, ", ") +} + func (r DeleteResult) Human() string { lines := []string{ "File system ID: " + r.FileSystemID, diff --git a/internal/fs/drive9_companion.go b/internal/fs/drive9_companion.go index 83c18f0..900f0fa 100644 --- a/internal/fs/drive9_companion.go +++ b/internal/fs/drive9_companion.go @@ -28,39 +28,6 @@ const ( defaultFSReadyWaitPollInterval = 5 * time.Second ) -type drive9CreateOutput struct { - Context string `json:"context"` - TenantID string `json:"tenant_id"` - APIKey string `json:"api_key"` - Status string `json:"status"` - Server string `json:"server"` - RegionCode string `json:"region_code,omitempty"` - Mode string `json:"mode,omitempty"` - CloudProvider string `json:"cloud_provider,omitempty"` - Region string `json:"region,omitempty"` - Config string `json:"config"` -} - -type drive9DeleteOutput struct { - TenantID string `json:"tenant_id,omitempty"` - Status string `json:"status"` - Server string `json:"server,omitempty"` -} - -type drive9AdminTenant struct { - TenantID string `json:"tenant_id"` - Status string `json:"status"` - Kind string `json:"kind"` - Quota any `json:"quota,omitempty"` -} - -type drive9AdminTenantListOutput struct { - Tenants []drive9AdminTenant `json:"tenants"` - Page int `json:"page"` - PageSize int `json:"page_size"` - NextPage int `json:"next_page,omitempty"` -} - type drive9StatMetadata struct { Path string `json:"path,omitempty"` Size int64 `json:"size"` @@ -165,238 +132,6 @@ func sleepDrive9Retry(ctx context.Context, attempt int) error { } } -func (s Service) drive9CreateFileSystem(ctx context.Context, opts CreateFileSystemOptions) (FileSystemResult, error) { - _, _, err := s.createRequestAndEndpoint(opts, false) - if err != nil { - return FileSystemResult{}, err - } - homeDir, err := s.homeDir() - if err != nil { - return FileSystemResult{}, err - } - if err := fscred.MigrateNameRegistry(homeDir, opts.Profile); err != nil { - return FileSystemResult{}, err - } - if err := fscred.PrepareCredentialStore(homeDir, opts.Profile.Name); err != nil { - return FileSystemResult{}, err - } - createHome, err := os.MkdirTemp("", "ti-fs-create-*") - if err != nil { - return FileSystemResult{}, apperr.Wrap("fs.companion_home", "runtime", 1, "prepare temporary ti fs create state", err) - } - defer os.RemoveAll(createHome) - runner := s.drive9Runner() - runner.HomeDir = createHome - args := []string{"create", "--json", "--region-code", opts.Profile.PlacementRegionCode} - result, err := runner.Run(ctx, fswrap.RunOptions{ - Profile: opts.Profile, - ResourceName: "_create", - Args: args, - CaptureStdout: true, - IncludeTIKeys: true, - IncludeFSAPIKey: false, - }) - if err != nil { - return FileSystemResult{}, err - } - var out drive9CreateOutput - if err := json.Unmarshal(result.Stdout, &out); err != nil { - return FileSystemResult{}, apperr.Wrap("fs.companion_decode", "runtime", 1, "decode ti fs provision response", err) - } - status := strings.TrimSpace(out.Status) - if status == "" { - status = "provisioning" - } - fileSystemID, err := fscred.ValidateFileSystemID(out.TenantID) - if err != nil { - return FileSystemResult{}, apperr.Wrap("fs.companion_decode", "runtime", 1, "ti fs create response did not include a valid tenant_id", err) - } - if strings.TrimSpace(out.APIKey) == "" { - return FileSystemResult{}, apperr.New("fs.companion_decode", "runtime", 1, "ti fs create response did not include api_key") - } - regionCode := opts.Profile.PlacementRegionCode - if regionCode == "" { - regionCode = out.RegionCode - } - fileSystem := FileSystemResult{ - FileSystemID: fileSystemID, - RegionCode: regionCode, - FSToken: out.APIKey, - Status: status, - CredentialsStored: false, - } - if _, storeErr := fscred.StoreCredential(homeDir, opts.Profile, fileSystemID, regionCode, out.APIKey, false); storeErr != nil { - if s.Stderr != nil { - _, _ = fmt.Fprintf(s.Stderr, "ti [WARNING]: file system %s was created, but its one-time token could not be stored locally: %s\n", fileSystemID, apperr.MessageFor(storeErr)) - } - return fileSystem, nil - } - fileSystem.CredentialsStored = true - if opts.WaitUntilReady { - if err := s.waitUntilFileSystemReady(ctx, homeDir, opts.Profile, fileSystemID); err != nil { - return FileSystemResult{}, err - } - fileSystem.Status = "ready" - } - return fileSystem, nil -} - -func (s Service) drive9DeleteFileSystem(ctx context.Context, opts DeleteFileSystemOptions) (DeleteResult, error) { - fileSystemID, _, err := s.deleteInputsAndEndpoint(opts, false) - if err != nil { - return DeleteResult{}, err - } - homeDir, err := s.homeDir() - if err != nil { - return DeleteResult{}, err - } - if err := fscred.MigrateNameRegistry(homeDir, opts.Profile); err != nil { - return DeleteResult{}, err - } - args := []string{"admin", "tenant", "delete", "--json", "--region-code", opts.Profile.PlacementRegionCode, "--tenant-id", fileSystemID} - result, err := s.drive9Runner().Run(ctx, fswrap.RunOptions{ - Profile: opts.Profile, - ResourceName: "_control-plane", - Args: args, - CaptureStdout: true, - IncludeTIKeys: true, - IncludeFSAPIKey: false, - }) - if err != nil { - if isDrive9NotFound(err) { - return DeleteResult{}, remoteFileSystemNotFound(fileSystemID, err) - } - return DeleteResult{}, err - } - var out drive9DeleteOutput - if err := json.Unmarshal(result.Stdout, &out); err != nil { - return DeleteResult{}, apperr.Wrap("fs.companion_decode", "runtime", 1, "decode ti fs deletion response", err) - } - if out.TenantID != "" && out.TenantID != fileSystemID { - return DeleteResult{}, apperr.New("fs.companion_decode", "runtime", 1, fmt.Sprintf("ti fs deletion response identified file system %q instead of %q", out.TenantID, fileSystemID)) - } - status := strings.TrimSpace(out.Status) - if status == "" { - status = "deleting" - } - credentialsRemoved, err := fscred.DeleteCredential(homeDir, opts.Profile.Name, fileSystemID) - if err != nil { - return DeleteResult{}, err - } - return DeleteResult{ - FileSystemID: fileSystemID, - Status: status, - CredentialsRemoved: credentialsRemoved, - RemoteDeletionState: status, - }, nil -} - -func (s Service) drive9ListFileSystems(ctx context.Context, profile *config.Profile) (ListFileSystemsResult, error) { - if err := validateProfile(profile); err != nil { - return ListFileSystemsResult{}, err - } - homeDir, err := s.homeDir() - if err != nil { - return ListFileSystemsResult{}, err - } - if err := fscred.MigrateNameRegistry(homeDir, profile); err != nil { - return ListFileSystemsResult{}, err - } - credentials, err := fscred.ListCredentials(homeDir, profile.Name) - if err != nil { - return ListFileSystemsResult{}, err - } - hasToken := make(map[string]bool, len(credentials)) - for _, credential := range credentials { - hasToken[credential.FileSystemID] = credential.HasLocalToken - } - const pageSize = 100 - page := 1 - seenPages := map[int]bool{} - seenIDs := map[string]bool{} - fileSystems := make([]FileSystemSummary, 0) - for { - if page <= 0 || seenPages[page] { - return ListFileSystemsResult{}, apperr.New("fs.companion_decode", "runtime", 1, "ti fs inventory returned a repeated or invalid page") - } - seenPages[page] = true - args := []string{"admin", "tenant", "list", "--json", "--region-code", profile.PlacementRegionCode, "--page-size", strconv.Itoa(pageSize), "--page", strconv.Itoa(page)} - result, err := s.drive9Runner().Run(ctx, fswrap.RunOptions{Profile: profile, ResourceName: "_control-plane", Args: args, CaptureStdout: true, IncludeTIKeys: true}) - if err != nil { - return ListFileSystemsResult{}, err - } - var out drive9AdminTenantListOutput - if err := json.Unmarshal(result.Stdout, &out); err != nil { - return ListFileSystemsResult{}, apperr.Wrap("fs.companion_decode", "runtime", 1, "decode ti fs inventory response", err) - } - if out.Page != page { - return ListFileSystemsResult{}, apperr.New("fs.companion_decode", "runtime", 1, fmt.Sprintf("ti fs inventory returned page %d while page %d was requested", out.Page, page)) - } - for _, tenant := range out.Tenants { - id, err := fscred.ValidateFileSystemID(tenant.TenantID) - if err != nil { - return ListFileSystemsResult{}, apperr.Wrap("fs.companion_decode", "runtime", 1, "ti fs inventory included an invalid tenant_id", err) - } - if seenIDs[id] { - return ListFileSystemsResult{}, apperr.New("fs.companion_decode", "runtime", 1, fmt.Sprintf("ti fs inventory returned duplicate file system ID %q", id)) - } - seenIDs[id] = true - fileSystems = append(fileSystems, FileSystemSummary{FileSystemID: id, RegionCode: profile.PlacementRegionCode, Status: tenant.Status, Kind: tenant.Kind, Quota: tenant.Quota, HasLocalToken: hasToken[id]}) - } - if out.NextPage == 0 { - break - } - if out.NextPage <= page { - return ListFileSystemsResult{}, apperr.New("fs.companion_decode", "runtime", 1, "ti fs inventory returned a repeated or regressing next_page") - } - page = out.NextPage - } - sort.Slice(fileSystems, func(i, j int) bool { return fileSystems[i].FileSystemID < fileSystems[j].FileSystemID }) - return ListFileSystemsResult{RegionCode: profile.PlacementRegionCode, FileSystems: fileSystems}, nil -} - -func (s Service) drive9DescribeFileSystem(ctx context.Context, profile *config.Profile, fileSystemID string) (DescribeFileSystemResult, error) { - if err := validateProfile(profile); err != nil { - return DescribeFileSystemResult{}, err - } - id, err := fscred.ValidateFileSystemID(fileSystemID) - if err != nil { - return DescribeFileSystemResult{}, err - } - homeDir, err := s.homeDir() - if err != nil { - return DescribeFileSystemResult{}, err - } - if err := fscred.MigrateNameRegistry(homeDir, profile); err != nil { - return DescribeFileSystemResult{}, err - } - result, err := s.drive9Runner().Run(ctx, fswrap.RunOptions{ - Profile: profile, ResourceName: "_control-plane", - Args: []string{"admin", "tenant", "get", "--json", "--region-code", profile.PlacementRegionCode, "--tenant-id", id}, - CaptureStdout: true, IncludeTIKeys: true, - }) - if err != nil { - if isDrive9NotFound(err) { - return DescribeFileSystemResult{}, remoteFileSystemNotFound(id, err) - } - return DescribeFileSystemResult{}, err - } - var tenant drive9AdminTenant - if err := json.Unmarshal(result.Stdout, &tenant); err != nil { - return DescribeFileSystemResult{}, apperr.Wrap("fs.companion_decode", "runtime", 1, "decode ti fs describe response", err) - } - if tenant.TenantID != id { - return DescribeFileSystemResult{}, apperr.New("fs.companion_decode", "runtime", 1, fmt.Sprintf("ti fs describe response identified file system %q instead of %q", tenant.TenantID, id)) - } - _, credentialErr := fscred.GetCredential(homeDir, profile.Name, id) - if credentialErr != nil && apperr.CodeFor(credentialErr) != "fs.credential_not_found" { - return DescribeFileSystemResult{}, credentialErr - } - return DescribeFileSystemResult{FileSystemSummary: FileSystemSummary{ - FileSystemID: id, RegionCode: profile.PlacementRegionCode, Status: tenant.Status, Kind: tenant.Kind, Quota: tenant.Quota, HasLocalToken: credentialErr == nil, - }}, nil -} - func (s Service) importFileSystemToken(ctx context.Context, opts ImportFileSystemTokenOptions, persist bool) (ImportFileSystemTokenResult, error) { if opts.Profile == nil { return ImportFileSystemTokenResult{}, apperr.New("fs.missing_profile", "config", 2, "active profile is required") diff --git a/internal/fs/drive9_companion_test.go b/internal/fs/drive9_companion_test.go index 8533e50..2bc1b86 100644 --- a/internal/fs/drive9_companion_test.go +++ b/internal/fs/drive9_companion_test.go @@ -28,268 +28,6 @@ type fakeDrive9Call struct { Env map[string]string `json:"env"` } -func TestDrive9CreateFileSystemStoresRegistryCredentialsAndUsesCanonicalRegion(t *testing.T) { - home := t.TempDir() - companion, recordPath := buildFakeDrive9(t) - t.Setenv("TI_FAKE_DRIVE9_RECORD", recordPath) - t.Setenv("DRIVE9_API_KEY", "ambient-drive9-key") - profile := testProfile() - - result, err := testCompanionService(home, companion).CreateFileSystem(context.Background(), CreateFileSystemOptions{ - Profile: profile, - }) - if err != nil { - t.Fatalf("CreateFileSystem failed: %v", err) - } - if result.FileSystemID != "tenant-1" || result.RegionCode != "aws-us-east-1" || result.FSToken != "fs-secret" || !result.CredentialsStored { - t.Fatalf("unexpected result: %#v", result) - } - - configDoc, err := store.ReadConfig(home) - if err != nil { - t.Fatalf("ReadConfig failed: %v", err) - } - if got := configDoc["stage"]; got.FSResourceName != "" || got.FSTenantID != "" || got.FSRegionCode != "" { - t.Fatalf("unexpected fs config: %#v", got) - } - credentialsDoc, err := store.ReadCredentials(home) - if err != nil { - t.Fatalf("ReadCredentials failed: %v", err) - } - if got := credentialsDoc["stage"]; got.FSAPIKey != "" { - t.Fatalf("fs api key must not be stored flat under profile: %#v", got) - } - resource, err := fscred.GetCredential(home, "stage", "tenant-1") - if err != nil { - t.Fatalf("Get ID-keyed credential failed: %v", err) - } - if resource.FileSystemID != "tenant-1" || resource.RegionCode != "aws-us-east-1" || resource.APIKey != "fs-secret" { - t.Fatalf("unexpected ID-keyed credential: %#v", resource) - } - - createCall := requireFakeDrive9Call(t, recordPath, "create") - wantArgs := []string{"create", "--json", "--region-code", "aws-us-east-1"} - if fmt.Sprint(createCall.Args) != fmt.Sprint(wantArgs) { - t.Fatalf("create args = %#v, want %#v", createCall.Args, wantArgs) - } - if createCall.Env["DRIVE9_REGION_CODE"] != "aws-us-east-1" || createCall.Env["DRIVE9_SERVER"] != "https://fs.test" { - t.Fatalf("unexpected region/server env: %#v", createCall.Env) - } - if createCall.Env["DRIVE9_PUBLIC_KEY"] != "public" || createCall.Env["DRIVE9_PRIVATE_KEY"] != "private" { - t.Fatalf("missing TiDB Cloud keys in create env: %#v", createCall.Env) - } - if _, ok := createCall.Env["DRIVE9_API_KEY"]; ok { - t.Fatalf("create should not pass an fs api key, env=%#v", createCall.Env) - } - createHome := createCall.Env["HOME"] - if createHome == "" || strings.HasPrefix(createHome, filepath.Join(home, ".ti")) { - t.Fatalf("create HOME = %q, want isolated temporary state", createHome) - } - if _, err := os.Stat(createHome); !os.IsNotExist(err) { - t.Fatalf("temporary create HOME was not removed: %q, err=%v", createHome, err) - } -} - -func TestDrive9CreateFileSystemWaitsUntilReady(t *testing.T) { - home := t.TempDir() - companion, recordPath := buildFakeDrive9(t) - t.Setenv("TI_FAKE_DRIVE9_RECORD", recordPath) - t.Setenv("TI_FAKE_DRIVE9_STAT_FAILURE_SEQUENCE", filepath.Join(t.TempDir(), "stat-attempted")) - - service := testCompanionService(home, companion) - service.FSReadyWaitTimeout = time.Second - service.FSReadyWaitPollInterval = time.Millisecond - result, err := service.CreateFileSystem(context.Background(), CreateFileSystemOptions{ - Profile: testProfile(), - WaitUntilReady: true, - }) - if err != nil { - t.Fatalf("CreateFileSystem failed: %v", err) - } - if result.Status != "ready" || !result.CredentialsStored { - t.Fatalf("unexpected result: %#v", result) - } - statCalls := 0 - for _, call := range readFakeDrive9Calls(t, recordPath) { - if len(call.Args) >= 2 && call.Args[0] == "fs" && call.Args[1] == "stat" { - statCalls++ - if call.Env["DRIVE9_API_KEY"] != "fs-secret" { - t.Fatalf("readiness stat used wrong credentials: %#v", call.Env) - } - } - } - if statCalls != 2 { - t.Fatalf("readiness stat calls = %d, want 2", statCalls) - } -} - -func TestDrive9CreateFileSystemReadyTimeoutPreservesCredentials(t *testing.T) { - home := t.TempDir() - companion, _ := buildFakeDrive9(t) - t.Setenv("TI_FAKE_DRIVE9_STAT_ALWAYS_FAIL", "1") - - service := testCompanionService(home, companion) - service.FSReadyWaitTimeout = 10 * time.Millisecond - service.FSReadyWaitPollInterval = time.Millisecond - _, err := service.CreateFileSystem(context.Background(), CreateFileSystemOptions{ - Profile: testProfile(), - WaitUntilReady: true, - }) - if apperr.CodeFor(err) != "fs.ready_wait_timeout" { - t.Fatalf("unexpected error: %v", err) - } - resource, getErr := fscred.GetCredential(home, "stage", "tenant-1") - if getErr != nil || resource.APIKey != "fs-secret" { - t.Fatalf("readiness timeout removed stored credentials: resource=%#v err=%v", resource, getErr) - } -} - -func TestDrive9CreateFileSystemFromEnvironmentProfileStoresDefaultProfile(t *testing.T) { - home := t.TempDir() - companion, recordPath := buildFakeDrive9(t) - t.Setenv("TI_FAKE_DRIVE9_RECORD", recordPath) - profile := &config.Profile{ - Name: config.DefaultProfile, - Source: "env", - PlacementRegionCode: "aws-us-east-1", - CloudProvider: "aws", - RegionCode: "us-east-1", - TiDBCloudPublicKey: "env-public", - TiDBCloudPrivateKey: "env-private", - } - - if _, err := testCompanionService(home, companion).CreateFileSystem(context.Background(), CreateFileSystemOptions{ - Profile: profile, - }); err != nil { - t.Fatalf("CreateFileSystem failed: %v", err) - } - - configDoc, err := store.ReadConfig(home) - if err != nil { - t.Fatalf("ReadConfig failed: %v", err) - } - if got := configDoc[config.DefaultProfile]; got.FSResourceName != "" || got.FSTenantID != "" { - t.Fatalf("expected fs config under default profile, got %#v", got) - } - if _, ok := configDoc["env"]; ok { - t.Fatalf("did not expect generated [env] config section: %#v", configDoc["env"]) - } - credentialsDoc, err := store.ReadCredentials(home) - if err != nil { - t.Fatalf("ReadCredentials failed: %v", err) - } - if got := credentialsDoc[config.DefaultProfile]; got.FSAPIKey != "" { - t.Fatalf("did not expect flat fs api key under default profile, got %#v", got) - } - if _, ok := credentialsDoc["env"]; ok { - t.Fatalf("did not expect generated [env] credentials section: %#v", credentialsDoc["env"]) - } -} - -func TestDrive9CreateAlwaysInvokesRemoteAndStoresByReturnedID(t *testing.T) { - home := t.TempDir() - companion, recordPath := buildFakeDrive9(t) - t.Setenv("TI_FAKE_DRIVE9_RECORD", recordPath) - profile := testProfile() - service := testCompanionService(home, companion) - if _, err := service.CreateFileSystem(context.Background(), CreateFileSystemOptions{Profile: profile}); err != nil { - t.Fatalf("first create: %v", err) - } - if _, err := service.CreateFileSystem(context.Background(), CreateFileSystemOptions{Profile: profile}); err != nil { - t.Fatalf("second create: %v", err) - } - repeated, err := service.CreateFileSystem(context.Background(), CreateFileSystemOptions{Profile: profile}) - if err != nil { - t.Fatalf("repeat create scratch: %v", err) - } - if repeated.Status != "active" || repeated.FSToken != "fs-secret" || !repeated.CredentialsStored { - t.Fatalf("unexpected repeated create result: %#v", repeated) - } - credentials, err := fscred.ListCredentials(home, profile.Name) - if err != nil || len(credentials) != 1 { - t.Fatalf("credentials=%#v err=%v", credentials, err) - } - calls := readFakeDrive9Calls(t, recordPath) - homes := map[string]bool{} - for _, call := range calls { - if hasArgPrefix(call.Args, []string{"create"}) { - homes[call.Env["HOME"]] = true - if strings.HasPrefix(call.Env["HOME"], filepath.Join(home, ".ti")) { - t.Fatalf("create used persistent companion HOME: %q", call.Env["HOME"]) - } - if _, err := os.Stat(call.Env["HOME"]); !os.IsNotExist(err) { - t.Fatalf("temporary create HOME was not removed: %q, err=%v", call.Env["HOME"], err) - } - } - } - if len(homes) != 3 { - t.Fatalf("expected an isolated companion home for each create, got %#v", homes) - } - createCalls := 0 - for _, call := range calls { - if hasArgPrefix(call.Args, []string{"create"}) { - createCalls++ - } - } - if createCalls != 3 { - t.Fatalf("create invoked Drive9 %d times, want 3 total calls", createCalls) - } -} - -func TestDrive9DeleteFileSystemDeletesOnlySelectedRegistryResource(t *testing.T) { - home := t.TempDir() - companion, recordPath := buildFakeDrive9(t) - t.Setenv("TI_FAKE_DRIVE9_RECORD", recordPath) - profile := dataProfile() - if err := store.WriteProfile(home, profile.Name, store.ConfigProfile{RegionCode: profile.PlacementRegionCode}, store.CredentialsProfile{TiDBCloudPublicKey: profile.TiDBCloudPublicKey, TiDBCloudPrivateKey: profile.TiDBCloudPrivateKey}); err != nil { - t.Fatal(err) - } - if _, err := fscred.StoreCredential(home, profile, "tenant-1", "aws-us-east-1", "fs-secret", false); err != nil { - t.Fatal(err) - } - if _, err := fscred.StoreCredential(home, profile, "tenant-2", "aws-us-east-1", "fs-secret-2", false); err != nil { - t.Fatal(err) - } - result, err := testCompanionService(home, companion).DeleteFileSystem(context.Background(), DeleteFileSystemOptions{ - Profile: profile, - FileSystemID: "tenant-1", - }) - if err != nil { - t.Fatalf("DeleteFileSystem failed: %v", err) - } - if !result.CredentialsRemoved || result.Status != "deleting" || result.RemoteDeletionState != "deleting" { - t.Fatalf("unexpected delete result: %#v", result) - } - deleteCall := requireFakeDrive9Call(t, recordPath, "admin", "tenant", "delete") - if fmt.Sprint(deleteCall.Args) != fmt.Sprint([]string{"admin", "tenant", "delete", "--json", "--region-code", "aws-us-east-1", "--tenant-id", "tenant-1"}) { - t.Fatalf("delete args = %#v", deleteCall.Args) - } - if deleteCall.Env["DRIVE9_API_KEY"] != "" || deleteCall.Env["DRIVE9_PUBLIC_KEY"] != "public" || deleteCall.Env["DRIVE9_PRIVATE_KEY"] != "private" { - t.Fatalf("missing delete env: %#v", deleteCall.Env) - } - - configDoc, err := store.ReadConfig(home) - if err != nil { - t.Fatalf("ReadConfig failed: %v", err) - } - if got := configDoc["stage"]; got.FSResourceName != "" || got.FSTenantID != "" || got.FSRegionCode != "" { - t.Fatalf("unexpected config after delete: %#v", got) - } - credentialsDoc, err := store.ReadCredentials(home) - if err != nil { - t.Fatalf("ReadCredentials failed: %v", err) - } - if got := credentialsDoc["stage"]; got.FSAPIKey != "" || got.TiDBCloudPublicKey != "public" { - t.Fatalf("unexpected credentials after delete: %#v", got) - } - if _, err := fscred.GetCredential(home, "stage", "tenant-1"); apperr.CodeFor(err) != "fs.credential_not_found" { - t.Fatalf("deleted resource still exists: %v", err) - } - if resource, err := fscred.GetCredential(home, "stage", "tenant-2"); err != nil || resource.APIKey != "fs-secret-2" { - t.Fatalf("unrelated resource was changed: resource=%#v err=%v", resource, err) - } -} - func TestDrive9CheckFileSystemUsesSelectedResource(t *testing.T) { companion, recordPath := buildFakeDrive9(t) t.Setenv("TI_FAKE_DRIVE9_RECORD", recordPath) @@ -321,255 +59,6 @@ func TestDrive9CheckFileSystemUsesSelectedResource(t *testing.T) { } } -func TestDrive9RemoteInventoryAndDescribeJoinLocalToken(t *testing.T) { - home := t.TempDir() - companion, recordPath := buildFakeDrive9(t) - t.Setenv("TI_FAKE_DRIVE9_RECORD", recordPath) - t.Setenv("TI_FAKE_DRIVE9_LIST_MODE", "mixed-local-token") - profile := testProfile() - if _, err := fscred.StoreCredential(home, profile, "tenant-1", "aws-us-east-1", fsTestToken(t, "tenant-1"), false); err != nil { - t.Fatal(err) - } - service := testCompanionService(home, companion) - list, err := service.ListFileSystems(context.Background(), profile) - if err != nil { - t.Fatal(err) - } - if len(list.FileSystems) != 2 || list.FileSystems[0].FileSystemID != "tenant-1" || !list.FileSystems[0].HasLocalToken { - t.Fatalf("list = %#v", list) - } - if list.FileSystems[1].FileSystemID != "tenant-2" || list.FileSystems[1].HasLocalToken { - t.Fatalf("remote resource without a local token was not preserved: %#v", list) - } - described, err := service.DescribeFileSystem(context.Background(), profile, "tenant-1") - if err != nil { - t.Fatal(err) - } - if described.FileSystemID != "tenant-1" || described.Status != "active" || !described.HasLocalToken { - t.Fatalf("described = %#v", described) - } - listCall := requireFakeDrive9Call(t, recordPath, "admin", "tenant", "list") - if listCall.Env["DRIVE9_API_KEY"] != "" || listCall.Env["DRIVE9_PUBLIC_KEY"] != "public" { - t.Fatalf("inventory used wrong credentials: %#v", listCall.Env) - } -} - -func TestDrive9RemoteInventoryCommandsRejectMissingTiDBCloudCredentials(t *testing.T) { - companion, recordPath := buildFakeDrive9(t) - t.Setenv("TI_FAKE_DRIVE9_RECORD", recordPath) - profile := testProfile() - profile.TiDBCloudPublicKey = "" - profile.TiDBCloudPrivateKey = "" - service := testCompanionService(t.TempDir(), companion) - - tests := []struct { - name string - run func() error - }{ - {name: "list", run: func() error { - _, err := service.ListFileSystems(context.Background(), profile) - return err - }}, - {name: "describe", run: func() error { - _, err := service.DescribeFileSystem(context.Background(), profile, "tenant-1") - return err - }}, - {name: "delete", run: func() error { - _, err := service.DeleteFileSystem(context.Background(), DeleteFileSystemOptions{Profile: profile, FileSystemID: "tenant-1"}) - return err - }}, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - if err := tc.run(); apperr.CodeFor(err) != "auth.missing_credentials" { - t.Fatalf("error = %v, want auth.missing_credentials", err) - } - }) - } - if _, err := os.Stat(recordPath); !os.IsNotExist(err) { - t.Fatalf("missing credentials invoked Drive9: %v", err) - } -} - -func TestDrive9DescribeMigratesLegacyCredentialBeforeJoiningLocalToken(t *testing.T) { - home := t.TempDir() - companion, _ := buildFakeDrive9(t) - profile := testProfile() - if err := fscred.Store(home, profile, "workspace", "tenant-1", "aws", "aws-us-east-1", fsTestToken(t, "tenant-1")); err != nil { - t.Fatal(err) - } - - described, err := testCompanionService(home, companion).DescribeFileSystem(context.Background(), profile, "tenant-1") - if err != nil { - t.Fatal(err) - } - if !described.HasLocalToken { - t.Fatalf("describe did not join the migrated legacy credential: %#v", described) - } - if _, err := fscred.GetCredential(home, profile.Name, "tenant-1"); err != nil { - t.Fatalf("describe did not migrate the legacy credential: %v", err) - } -} - -func TestDrive9DeleteMigratesLegacyCredentialAndDoesNotRestoreIt(t *testing.T) { - home := t.TempDir() - companion, _ := buildFakeDrive9(t) - profile := testProfile() - if err := fscred.Store(home, profile, "workspace", "tenant-1", "aws", "aws-us-east-1", fsTestToken(t, "tenant-1")); err != nil { - t.Fatal(err) - } - - result, err := testCompanionService(home, companion).DeleteFileSystem(context.Background(), DeleteFileSystemOptions{Profile: profile, FileSystemID: "tenant-1"}) - if err != nil { - t.Fatal(err) - } - if !result.CredentialsRemoved { - t.Fatalf("delete did not remove the migrated credential: %#v", result) - } - if err := fscred.MigrateNameRegistry(home, profile); err != nil { - t.Fatal(err) - } - if _, err := fscred.GetCredential(home, profile.Name, "tenant-1"); apperr.CodeFor(err) != "fs.credential_not_found" { - t.Fatalf("legacy rollback source restored the deleted credential: %v", err) - } - if _, err := fscred.Get(home, profile.Name, "workspace"); err != nil { - t.Fatalf("delete removed the legacy rollback source: %v", err) - } -} - -func TestDrive9RemoteInventoryPaginationSortingAndEmptyResults(t *testing.T) { - home := t.TempDir() - companion, recordPath := buildFakeDrive9(t) - t.Setenv("TI_FAKE_DRIVE9_RECORD", recordPath) - t.Setenv("TI_FAKE_DRIVE9_LIST_MODE", "paginate") - profile := testProfile() - service := testCompanionService(home, companion) - - result, err := service.ListFileSystems(context.Background(), profile) - if err != nil { - t.Fatal(err) - } - if len(result.FileSystems) != 2 || result.FileSystems[0].FileSystemID != "tenant-1" || result.FileSystems[1].FileSystemID != "tenant-2" { - t.Fatalf("paginated inventory was not sorted: %#v", result.FileSystems) - } - listCalls := 0 - for _, call := range readFakeDrive9Calls(t, recordPath) { - if hasArgPrefix(call.Args, []string{"admin", "tenant", "list"}) { - listCalls++ - if !containsArg(call.Args, "--page-size") || call.Env["DRIVE9_REGION_CODE"] != "aws-us-east-1" { - t.Fatalf("inventory call did not preserve pagination or region routing: %#v", call) - } - } - } - if listCalls != 2 { - t.Fatalf("inventory list calls = %d, want 2", listCalls) - } - - t.Setenv("TI_FAKE_DRIVE9_LIST_MODE", "empty") - empty, err := service.ListFileSystems(context.Background(), profile) - if err != nil { - t.Fatal(err) - } - if empty.FileSystems == nil || len(empty.FileSystems) != 0 { - t.Fatalf("empty inventory = %#v, want an empty JSON array", empty.FileSystems) - } -} - -func TestDrive9RemoteInventoryRejectsInvalidResponses(t *testing.T) { - for _, tc := range []struct { - name string - mode string - }{ - {name: "malformed JSON", mode: "malformed"}, - {name: "regressing next page", mode: "regress"}, - {name: "mismatched response page", mode: "page-mismatch"}, - {name: "duplicate file system ID", mode: "duplicate"}, - } { - t.Run(tc.name, func(t *testing.T) { - companion, _ := buildFakeDrive9(t) - t.Setenv("TI_FAKE_DRIVE9_LIST_MODE", tc.mode) - _, err := testCompanionService(t.TempDir(), companion).ListFileSystems(context.Background(), testProfile()) - if apperr.CodeFor(err) != "fs.companion_decode" { - t.Fatalf("inventory error = %v, want fs.companion_decode", err) - } - }) - } -} - -func TestDrive9CreateReturnsOneTimeTokenWhenLocalPersistenceFails(t *testing.T) { - home := t.TempDir() - companion, _ := buildFakeDrive9(t) - paths, err := fscred.CredentialPath(home, "stage", "tenant-1") - if err != nil { - t.Fatal(err) - } - profileCredentialDir := filepath.Dir(filepath.Dir(paths.Credentials)) - t.Setenv("TI_FAKE_DRIVE9_BREAK_CREDENTIAL_ROOT", profileCredentialDir) - var stderr strings.Builder - service := testCompanionService(home, companion) - service.Stderr = &stderr - result, err := service.CreateFileSystem(context.Background(), CreateFileSystemOptions{Profile: testProfile()}) - if err != nil { - t.Fatalf("remote create should remain successful after local persistence failure: %v", err) - } - if result.FileSystemID != "tenant-1" || result.FSToken != "fs-secret" || result.CredentialsStored { - t.Fatalf("create result lost one-time recovery data: %#v", result) - } - if !strings.Contains(stderr.String(), "was created") || strings.Contains(stderr.String(), result.FSToken) { - t.Fatalf("create warning is missing or leaked the token: %q", stderr.String()) - } -} - -func TestDrive9CreatePreflightRejectsUnwritableCredentialStoreBeforeRemoteCall(t *testing.T) { - home := t.TempDir() - companion, recordPath := buildFakeDrive9(t) - t.Setenv("TI_FAKE_DRIVE9_RECORD", recordPath) - credentialRoot := filepath.Join(home, ".ti", "fs_credentials") - if err := os.MkdirAll(filepath.Dir(credentialRoot), 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(credentialRoot, []byte("not a directory"), 0o600); err != nil { - t.Fatal(err) - } - _, err := testCompanionService(home, companion).CreateFileSystem(context.Background(), CreateFileSystemOptions{Profile: testProfile()}) - if err == nil { - t.Fatal("create should reject an unusable local credential store") - } - if _, statErr := os.Stat(recordPath); !os.IsNotExist(statErr) { - t.Fatalf("credential preflight failure invoked Drive9 or created its record: %v", statErr) - } -} - -func TestDrive9DeleteFailurePreservesLocalCredential(t *testing.T) { - home := t.TempDir() - companion, _ := buildFakeDrive9(t) - t.Setenv("TI_FAKE_DRIVE9_DELETE_FAIL", "1") - profile := testProfile() - if _, err := fscred.StoreCredential(home, profile, "tenant-1", "aws-us-east-1", "fs-secret", false); err != nil { - t.Fatal(err) - } - _, err := testCompanionService(home, companion).DeleteFileSystem(context.Background(), DeleteFileSystemOptions{Profile: profile, FileSystemID: "tenant-1"}) - if err == nil { - t.Fatal("delete should return the remote failure") - } - credential, getErr := fscred.GetCredential(home, profile.Name, "tenant-1") - if getErr != nil || credential.APIKey != "fs-secret" { - t.Fatalf("remote delete failure removed local credentials: credential=%#v err=%v", credential, getErr) - } -} - -func TestDrive9DescribeAndDeleteMapRemoteNotFound(t *testing.T) { - companion, _ := buildFakeDrive9(t) - service := testCompanionService(t.TempDir(), companion) - t.Setenv("TI_FAKE_DRIVE9_NOT_FOUND", "1") - if _, err := service.DescribeFileSystem(context.Background(), testProfile(), "tenant-missing"); apperr.CodeFor(err) != "fs.resource_not_found" { - t.Fatalf("describe error = %v, want fs.resource_not_found", err) - } - if _, err := service.DeleteFileSystem(context.Background(), DeleteFileSystemOptions{Profile: testProfile(), FileSystemID: "tenant-missing"}); apperr.CodeFor(err) != "fs.resource_not_found" { - t.Fatalf("delete error = %v, want fs.resource_not_found", err) - } -} - func TestImportFileSystemTokenValidatesStatusAndStoresCredential(t *testing.T) { token := fsTestToken(t, "tenant-import") home := t.TempDir() @@ -946,16 +435,19 @@ func TestDrive9FailedUnmountPreservesMountLocator(t *testing.T) { } } -func TestDryRunCreateFileSystemUsesRedactedProvisionShape(t *testing.T) { +func TestDryRunCreateFileSystemUsesAdminTenantMetadataShape(t *testing.T) { profile := testProfile() + displayName := "agent-workspace" result, err := Service{Resolver: supportedFSManifestResolver("https://fs.test")}.DryRunCreateFileSystem(context.Background(), "ti fs create-file-system", CreateFileSystemOptions{ Profile: profile, WaitUntilReady: true, + DisplayName: &displayName, + Labels: map[string]string{"environment": "production"}, }) if err != nil { t.Fatalf("DryRunCreateFileSystem failed: %v", err) } - if result.Operation != "create_file_system" || result.Request.Path != "/v1/provision" { + if result.Operation != "create_file_system" || result.Request.Path != "/v1/admin/tenants" { t.Fatalf("unexpected dry-run result: %#v", result) } bodyBytes, err := json.Marshal(result.Request.Body) @@ -966,11 +458,14 @@ func TestDryRunCreateFileSystemUsesRedactedProvisionShape(t *testing.T) { if err := json.Unmarshal(bodyBytes, &body); err != nil { t.Fatalf("decode dry-run body: %v", err) } - if body["public_key"] != "[configured]" || body["private_key"] != "[redacted]" { + if body["display_name"] != displayName { + t.Fatalf("dry-run lost metadata: %#v", body) + } + if _, ok := body["public_key"]; ok { t.Fatalf("dry-run leaked credentials: %#v", body) } - if _, ok := body["tidbcloud_spending_limit"]; ok { - t.Fatalf("dry-run should not include spending limit: %#v", body) + if labels, ok := body["label"].(map[string]any); !ok || labels["environment"] != "production" { + t.Fatalf("dry-run lost labels: %#v", body) } if !hasDryRunCheck(result.Checks, "endpoint_selection", "passed") { t.Fatalf("expected endpoint dry-run check: %#v", result.Checks) diff --git a/internal/fs/tenant_control.go b/internal/fs/tenant_control.go new file mode 100644 index 0000000..39fddb8 --- /dev/null +++ b/internal/fs/tenant_control.go @@ -0,0 +1,462 @@ +package fs + +import ( + "context" + "errors" + "fmt" + "net/http" + "regexp" + "sort" + "strings" + "unicode/utf8" + + "github.com/tidbcloud/ti-cli/internal/api" + "github.com/tidbcloud/ti-cli/internal/api/endpoints" + apifs "github.com/tidbcloud/ti-cli/internal/api/fs" + apitransport "github.com/tidbcloud/ti-cli/internal/api/transport" + "github.com/tidbcloud/ti-cli/internal/apperr" + "github.com/tidbcloud/ti-cli/internal/auth" + "github.com/tidbcloud/ti-cli/internal/authz" + "github.com/tidbcloud/ti-cli/internal/config" + "github.com/tidbcloud/ti-cli/internal/fs/fscred" +) + +const ( + adminTenantPageSize = 100 + maxTenantLabels = 30 + maxLabelNameLength = 63 + maxLabelPrefixLength = 253 + maxLabelValueLength = 63 + invalidDisplayNameMessage = "--display-name must be 4-64 characters using ASCII letters, numbers, or hyphens, and must start and end with a letter or number" + invalidDisplayFilterMessage = "--display-name filter cannot be empty or contain %, _, or control characters" + invalidLabelKeyMessage = "label keys must be Kubernetes qualified names: an optional lowercase DNS prefix of at most 253 bytes and '/', followed by 1-63 bytes using ASCII letters, numbers, '-', '_' or '.', starting and ending with a letter or number" + invalidLabelValueMessage = "label values must be empty or at most 63 bytes using ASCII letters, numbers, '-', '_' or '.', and must start and end with a letter or number" +) + +var ( + tenantDisplayNameRegexp = regexp.MustCompile(`^[A-Za-z0-9][-A-Za-z0-9]{2,62}[A-Za-z0-9]$`) + labelNameRegexp = regexp.MustCompile(`^[A-Za-z0-9](?:[-A-Za-z0-9_.]*[A-Za-z0-9])?$`) + labelDNSPrefixRegexp = regexp.MustCompile(`^[a-z0-9](?:[-a-z0-9]*[a-z0-9])?(?:\.[a-z0-9](?:[-a-z0-9]*[a-z0-9])?)*$`) +) + +func ParseTenantMetadata(displayName string, displayNameSet bool, rawLabels []string) (*string, map[string]string, error) { + var parsedDisplayName *string + if displayNameSet { + if err := validateDisplayName(displayName); err != nil { + return nil, nil, err + } + parsedDisplayName = &displayName + } + labels, err := parseLabels(rawLabels, maxTenantLabels) + if err != nil { + return nil, nil, err + } + return parsedDisplayName, labels, nil +} + +func ParseTenantListFilters(displayName string, displayNameSet bool, rawLabels []string) (*string, *LabelFilter, error) { + var parsedDisplayName *string + if displayNameSet { + if displayName == "" || strings.ContainsAny(displayName, "%_") || strings.IndexFunc(displayName, func(r rune) bool { return r < 0x20 || r == 0x7f }) >= 0 { + return nil, nil, apperr.New("fs.invalid_display_name", "usage", 2, invalidDisplayFilterMessage) + } + parsedDisplayName = &displayName + } + if len(rawLabels) > 1 { + return nil, nil, apperr.New("fs.invalid_label", "usage", 2, "--label can be provided at most once when listing file systems") + } + if len(rawLabels) == 0 { + return parsedDisplayName, nil, nil + } + key, value, err := parseLabel(rawLabels[0]) + if err != nil { + return nil, nil, err + } + return parsedDisplayName, &LabelFilter{Key: key, Value: value}, nil +} + +func (s Service) createFileSystem(ctx context.Context, opts CreateFileSystemOptions) (FileSystemResult, error) { + request, client, creds, endpoint, err := s.adminCreateInputs(opts) + if err != nil { + return FileSystemResult{}, err + } + homeDir, err := s.homeDir() + if err != nil { + return FileSystemResult{}, err + } + if err := fscred.MigrateNameRegistry(homeDir, opts.Profile); err != nil { + return FileSystemResult{}, apperr.Wrap("fs.credential_store_preflight", "config", 1, "prepare local FS credential storage", err) + } + if err := fscred.PrepareCredentialStore(homeDir, profileName(opts.Profile)); err != nil { + return FileSystemResult{}, apperr.Wrap("fs.credential_store_preflight", "config", 1, "prepare local FS credential storage", err) + } + response, err := client.CreateAdminTenant(ctx, creds, request) + if err != nil { + return FileSystemResult{}, mapAdminTenantError(err, "create", "", opts.Profile.PlacementRegionCode) + } + fileSystemID, err := fscred.ValidateFileSystemID(response.TenantID) + if err != nil { + return FileSystemResult{}, apiContractError("create response did not include a valid tenant_id", err) + } + if strings.TrimSpace(response.APIKey) == "" { + return FileSystemResult{}, apiContractError("create response did not include the one-time owner token", nil) + } + status := strings.TrimSpace(response.Status) + if status == "" { + status = "provisioning" + } + result := FileSystemResult{ + FileSystemID: fileSystemID, DisplayName: effectiveTenantDisplayName(fileSystemID, response.DisplayName), Labels: cloneLabels(response.Labels), + RegionCode: opts.Profile.PlacementRegionCode, FSToken: response.APIKey, Status: status, + } + if _, storeErr := fscred.StoreCredential(homeDir, opts.Profile, fileSystemID, endpoint.RegionName, response.APIKey, false); storeErr != nil { + if s.Stderr != nil { + _, _ = fmt.Fprintf(s.Stderr, "ti [WARNING]: file system %s was created, but its one-time token could not be stored locally: %s\n", fileSystemID, apperr.MessageFor(storeErr)) + } + return result, nil + } + result.CredentialsStored = true + if opts.WaitUntilReady { + if err := s.waitUntilFileSystemReady(ctx, homeDir, opts.Profile, fileSystemID); err != nil { + return FileSystemResult{}, err + } + result.Status = "ready" + } + return result, nil +} + +func (s Service) listFileSystems(ctx context.Context, opts ListFileSystemsOptions) (ListFileSystemsResult, error) { + if err := validateAdminTenantListOptions(opts); err != nil { + return ListFileSystemsResult{}, err + } + client, creds, _, err := s.adminTenantClient(opts.Profile, authz.FSVolumeRead, "list file systems") + if err != nil { + return ListFileSystemsResult{}, err + } + homeDir, err := s.homeDir() + if err != nil { + return ListFileSystemsResult{}, err + } + if err := fscred.MigrateNameRegistry(homeDir, opts.Profile); err != nil { + return ListFileSystemsResult{}, err + } + credentials, err := fscred.ListCredentials(homeDir, profileName(opts.Profile)) + if err != nil { + return ListFileSystemsResult{}, err + } + hasToken := make(map[string]bool, len(credentials)) + for _, credential := range credentials { + hasToken[credential.FileSystemID] = credential.HasLocalToken + } + + page := 1 + seenPages := map[int]bool{} + seenIDs := map[string]bool{} + fileSystems := make([]FileSystemSummary, 0) + for { + if page <= 0 || seenPages[page] { + return ListFileSystemsResult{}, apiContractError("file system inventory returned a repeated or invalid page", nil) + } + seenPages[page] = true + apiOpts := apifs.ListAdminTenantsOptions{Page: page, PageSize: adminTenantPageSize} + if opts.DisplayName != nil { + apiOpts.DisplayName = *opts.DisplayName + } + if opts.Label != nil { + apiOpts.Label = &apifs.AdminTenantLabelFilter{Key: opts.Label.Key, Value: opts.Label.Value} + } + response, listErr := client.ListAdminTenants(ctx, creds, apiOpts) + if listErr != nil { + return ListFileSystemsResult{}, mapAdminTenantError(listErr, "list", "", opts.Profile.PlacementRegionCode) + } + if response.Page != page { + return ListFileSystemsResult{}, apiContractError(fmt.Sprintf("file system inventory returned page %d while page %d was requested", response.Page, page), nil) + } + for _, tenant := range response.Tenants { + id, idErr := fscred.ValidateFileSystemID(tenant.TenantID) + if idErr != nil { + return ListFileSystemsResult{}, apiContractError("file system inventory included an invalid tenant_id", idErr) + } + if seenIDs[id] { + return ListFileSystemsResult{}, apiContractError(fmt.Sprintf("file system inventory returned duplicate file system ID %q", id), nil) + } + seenIDs[id] = true + tenant.TenantID = id + fileSystems = append(fileSystems, mapAdminTenant(tenant, opts.Profile.PlacementRegionCode, hasToken[id])) + } + if response.NextPage == 0 { + break + } + if response.NextPage <= page || seenPages[response.NextPage] { + return ListFileSystemsResult{}, apiContractError("file system inventory returned a repeated or regressing next_page", nil) + } + page = response.NextPage + } + sort.Slice(fileSystems, func(i, j int) bool { return fileSystems[i].FileSystemID < fileSystems[j].FileSystemID }) + return ListFileSystemsResult{RegionCode: opts.Profile.PlacementRegionCode, FileSystems: fileSystems}, nil +} + +func (s Service) describeFileSystem(ctx context.Context, profile *config.Profile, fileSystemID string) (DescribeFileSystemResult, error) { + id, client, creds, err := s.adminItemInputs(profile, fileSystemID, authz.FSVolumeRead, "describe a file system") + if err != nil { + return DescribeFileSystemResult{}, err + } + response, err := client.GetAdminTenant(ctx, creds, id) + if err != nil { + return DescribeFileSystemResult{}, mapAdminTenantError(err, "get", id, profile.PlacementRegionCode) + } + if response.TenantID != id { + return DescribeFileSystemResult{}, apiContractError(fmt.Sprintf("describe response identified file system %q instead of %q", response.TenantID, id), nil) + } + homeDir, err := s.homeDir() + if err != nil { + return DescribeFileSystemResult{}, err + } + if err := fscred.MigrateNameRegistry(homeDir, profile); err != nil { + return DescribeFileSystemResult{}, err + } + _, credentialErr := fscred.GetCredential(homeDir, profileName(profile), id) + if credentialErr != nil && apperr.CodeFor(credentialErr) != "fs.credential_not_found" { + return DescribeFileSystemResult{}, credentialErr + } + return DescribeFileSystemResult{FileSystemSummary: mapAdminTenant(response, profile.PlacementRegionCode, credentialErr == nil)}, nil +} + +func (s Service) deleteFileSystem(ctx context.Context, opts DeleteFileSystemOptions) (DeleteResult, error) { + id, client, creds, _, err := s.adminDeleteInputs(opts) + if err != nil { + return DeleteResult{}, err + } + response, err := client.DeleteAdminTenant(ctx, creds, id) + if err != nil { + return DeleteResult{}, mapAdminTenantError(err, "delete", id, opts.Profile.PlacementRegionCode) + } + if response.TenantID != "" && response.TenantID != id { + return DeleteResult{}, apiContractError(fmt.Sprintf("delete response identified file system %q instead of %q", response.TenantID, id), nil) + } + status := strings.TrimSpace(response.Status) + if status == "" { + status = "deleting" + } + homeDir, err := s.homeDir() + if err != nil { + return DeleteResult{}, err + } + credentialsRemoved, err := fscred.DeleteCredential(homeDir, profileName(opts.Profile), id) + if err != nil { + return DeleteResult{}, err + } + return DeleteResult{FileSystemID: id, Status: status, CredentialsRemoved: credentialsRemoved, RemoteDeletionState: status}, nil +} + +func (s Service) adminCreateInputs(opts CreateFileSystemOptions) (apifs.AdminTenantCreateRequest, *apifs.Client, apifs.TiDBCloudCredentials, endpoints.Endpoint, error) { + if err := validateCreateMetadata(opts.DisplayName, opts.Labels); err != nil { + return apifs.AdminTenantCreateRequest{}, nil, apifs.TiDBCloudCredentials{}, endpoints.Endpoint{}, err + } + client, creds, endpoint, err := s.adminTenantClient(opts.Profile, authz.FSVolumeCreate, "create a file system") + if err != nil { + return apifs.AdminTenantCreateRequest{}, nil, apifs.TiDBCloudCredentials{}, endpoints.Endpoint{}, err + } + request := apifs.AdminTenantCreateRequest{Labels: cloneLabels(opts.Labels)} + if opts.DisplayName != nil { + request.DisplayName = *opts.DisplayName + } + return request, client, creds, endpoint, nil +} + +func (s Service) adminDeleteInputs(opts DeleteFileSystemOptions) (string, *apifs.Client, apifs.TiDBCloudCredentials, endpoints.Endpoint, error) { + id, client, creds, err := s.adminItemInputs(opts.Profile, opts.FileSystemID, authz.FSVolumeDelete, "delete a file system") + if err != nil { + return "", nil, apifs.TiDBCloudCredentials{}, endpoints.Endpoint{}, err + } + endpoint, err := s.resolveFS(opts.Profile) + if err != nil { + return "", nil, apifs.TiDBCloudCredentials{}, endpoints.Endpoint{}, err + } + return id, client, creds, endpoint, nil +} + +func (s Service) adminItemInputs(profile *config.Profile, fileSystemID string, permission authz.Permission, action string) (string, *apifs.Client, apifs.TiDBCloudCredentials, error) { + id, err := fscred.ValidateFileSystemID(fileSystemID) + if err != nil { + return "", nil, apifs.TiDBCloudCredentials{}, err + } + client, creds, _, err := s.adminTenantClient(profile, permission, action) + return id, client, creds, err +} + +func (s Service) adminTenantClient(profile *config.Profile, permission authz.Permission, action string) (*apifs.Client, apifs.TiDBCloudCredentials, endpoints.Endpoint, error) { + creds, err := auth.ValidateProfile(profile) + if err != nil { + return nil, apifs.TiDBCloudCredentials{}, endpoints.Endpoint{}, err + } + endpoint, err := s.resolveFS(profile) + if err != nil { + return nil, apifs.TiDBCloudCredentials{}, endpoints.Endpoint{}, err + } + raw, err := api.New(api.Options{ + Endpoint: endpoint, ProfileName: creds.ProfileName, Permission: permission, Action: action, + HTTPClient: s.HTTPClient, Transport: s.Transport, Timeout: s.Timeout, Debug: s.Debug, DebugWriter: s.DebugWriter, + Redactor: apitransport.Redactor{Secrets: []string{creds.PublicKey, creds.PrivateKey}}, UserAgent: "ti fs control plane", + }) + if err != nil { + return nil, apifs.TiDBCloudCredentials{}, endpoints.Endpoint{}, err + } + return apifs.New(raw), apifs.TiDBCloudCredentials{PublicKey: creds.PublicKey, PrivateKey: creds.PrivateKey}, endpoint, nil +} + +func validateAdminTenantListOptions(opts ListFileSystemsOptions) error { + if _, err := auth.ValidateProfile(opts.Profile); err != nil { + return err + } + if opts.DisplayName != nil { + value := *opts.DisplayName + if value == "" || strings.ContainsAny(value, "%_") || strings.IndexFunc(value, func(r rune) bool { return r < 0x20 || r == 0x7f }) >= 0 { + return apperr.New("fs.invalid_display_name", "usage", 2, invalidDisplayFilterMessage) + } + } + if opts.Label != nil { + if !isLabelKey(opts.Label.Key) { + return apperr.New("fs.invalid_label", "usage", 2, invalidLabelKeyMessage) + } + if !isLabelValue(opts.Label.Value) { + return apperr.New("fs.invalid_label", "usage", 2, invalidLabelValueMessage) + } + } + return nil +} + +func validateCreateMetadata(displayName *string, labels map[string]string) error { + if displayName != nil { + if err := validateDisplayName(*displayName); err != nil { + return err + } + } + if len(labels) > maxTenantLabels { + return apperr.New("fs.invalid_label", "usage", 2, fmt.Sprintf("at most %d --label values are allowed", maxTenantLabels)) + } + for key, value := range labels { + if !isLabelKey(key) { + return apperr.New("fs.invalid_label", "usage", 2, invalidLabelKeyMessage) + } + if !isLabelValue(value) { + return apperr.New("fs.invalid_label", "usage", 2, invalidLabelValueMessage) + } + } + return nil +} + +func validateDisplayName(value string) error { + if !tenantDisplayNameRegexp.MatchString(value) { + return apperr.New("fs.invalid_display_name", "usage", 2, invalidDisplayNameMessage) + } + return nil +} + +func parseLabels(values []string, limit int) (map[string]string, error) { + if len(values) > limit { + return nil, apperr.New("fs.invalid_label", "usage", 2, fmt.Sprintf("at most %d --label values are allowed", limit)) + } + labels := make(map[string]string, len(values)) + for _, raw := range values { + key, value, err := parseLabel(raw) + if err != nil { + return nil, err + } + if _, exists := labels[key]; exists { + return nil, apperr.New("fs.invalid_label", "usage", 2, fmt.Sprintf("duplicate label key %q", key)) + } + labels[key] = value + } + return labels, nil +} + +func parseLabel(raw string) (string, string, error) { + key, value, ok := strings.Cut(raw, "=") + if !ok || !isLabelKey(key) { + return "", "", apperr.New("fs.invalid_label", "usage", 2, invalidLabelKeyMessage) + } + if !isLabelValue(value) { + return "", "", apperr.New("fs.invalid_label", "usage", 2, invalidLabelValueMessage) + } + return key, value, nil +} + +func isLabelKey(key string) bool { + parts := strings.Split(key, "/") + if len(parts) > 2 { + return false + } + name := parts[len(parts)-1] + if len(name) == 0 || len(name) > maxLabelNameLength || !utf8.ValidString(name) || !labelNameRegexp.MatchString(name) { + return false + } + if len(parts) == 1 { + return true + } + prefix := parts[0] + if len(prefix) == 0 || len(prefix) > maxLabelPrefixLength || !utf8.ValidString(prefix) || !labelDNSPrefixRegexp.MatchString(prefix) { + return false + } + for _, segment := range strings.Split(prefix, ".") { + if len(segment) > 63 { + return false + } + } + return true +} + +func isLabelValue(value string) bool { + return value == "" || (len(value) <= maxLabelValueLength && utf8.ValidString(value) && labelNameRegexp.MatchString(value)) +} + +func mapAdminTenant(tenant apifs.AdminTenant, regionCode string, hasLocalToken bool) FileSystemSummary { + return FileSystemSummary{ + FileSystemID: tenant.TenantID, DisplayName: effectiveTenantDisplayName(tenant.TenantID, tenant.DisplayName), Labels: cloneLabels(tenant.Labels), + RegionCode: regionCode, Status: tenant.Status, Kind: tenant.Kind, Quota: tenant.Quota, HasLocalToken: hasLocalToken, + } +} + +func effectiveTenantDisplayName(fileSystemID, displayName string) string { + if strings.TrimSpace(displayName) == "" { + return fileSystemID + } + return displayName +} + +func cloneLabels(labels map[string]string) map[string]string { + result := make(map[string]string, len(labels)) + for key, value := range labels { + result[key] = value + } + return result +} + +func apiContractError(message string, cause error) error { + if cause == nil { + return apperr.New("fs.api_contract", "api", 1, message) + } + return apperr.Wrap("fs.api_contract", "api", 1, message, cause) +} + +func mapAdminTenantError(err error, operation, fileSystemID, regionCode string) error { + var apiErr *api.Error + if !errors.As(err, &apiErr) { + return err + } + if apiErr.StatusCode == http.StatusConflict && operation == "create" { + return apperr.Wrap("fs.display_name_conflict", "api", 1, "display name conflicts with an existing file system in the organization", err) + } + if apiErr.StatusCode == http.StatusNotFound { + switch operation { + case "create", "list": + return apperr.Wrap("fs.control_plane_unavailable", "api", 1, fmt.Sprintf("file system control plane is unavailable in region %q", regionCode), err) + default: + return remoteFileSystemNotFound(fileSystemID, err) + } + } + if apiErr.StatusCode >= http.StatusInternalServerError && apiErr.RequestID != "" { + return apperr.New(apiErr.Code, apiErr.Category, apiErr.ExitCode, fmt.Sprintf("%s (request ID: %s)", apiErr.Message, apiErr.RequestID)) + } + return err +} diff --git a/internal/fs/tenant_control_test.go b/internal/fs/tenant_control_test.go new file mode 100644 index 0000000..744a898 --- /dev/null +++ b/internal/fs/tenant_control_test.go @@ -0,0 +1,588 @@ +package fs + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/tidbcloud/ti-cli/internal/apperr" + "github.com/tidbcloud/ti-cli/internal/fs/fscred" +) + +func TestTenantControlCreateStoresOwnerTokenAndDoesNotInvokeCompanion(t *testing.T) { + home := t.TempDir() + profile := testProfile() + displayName := "agent-workspace" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/v1/admin/tenants" { + t.Fatalf("request = %s %s", r.Method, r.URL.RequestURI()) + } + assertAdminCredentialHeaders(t, r) + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body["display_name"] != displayName || body["public_key"] != nil || body["private_key"] != nil { + t.Fatalf("body = %#v", body) + } + labels, ok := body["label"].(map[string]any) + if !ok || labels["environment"] != "production" || labels["team"] != "ai" { + t.Fatalf("labels = %#v", body["label"]) + } + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(map[string]any{ + "tenant_id": "tenant-created", "display_name": displayName, "label": labels, "api_key": "owner-secret", "status": "provisioning", + "cloud_provider": "aws", "region": "us-east-1", + }) + })) + defer server.Close() + + service := directTenantService(home, server.URL) + service.CompanionPath = filepath.Join(t.TempDir(), "must-not-run") + result, err := service.CreateFileSystem(context.Background(), CreateFileSystemOptions{ + Profile: profile, DisplayName: &displayName, Labels: map[string]string{"environment": "production", "team": "ai"}, + }) + if err != nil { + t.Fatal(err) + } + if result.FileSystemID != "tenant-created" || result.DisplayName != displayName || result.Labels["team"] != "ai" || result.FSToken != "owner-secret" || !result.CredentialsStored { + t.Fatalf("result = %#v", result) + } + credential, err := fscred.GetCredential(home, profile.Name, result.FileSystemID) + if err != nil { + t.Fatal(err) + } + if credential.APIKey != "owner-secret" || credential.RegionCode != "aws-us-east-1" { + t.Fatalf("credential = %#v", credential) + } +} + +func TestTenantControlCreateWithoutMetadataUsesServerFallback(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if len(body) != 0 { + t.Fatalf("body = %#v", body) + } + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(map[string]any{"tenant_id": "tenant-fallback", "api_key": "owner-secret", "status": "active"}) + })) + defer server.Close() + + result, err := directTenantService(t.TempDir(), server.URL).CreateFileSystem(context.Background(), CreateFileSystemOptions{Profile: testProfile()}) + if err != nil { + t.Fatal(err) + } + if result.DisplayName != "tenant-fallback" || result.Labels == nil || len(result.Labels) != 0 { + t.Fatalf("result = %#v", result) + } +} + +func TestTenantControlCreateWaitUsesOnlyCompanionReadiness(t *testing.T) { + home := t.TempDir() + companion, recordPath := buildFakeDrive9(t) + t.Setenv("TI_FAKE_DRIVE9_RECORD", recordPath) + t.Setenv("TI_FAKE_DRIVE9_STAT_FAILURE_SEQUENCE", filepath.Join(t.TempDir(), "stat-attempted")) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(map[string]any{"tenant_id": "tenant-1", "display_name": "tenant-1", "label": map[string]string{}, "api_key": "fs-secret", "status": "provisioning"}) + })) + defer server.Close() + + service := directTenantService(home, server.URL) + service.CompanionPath = companion + service.FSReadyWaitTimeout = time.Second + service.FSReadyWaitPollInterval = time.Millisecond + result, err := service.CreateFileSystem(context.Background(), CreateFileSystemOptions{Profile: testProfile(), WaitUntilReady: true}) + if err != nil { + t.Fatal(err) + } + if result.Status != "ready" { + t.Fatalf("result = %#v", result) + } + statCalls := 0 + for _, call := range readFakeDrive9Calls(t, recordPath) { + if len(call.Args) > 0 && call.Args[0] == "create" { + t.Fatalf("direct create invoked companion create: %#v", call.Args) + } + if hasArgPrefix(call.Args, []string{"fs", "stat"}) { + statCalls++ + } + } + if statCalls != 2 { + t.Fatalf("readiness calls = %d, want 2", statCalls) + } +} + +func TestTenantControlListFiltersPaginationMetadataAndLocalTokenJoin(t *testing.T) { + home := t.TempDir() + profile := testProfile() + if _, err := fscred.StoreCredential(home, profile, "tenant-2", "aws-us-east-1", fsTestToken(t, "tenant-2"), false); err != nil { + t.Fatal(err) + } + pages := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + pages++ + assertAdminCredentialHeaders(t, r) + query := r.URL.Query() + if query.Get("page_size") != "100" || query.Get("display_name") != "workspace" || query.Get("label") != "environment==production" { + t.Fatalf("query = %q", r.URL.RawQuery) + } + switch query.Get("page") { + case "1": + _ = json.NewEncoder(w).Encode(map[string]any{ + "tenants": []map[string]any{{"tenant_id": "tenant-2", "display_name": "workspace-two", "label": map[string]string{"environment": "production"}, "status": "active", "kind": "live", "quota": tenantQuotaFixture()}}, + "page": 1, "page_size": 100, "next_page": 2, + }) + case "2": + _ = json.NewEncoder(w).Encode(map[string]any{ + "tenants": []map[string]any{{"tenant_id": "tenant-1", "display_name": "", "label": nil, "status": "active", "kind": "live", "quota": tenantQuotaFixture()}}, + "page": 2, "page_size": 100, + }) + default: + t.Fatalf("unexpected page %q", query.Get("page")) + } + })) + defer server.Close() + + displayName := "workspace" + result, err := directTenantService(home, server.URL).ListFileSystems(context.Background(), ListFileSystemsOptions{ + Profile: profile, DisplayName: &displayName, Label: &LabelFilter{Key: "environment", Value: "production"}, + }) + if err != nil { + t.Fatal(err) + } + if pages != 2 || len(result.FileSystems) != 2 || result.FileSystems[0].FileSystemID != "tenant-1" || result.FileSystems[1].FileSystemID != "tenant-2" { + t.Fatalf("result = %#v, pages = %d", result, pages) + } + if result.FileSystems[0].DisplayName != "tenant-1" || result.FileSystems[0].Labels == nil || result.FileSystems[0].HasLocalToken { + t.Fatalf("fallback item = %#v", result.FileSystems[0]) + } + if !result.FileSystems[1].HasLocalToken || result.FileSystems[1].Quota == nil { + t.Fatalf("local item = %#v", result.FileSystems[1]) + } + text := result.Human() + if !strings.Contains(text, "DISPLAY_NAME") || !strings.Contains(text, "workspace-two") { + t.Fatalf("text output = %q", text) + } +} + +func TestTenantControlDescribeAndDeleteUseIDs(t *testing.T) { + home := t.TempDir() + profile := testProfile() + for _, id := range []string{"tenant-1", "tenant-2"} { + if _, err := fscred.StoreCredential(home, profile, id, "aws-us-east-1", fsTestToken(t, id), false); err != nil { + t.Fatal(err) + } + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertAdminCredentialHeaders(t, r) + switch r.Method { + case http.MethodGet: + _ = json.NewEncoder(w).Encode(map[string]any{"tenant_id": "tenant-1", "display_name": "workspace-one", "label": map[string]string{"team": "ai"}, "status": "active", "kind": "live", "quota": tenantQuotaFixture()}) + case http.MethodDelete: + if r.ContentLength > 0 { + t.Fatalf("delete unexpectedly had a body") + } + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(map[string]any{"tenant_id": "tenant-1", "status": "deleting"}) + default: + t.Fatalf("request = %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + service := directTenantService(home, server.URL) + described, err := service.DescribeFileSystem(context.Background(), profile, "tenant-1") + if err != nil { + t.Fatal(err) + } + if described.DisplayName != "workspace-one" || described.Labels["team"] != "ai" || !described.HasLocalToken || !strings.Contains(described.Human(), "Labels: team=ai") { + t.Fatalf("describe = %#v", described) + } + deleted, err := service.DeleteFileSystem(context.Background(), DeleteFileSystemOptions{Profile: profile, FileSystemID: "tenant-1"}) + if err != nil { + t.Fatal(err) + } + if deleted.Status != "deleting" || !deleted.CredentialsRemoved { + t.Fatalf("delete = %#v", deleted) + } + if _, err := fscred.GetCredential(home, profile.Name, "tenant-1"); apperr.CodeFor(err) != "fs.credential_not_found" { + t.Fatalf("selected credential remains: %v", err) + } + if _, err := fscred.GetCredential(home, profile.Name, "tenant-2"); err != nil { + t.Fatalf("unrelated credential was removed: %v", err) + } +} + +func TestTenantControlValidationRejectsInvalidMetadataBeforeNetwork(t *testing.T) { + validDisplay, labels, err := ParseTenantMetadata("agent-workspace", true, []string{"environment=production", "example.com/empty="}) + if err != nil || validDisplay == nil || labels["example.com/empty"] != "" { + t.Fatalf("valid metadata = %v %#v %#v", err, validDisplay, labels) + } + tests := []struct { + name string + run func() error + code string + }{ + {name: "explicit empty display name", code: "fs.invalid_display_name", run: func() error { _, _, err := ParseTenantMetadata("", true, nil); return err }}, + {name: "surrounding whitespace", code: "fs.invalid_display_name", run: func() error { _, _, err := ParseTenantMetadata(" agent-workspace ", true, nil); return err }}, + {name: "duplicate label", code: "fs.invalid_label", run: func() error { + _, _, err := ParseTenantMetadata("", false, []string{"team=ai", "team=data"}) + return err + }}, + {name: "invalid label key", code: "fs.invalid_label", run: func() error { _, _, err := ParseTenantMetadata("", false, []string{"Example.com/team=ai"}); return err }}, + {name: "oversized label prefix segment", code: "fs.invalid_label", run: func() error { + _, _, err := ParseTenantMetadata("", false, []string{strings.Repeat("a", 64) + ".example/team=ai"}) + return err + }}, + {name: "invalid label value", code: "fs.invalid_label", run: func() error { _, _, err := ParseTenantMetadata("", false, []string{"team=-ai"}); return err }}, + {name: "missing label separator", code: "fs.invalid_label", run: func() error { _, _, err := ParseTenantMetadata("", false, []string{"team"}); return err }}, + {name: "wildcard display filter", code: "fs.invalid_display_name", run: func() error { _, _, err := ParseTenantListFilters("work%", true, nil); return err }}, + {name: "multiple list labels", code: "fs.invalid_label", run: func() error { + _, _, err := ParseTenantListFilters("", false, []string{"team=ai", "env=prod"}) + return err + }}, + } + tooMany := make([]string, maxTenantLabels+1) + for i := range tooMany { + tooMany[i] = fmt.Sprintf("key%d=value", i) + } + tests = append(tests, struct { + name string + run func() error + code string + }{name: "too many labels", code: "fs.invalid_label", run: func() error { _, _, err := ParseTenantMetadata("", false, tooMany); return err }}) + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if err := test.run(); apperr.CodeFor(err) != test.code { + t.Fatalf("error = %v, want %s", err, test.code) + } + }) + } +} + +func TestTenantControlMapsErrorsAndRejectsContractViolations(t *testing.T) { + tests := []struct { + name string + statusCode int + body string + run func(Service) error + wantCode string + }{ + {name: "display conflict", statusCode: http.StatusConflict, body: `{"error":"conflict"}`, wantCode: "fs.display_name_conflict", run: func(s Service) error { + name := "agent-workspace" + _, err := s.CreateFileSystem(context.Background(), CreateFileSystemOptions{Profile: testProfile(), DisplayName: &name}) + return err + }}, + {name: "control plane unavailable", statusCode: http.StatusNotFound, body: `{"error":"admin tenant API not enabled"}`, wantCode: "fs.control_plane_unavailable", run: func(s Service) error { + _, err := s.ListFileSystems(context.Background(), ListFileSystemsOptions{Profile: testProfile()}) + return err + }}, + {name: "item missing", statusCode: http.StatusNotFound, body: `{"error":"tenant not found"}`, wantCode: "fs.resource_not_found", run: func(s Service) error { + _, err := s.DescribeFileSystem(context.Background(), testProfile(), "tenant-missing") + return err + }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(test.statusCode) + _, _ = w.Write([]byte(test.body)) + })) + defer server.Close() + if err := test.run(directTenantService(t.TempDir(), server.URL)); apperr.CodeFor(err) != test.wantCode { + t.Fatalf("error = %v, want %s", err, test.wantCode) + } + }) + } + + mismatch := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{"tenant_id": "tenant-other", "display_name": "tenant-other", "label": map[string]string{}, "status": "active"}) + })) + defer mismatch.Close() + if _, err := directTenantService(t.TempDir(), mismatch.URL).DescribeFileSystem(context.Background(), testProfile(), "tenant-1"); apperr.CodeFor(err) != "fs.api_contract" { + t.Fatalf("mismatch error = %v", err) + } + + missingToken := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{"tenant_id": "tenant-1", "display_name": "tenant-1", "label": map[string]string{}, "status": "active"}) + })) + defer missingToken.Close() + if _, err := directTenantService(t.TempDir(), missingToken.URL).CreateFileSystem(context.Background(), CreateFileSystemOptions{Profile: testProfile()}); apperr.CodeFor(err) != "fs.api_contract" { + t.Fatalf("missing token error = %v", err) + } +} + +func TestTenantControlDeleteFailurePreservesCredential(t *testing.T) { + home := t.TempDir() + profile := testProfile() + if _, err := fscred.StoreCredential(home, profile, "tenant-1", "aws-us-east-1", fsTestToken(t, "tenant-1"), false); err != nil { + t.Fatal(err) + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":"temporary backend failure"}`)) + })) + defer server.Close() + if _, err := directTenantService(home, server.URL).DeleteFileSystem(context.Background(), DeleteFileSystemOptions{Profile: profile, FileSystemID: "tenant-1"}); err == nil { + t.Fatal("delete should fail") + } + if _, err := fscred.GetCredential(home, profile.Name, "tenant-1"); err != nil { + t.Fatalf("failed delete removed credentials: %v", err) + } +} + +func directTenantService(homeDir, baseURL string) Service { + return Service{HomeDir: homeDir, Resolver: supportedFSManifestResolver(baseURL), CompanionPath: filepath.Join(homeDir, "companion-must-not-run")} +} + +func assertAdminCredentialHeaders(t *testing.T, request *http.Request) { + t.Helper() + if request.Header.Get("X-TiDBCloud-Public-Key") != "public" || request.Header.Get("X-TiDBCloud-Private-Key") != "private" { + t.Fatalf("credential headers = %q/%q", request.Header.Get("X-TiDBCloud-Public-Key"), request.Header.Get("X-TiDBCloud-Private-Key")) + } +} + +func tenantQuotaFixture() map[string]any { + return map[string]any{ + "config": map[string]any{"max_storage_size": 1024, "max_file_size": 128, "max_file_count": 1000, "tidbcloud_spending_limit": nil}, + "usage": map[string]any{"storage_bytes": 12, "reserved_bytes": 3, "file_count": 2}, + } +} + +func TestTenantControlCreatePersistenceFailureReturnsRecoveryData(t *testing.T) { + home := t.TempDir() + credentialRoot := filepath.Join(home, ".ti", "fs_credentials") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + if err := os.RemoveAll(credentialRoot); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(credentialRoot, []byte("not a directory"), 0o600); err != nil { + t.Fatal(err) + } + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(map[string]any{"tenant_id": "tenant-1", "display_name": "tenant-1", "label": map[string]string{}, "api_key": "one-time-owner-token", "status": "active"}) + })) + defer server.Close() + var stderr strings.Builder + service := directTenantService(home, server.URL) + service.Stderr = &stderr + result, err := service.CreateFileSystem(context.Background(), CreateFileSystemOptions{Profile: testProfile()}) + if err != nil { + t.Fatal(err) + } + if result.FSToken != "one-time-owner-token" || result.CredentialsStored || !strings.Contains(stderr.String(), "was created") || strings.Contains(stderr.String(), result.FSToken) { + t.Fatalf("result = %#v stderr = %q", result, stderr.String()) + } +} + +func TestTenantControlCreatePreflightFailsBeforeRemoteMutation(t *testing.T) { + home := t.TempDir() + credentialRoot := filepath.Join(home, ".ti", "fs_credentials") + if err := os.MkdirAll(filepath.Dir(credentialRoot), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(credentialRoot, []byte("not a directory"), 0o600); err != nil { + t.Fatal(err) + } + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests.Add(1) + w.WriteHeader(http.StatusAccepted) + })) + defer server.Close() + + _, err := directTenantService(home, server.URL).CreateFileSystem(context.Background(), CreateFileSystemOptions{Profile: testProfile()}) + if apperr.CodeFor(err) != "fs.credential_store_preflight" { + t.Fatalf("error = %v, want fs.credential_store_preflight", err) + } + if requests.Load() != 0 { + t.Fatalf("credential preflight failure sent %d remote requests", requests.Load()) + } +} + +func TestTenantControlCreateWaitTimeoutPreservesCredential(t *testing.T) { + home := t.TempDir() + companion, _ := buildFakeDrive9(t) + t.Setenv("TI_FAKE_DRIVE9_STAT_ALWAYS_FAIL", "1") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(map[string]any{ + "tenant_id": "tenant-timeout", "display_name": "tenant-timeout", "label": map[string]string{}, "api_key": "owner-secret", "status": "provisioning", + }) + })) + defer server.Close() + + service := directTenantService(home, server.URL) + service.CompanionPath = companion + service.FSReadyWaitTimeout = 10 * time.Millisecond + service.FSReadyWaitPollInterval = time.Millisecond + _, err := service.CreateFileSystem(context.Background(), CreateFileSystemOptions{Profile: testProfile(), WaitUntilReady: true}) + if apperr.CodeFor(err) != "fs.ready_wait_timeout" || !strings.Contains(apperr.MessageFor(err), "tenant-timeout") { + t.Fatalf("error = %v, want timeout retaining the created ID", err) + } + credential, getErr := fscred.GetCredential(home, testProfile().Name, "tenant-timeout") + if getErr != nil || credential.APIKey != "owner-secret" { + t.Fatalf("readiness timeout removed credential: credential=%#v err=%v", credential, getErr) + } +} + +func TestTenantControlMissingCredentialsFailBeforeNetwork(t *testing.T) { + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests.Add(1) + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + profile := testProfile() + profile.TiDBCloudPublicKey = "" + profile.TiDBCloudPrivateKey = "" + service := directTenantService(t.TempDir(), server.URL) + tests := []struct { + name string + run func() error + }{ + {name: "create", run: func() error { + _, err := service.CreateFileSystem(context.Background(), CreateFileSystemOptions{Profile: profile}) + return err + }}, + {name: "list", run: func() error { + _, err := service.ListFileSystems(context.Background(), ListFileSystemsOptions{Profile: profile}) + return err + }}, + {name: "describe", run: func() error { + _, err := service.DescribeFileSystem(context.Background(), profile, "tenant-1") + return err + }}, + {name: "delete", run: func() error { + _, err := service.DeleteFileSystem(context.Background(), DeleteFileSystemOptions{Profile: profile, FileSystemID: "tenant-1"}) + return err + }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if err := test.run(); apperr.CodeFor(err) != "auth.missing_credentials" { + t.Fatalf("error = %v, want auth.missing_credentials", err) + } + }) + } + if requests.Load() != 0 { + t.Fatalf("missing credentials sent %d remote requests", requests.Load()) + } +} + +func TestTenantControlRejectsInvalidPagination(t *testing.T) { + tests := []struct { + name string + respond func(page string) map[string]any + }{ + {name: "mismatched page", respond: func(string) map[string]any { + return map[string]any{"tenants": []any{}, "page": 2, "page_size": 100} + }}, + {name: "regressing next page", respond: func(string) map[string]any { + return map[string]any{"tenants": []any{}, "page": 1, "page_size": 100, "next_page": 1} + }}, + {name: "duplicate ID", respond: func(page string) map[string]any { + result := map[string]any{"tenants": []map[string]any{{"tenant_id": "tenant-1", "display_name": "tenant-1", "label": map[string]string{}}}, "page_size": 100} + if page == "1" { + result["page"] = 1 + result["next_page"] = 2 + } else { + result["page"] = 2 + } + return result + }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(test.respond(r.URL.Query().Get("page"))) + })) + defer server.Close() + _, err := directTenantService(t.TempDir(), server.URL).ListFileSystems(context.Background(), ListFileSystemsOptions{Profile: testProfile()}) + if apperr.CodeFor(err) != "fs.api_contract" { + t.Fatalf("error = %v, want fs.api_contract", err) + } + }) + } +} + +func TestTenantControlRemoteErrorsRetainSafeDetails(t *testing.T) { + t.Run("forbidden detail", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"error":"organization policy denied tenant inventory"}`)) + })) + defer server.Close() + _, err := directTenantService(t.TempDir(), server.URL).ListFileSystems(context.Background(), ListFileSystemsOptions{Profile: testProfile()}) + if apperr.CodeFor(err) != "authz.permission_denied" || !strings.Contains(apperr.MessageFor(err), "organization policy denied tenant inventory") { + t.Fatalf("error = %v", err) + } + }) + + t.Run("server request ID", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("X-Request-ID", "request-tenant-500") + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":"temporary failure"}`)) + })) + defer server.Close() + _, err := directTenantService(t.TempDir(), server.URL).ListFileSystems(context.Background(), ListFileSystemsOptions{Profile: testProfile()}) + if apperr.CodeFor(err) != "api.remote_error" || !strings.Contains(apperr.MessageFor(err), "request-tenant-500") { + t.Fatalf("error = %v", err) + } + }) + + t.Run("create is not retried", func(t *testing.T) { + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests.Add(1) + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + _, err := directTenantService(t.TempDir(), server.URL).CreateFileSystem(context.Background(), CreateFileSystemOptions{Profile: testProfile()}) + if err == nil || requests.Load() != 1 { + t.Fatalf("error = %v, requests = %d", err, requests.Load()) + } + }) +} + +func TestTenantControlMigratesLegacyCredentialForDescribeAndDelete(t *testing.T) { + home := t.TempDir() + profile := testProfile() + if err := fscred.Store(home, profile, "workspace", "tenant-legacy", "aws", "aws-us-east-1", fsTestToken(t, "tenant-legacy")); err != nil { + t.Fatal(err) + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + _ = json.NewEncoder(w).Encode(map[string]any{"tenant_id": "tenant-legacy", "display_name": "legacy-workspace", "label": map[string]string{}, "status": "active"}) + case http.MethodDelete: + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(map[string]any{"tenant_id": "tenant-legacy", "status": "deleting"}) + } + })) + defer server.Close() + service := directTenantService(home, server.URL) + described, err := service.DescribeFileSystem(context.Background(), profile, "tenant-legacy") + if err != nil || !described.HasLocalToken { + t.Fatalf("describe = %#v err=%v", described, err) + } + deleted, err := service.DeleteFileSystem(context.Background(), DeleteFileSystemOptions{Profile: profile, FileSystemID: "tenant-legacy"}) + if err != nil || !deleted.CredentialsRemoved { + t.Fatalf("delete = %#v err=%v", deleted, err) + } + if _, err := fscred.GetCredential(home, profile.Name, "tenant-legacy"); apperr.CodeFor(err) != "fs.credential_not_found" { + t.Fatalf("migrated credential remains: %v", err) + } +}