From da9cf62de3749d82e2c71bcaec1eae899b507eef Mon Sep 17 00:00:00 2001 From: Cheese Date: Tue, 11 Aug 2026 16:58:49 +0800 Subject: [PATCH] feat(db): remove project selection and inventory --- .github/ISSUE_TEMPLATE/bug_report.yml | 1 - AGENTS.md | 68 ++--- Makefile | 5 +- README.md | 4 +- docs/present.md | 29 +- docs/priciples.md | 16 +- .../0029-local-file-system-mount-inventory.md | 277 ------------------ ...> 0030-homebrew-and-scoop-distribution.md} | 0 ...=> 0031-serverless-function-deployment.md} | 0 .../0012-install-and-update-distribution.md | 4 +- .../0029-remove-db-project-selection.md} | 103 +++++-- e2e/cli_test.go | 51 ++-- e2e/live_test.go | 81 +---- internal/api/iam/client.go | 66 ----- internal/api/iam/client_test.go | 103 +------ internal/api/starter/client.go | 17 +- internal/api/starter/client_test.go | 11 +- internal/authz/authz.go | 66 ++--- internal/authz/authz_test.go | 9 +- internal/cli/commands.go | 70 +---- internal/cli/root.go | 1 - internal/cli/root_test.go | 94 ++---- internal/config/configure/wizard.go | 123 +------- internal/config/configure/wizard_test.go | 220 +++----------- internal/config/profile.go | 3 - internal/config/profile_test.go | 14 +- internal/config/store/store.go | 16 +- internal/config/store/store_test.go | 6 +- internal/db/contracts.go | 14 +- internal/db/product/starter/cluster.go | 27 -- .../db/product/starter/cluster_plan_test.go | 4 +- internal/db/product/starter/cluster_test.go | 55 +--- internal/fs/fscred/fscred_test.go | 4 +- internal/organization/organization.go | 115 -------- internal/organization/organization_test.go | 118 -------- internal/settings/settings_test.go | 2 +- internal/telemetry/telemetry_test.go | 4 +- scripts/install.ps1 | 4 +- scripts/install.sh | 4 +- 39 files changed, 299 insertions(+), 1510 deletions(-) delete mode 100644 docs/spec/0029-local-file-system-mount-inventory.md rename docs/spec/{0031-homebrew-and-scoop-distribution.md => 0030-homebrew-and-scoop-distribution.md} (100%) rename docs/spec/{0032-serverless-function-deployment.md => 0031-serverless-function-deployment.md} (100%) rename docs/spec/{0030-remove-db-project-selection.md => done/0029-remove-db-project-selection.md} (67%) delete mode 100644 internal/organization/organization.go delete mode 100644 internal/organization/organization_test.go diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 71e3b6a..40a28f8 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -23,7 +23,6 @@ body: options: - configure - update - - organization - db - fs - fs-git diff --git a/AGENTS.md b/AGENTS.md index d5001f6..a4fbfa4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,16 +38,20 @@ Implemented: `docs/spec/done/0003-output-error-query-dry-run.md` - API client auth, authorization, and region routing from `docs/spec/done/0004-api-client-auth-and-region-routing.md` -- Organization project listing from - `docs/spec/done/0005-organization-management.md` +- Historical organization project listing from + `docs/spec/done/0005-organization-management.md`, superseded by project + selection and inventory removal in `docs/spec/done/0029-remove-db-project-selection.md` - Starter DB cluster lifecycle from `docs/spec/done/0006-starter-db-cluster-lifecycle.md` - Starter DB branch lifecycle from `docs/spec/done/0007-starter-db-branch-lifecycle.md` - Starter DB SQL access and query from `docs/spec/done/0008-starter-db-sql-access-and-query.md` -- Default virtual project discovery and DB create resolution from - `docs/spec/done/0017-default-virtual-project-resolution.md` +- Historical default virtual project discovery from + `docs/spec/done/0017-default-virtual-project-resolution.md`, superseded by + project selection and inventory removal +- TiDB Cloud Project selection and inventory removal from + `docs/spec/done/0029-remove-db-project-selection.md` - Starter-only DB resource guardrails from `docs/spec/done/0023-starter-only-db-resource-guardrails.md` - Region-scoped Starter DB cluster listing from @@ -78,7 +82,6 @@ Implemented: - `ti configure` - `ti update --check` - `ti update` -- `ti organization list-projects` - `ti db create-db-cluster` - `ti db list-db-clusters` - `ti db describe-db-cluster` @@ -204,7 +207,6 @@ make test make e2e make telemetry-e2e make live-e2e-configure -make live-e2e-organization make live-e2e-db make live-e2e-fs make live-e2e-fs-git @@ -233,7 +235,7 @@ delivery path before dropping only that temporary database. It must not run as part of `make test`, `make e2e`, or any live-e2e target. The `make live-e2e-` targets build `bin/ti` and run only the selected top-level command family against the `live-e2e` profile by default. Keep -configure, organization, db, fs, fs-git, fs-journal, and fs-vault tests +configure, db, fs, fs-git, fs-journal, and fs-vault tests independently selectable. Do not make a focused family target run tests from a different family, and do not add separate mutating/non-mutating variants. `make live-e2e` runs every live family together in one test process and remains @@ -243,10 +245,9 @@ Live e2e must strictly cover every implemented interface and command for the current project stage, including real create/update/delete flows when those commands are implemented. For Starter DB clusters, the live suite creates a uniquely named `ti-e2e-*` cluster with `--wait`, without a -spending limit or explicit/configured project ID, verifies the returned state -is `ACTIVE` and has a non-empty server-selected project label, and deletes only -that cluster. The server-selected account default is not required to equal the -`tidbx_virtual` project discovered by `ti configure`. For Starter DB branches, +spending limit or project selection, verifies the returned state is `ACTIVE`, +preserves any server-selected project metadata, and deletes only that cluster. +For Starter DB branches, the live suite creates, reads, lists, and deletes only a `ti-e2e-branch-*` branch on the cluster created by the same test run. Branch creation must use `--wait`; cluster deletion must use `--wait`. For Starter DB SQL access, the live suite prepares ti-managed @@ -326,7 +327,6 @@ internal/fs/fscred/ ID-keyed ti fs credentials, selection, and legacy mi internal/fs/mountlocator/ non-secret Drive9 background mount routing state internal/oplog/ local JSONL operation log writer internal/output/ structured JSON/text/raw rendering -internal/organization/ organization project command use cases internal/query/ JMESPath query application internal/secretinput/ no-echo secret input helper internal/settings/ global settings parsing and legacy logging migration @@ -354,7 +354,7 @@ Follow these rules unless `docs/priciples.md` is updated: - The command tree is at most two levels: `ti [subcommand]`. - `ti configure` and `ti update` are the only intentional top-level verb exceptions. `ti configure` is the only interactive command. -- Other top-level commands are nouns such as `db`, `fs`, and `organization`. +- Other top-level commands are nouns such as `db` and `fs`. - Use long flags only, for example `--profile` and `--db-cluster-name`. - Do not add short flags or one-letter aliases. The current CLI rejects short flags before invoking Cobra. @@ -363,7 +363,7 @@ Follow these rules unless `docs/priciples.md` is updated: handlers as their canonical commands. - Do not prompt for input except inside `ti configure`. - Successful structured control-plane commands output JSON by default. -- Implement DB, organization, and fs control-plane commands through +- Implement DB and fs control-plane commands through `controlPlaneCommandSpec` in `internal/cli`, so normal execution, dry-run, output rendering, and query handling stay on the shared path. - Non-DB control-plane commands must declare exactly one `authz.Permission` in @@ -398,8 +398,8 @@ Follow these rules unless `docs/priciples.md` is updated: legacy fallback, and dispatch through capability interfaces. Reject recognized but unsupported products and missing, unknown, or conflicting plans before the product operation. -- Only `ti db` uses dynamic operation-to-permission mapping. Keep FS and - organization command permissions static. The CLI composition root registers +- Only `ti db` uses dynamic operation-to-permission mapping. Keep FS command + permissions static. The CLI composition root registers product resolvers/providers; the root `internal/db` package must not import child product packages. - `ti db list-db-clusters --db-cluster-type starter` adds an immutable API @@ -469,13 +469,9 @@ Implemented command behavior: - `ti update --dry-run` - `ti update` - `ti update --target-version v0.1.1` -- `ti organization list-projects` -- `ti organization list-projects --query 'projects[0].id'` -- `ti organization list-projects --output text` - `ti db create-db-cluster --db-cluster-type starter --db-cluster-name demo` - `ti db create-db-cluster --db-cluster-type starter --db-cluster-name demo --wait` - `ti db create-db-cluster --db-cluster-type starter --db-cluster-name demo --dry-run` -- `ti db create-db-cluster --db-cluster-type starter --db-cluster-name demo --project-id ` - `ti db list-db-clusters --db-cluster-type starter` - `ti db list-db-clusters --db-cluster-type starter --query 'clusters[].id'` - `ti db describe-db-cluster --db-cluster-id ` @@ -593,7 +589,6 @@ Registered command surface: - `ti update --check` - `ti update` -- `ti organization list-projects` - `ti db create-db-cluster` - `ti db list-db-clusters` - `ti db describe-db-cluster` @@ -721,9 +716,9 @@ the deprecated variable name. - The global `--profile` flag selects a profile when explicitly provided. - The global `--region` flag selects command-scope placement when explicitly provided and must reject an explicit empty value. -- `ti configure` writes canonical `region_code`, discovers the unique - `tidbx_virtual` project as `project_id`, and writes - `tidb_cloud_public_key` and `tidb_cloud_private_key`. +- `ti configure` writes canonical `region_code`, `tidb_cloud_public_key`, and + `tidb_cloud_private_key` without making a network request or discovering a + TiDB Cloud project. - `ti configure --non-interactive` must not prompt. It reads values from flags first, then `TI_REGION_CODE`, `TIDB_CLOUD_PUBLIC_KEY`, and `TIDB_CLOUD_PRIVATE_KEY`. Missing values fail with an actionable error. @@ -740,7 +735,6 @@ Typical configured profile keys: # ~/.ti/config [default] region_code = "aws-us-east-1" -project_id = "..." # ~/.ti/credentials [default] @@ -748,9 +742,9 @@ tidb_cloud_public_key = "..." tidb_cloud_private_key = "..." ``` -`project_id` is written by `ti configure` but is not required to create a -Starter cluster. If it is absent and `--project-id` is not provided, the create -request omits the project label and TiDB Cloud selects the account default. +Starter cluster creation never reads or sends project selection. It omits the +project label and lets TiDB Cloud select its server-side default project. +Project metadata returned by TiDB Cloud remains unchanged in command output. One profile can access multiple remotely inventoried ti fs resources. The main config stores neither a default resource nor resource credentials. @@ -847,19 +841,11 @@ Placement lookup order for authenticated commands: 2. If `TI_REGION_CODE` is set, use it for this command only. 3. Otherwise use the selected profile's `region_code`. -Starter DB cluster creation project lookup order is: - -1. Explicit non-empty `--project-id`. -2. The selected profile's `project_id`, discovered by `ti configure` from the - unique accessible project whose type is `tidbx_virtual`. -3. Otherwise omit the `tidb.cloud/project` label and let the Starter API select - the account's default project. - -An explicitly empty `--project-id` is an error and must not use the profile -or server fallback. When no project ID resolves, omit the label entirely; do -not send `tidb.cloud/project` with an empty value. Other DB commands identify -existing resources by cluster or branch ID and do not send `project_id`. -Drive9-backed ti fs commands do not consume this DB project default. +Starter DB cluster creation omits project selection entirely. Do not expose a +`--project-id` flag, read a legacy profile `project_id`, or send a +`tidb.cloud/project` label. Preserve project-related fields and labels returned +by TiDB Cloud as opaque API resource metadata. Other DB commands identify +existing resources by cluster or branch ID. Environment credentials are a credential source only; they must not change the local profile namespace and must not cause ti to write local `[env]` sections. diff --git a/Makefile b/Makefile index 6ec5f9d..f5e742f 100644 --- a/Makefile +++ b/Makefile @@ -25,7 +25,7 @@ ifneq ($(strip $(TELEMETRY_ENDPOINT)),) LDFLAGS += -X $(MODULE)/internal/version.telemetryEndpoint=$(TELEMETRY_ENDPOINT) endif -.PHONY: all build build-telemetry-backend build-telemetry-migrator test e2e telemetry-e2e live-e2e live-e2e-configure live-e2e-organization live-e2e-db live-e2e-fs live-e2e-fs-git live-e2e-fs-journal live-e2e-fs-vault release-snapshot clean +.PHONY: all build build-telemetry-backend build-telemetry-migrator test e2e telemetry-e2e live-e2e live-e2e-configure live-e2e-db live-e2e-fs live-e2e-fs-git live-e2e-fs-journal live-e2e-fs-vault release-snapshot clean all: build @@ -58,9 +58,6 @@ live-e2e: build live-e2e-configure: build $(LIVE_E2E_RUN) -run '^TestLive(ProfileConfigured|CLICommandSurface)$$' -live-e2e-organization: build - $(LIVE_E2E_RUN) -run '^TestLiveOrganization' - live-e2e-db: build $(LIVE_E2E_RUN) -run '^TestLiveDB' diff --git a/README.md b/README.md index 51cdb91..49772c6 100644 --- a/README.md +++ b/README.md @@ -197,7 +197,7 @@ The token contains its file system ID, so a clean sandbox does not need `TI_FS_F Cluster lists include only verified Starter clusters in the effective region and omit other service plans, cross-region resources, and resources whose region cannot be verified. Use global `--region`, for example `ti --region aws-us-west-2 db list-db-clusters --db-cluster-type starter`, to inspect another region without changing the stored profile. Listing incrementally fills the requested result page from TiDB Cloud API pages. Its opaque `next_page_token` belongs to `ti` and can be passed only to a later call with the same profile, type, region, filter, and ordering. -`ti configure` discovers the account's virtual project and saves its ID in the selected profile. Cluster creation uses an explicit `--project-id` first, then that saved project ID. If neither is available, `ti` omits the project label and lets TiDB Cloud select the account's default project. +`ti configure` stores the selected profile's region and API keys locally without making a TiDB Cloud request. Cluster creation omits project selection and lets TiDB Cloud select its server-side default project. Project metadata returned by TiDB Cloud remains visible in the cluster response. ```shell ti db create-db-cluster --db-cluster-type starter --db-cluster-name my-distributed-mysql --wait @@ -216,7 +216,6 @@ ti db create-db-cluster --db-cluster-type starter --db-cluster-name my-distribut ```text ti configure ti update -ti organization list-projects ti db create-db-cluster --db-cluster-type starter ti db list-db-clusters --db-cluster-type starter @@ -346,7 +345,6 @@ Run one live command family against the `live-e2e` profile: ```bash make live-e2e-configure -make live-e2e-organization make live-e2e-db make live-e2e-fs make live-e2e-fs-git diff --git a/docs/present.md b/docs/present.md index f48de52..1325eb1 100644 --- a/docs/present.md +++ b/docs/present.md @@ -26,18 +26,9 @@ TIDB_CLOUD_PRIVATE_KEY="$TIDB_CLOUD_PRIVATE_KEY" \ bin/ti configure --non-interactive ``` -`configure` 会验证 TiDB Cloud API key,找到唯一的 `tidbx_virtual` project,并把其 ID 保存为默认 `project_id`。后续创建 Starter cluster 不需要重复传 `--project-id`。 +`configure` 只在本地保存 region 和 TiDB Cloud API key,不发送远端请求。第一个远端命令会验证对应权限;创建 Starter cluster 时由 TiDB Cloud 选择服务端默认 project。 -## 1. 查看 Organization Project - -```bash -bin/ti organization list-projects --output text -bin/ti organization list-projects --query 'projects[].{id:id,name:name,type:type}' -``` - -`organization` 当前是只读 control plane,可供人、脚本和 agent 检查 API key 能访问的项目。 - -## 2. 创建并管理 Starter Cluster +## 1. 创建并管理 Starter Cluster 为本次演示生成唯一名字: @@ -91,7 +82,7 @@ bin/ti db create-db-cluster-branch \ bin/ti db list-db-cluster-branches --db-cluster-id "$CLUSTER_ID" --output text ``` -## 3. 创建 SQL 用户并按角色执行 SQL +## 2. 创建 SQL 用户并按角色执行 SQL 创建或修复 ti 管理的三种稳定 SQL 用户。该操作可重入,不会在每次运行时创建新的一组: @@ -145,7 +136,7 @@ bin/ti db execute-sql-statement \ SQL 默认通过 HTTPS SQL API 执行,一次命令只执行一个 statement。`--transport mysql` 是显式的一次性连接模式,不是隐藏 fallback。 -## 4. 创建并管理 Filesystem +## 3. 创建并管理 Filesystem 创建一个由服务端分配稳定 ID 的资源。远端 inventory 是资源状态的权威来源,本地只保存按 ID 索引的访问凭证: @@ -169,7 +160,7 @@ bin/ti fs check-file-system \ 创建命令的 JSON 结果包含一次性的 `fs_token`。它是资源 owner credential,不能写入日志或公开传递。凭证存储在 `~/.ti/fs_credentials///credentials`,不写入主 `~/.ti/credentials`。 -## 5. 使用 Data Plane 操作文件 +## 4. 使用 Data Plane 操作文件 ```bash bin/ti fs create-directory \ @@ -205,7 +196,7 @@ bin/ti fs ls --file-system-id "$FILE_SYSTEM_ID" --path /demo --output text bin/ti fs cat --file-system-id "$FILE_SYSTEM_ID" --path /demo/from-data-plane.txt ``` -## 6. 挂载并验证双向可见性 +## 5. 挂载并验证双向可见性 ```bash export MOUNT_PATH="/tmp/ti-demo-${DEMO_ID}" @@ -240,7 +231,7 @@ FUSE mount 可以在卸载前 drain dirty state。WebDAV 通过正常的 file cl bin/ti fs drain-file-system --mount-path "$MOUNT_PATH" ``` -## 7. 使用 Filesystem Git Workspace +## 6. 使用 Filesystem Git Workspace 在挂载目录内执行快速 clone 和 hydrate: @@ -280,7 +271,7 @@ bin/ti fs-git remove-git-worktree \ `ti fs-git` 不替代 Git。它负责为 Filesystem mount 准备 clone、hydrate 和 worktree 工作流;普通提交和分支操作仍使用 `git`。 -## 8. 使用 Journal 记录 Agent 工作流 +## 7. 使用 Journal 记录 Agent 工作流 ```bash export JOURNAL_ID="jrn-demo-${DEMO_ID}" @@ -317,7 +308,7 @@ bin/ti fs-journal verify-journal \ Journal 是 append-only、可验证的 workflow ledger,不是普通文本日志文件。 -## 9. 使用 Vault 管理和委派 Secret +## 8. 使用 Vault 管理和委派 Secret ```bash printf 'demo-token\n' > /tmp/ti-demo-token.txt @@ -362,7 +353,7 @@ bin/ti fs-vault list-audit-events \ Vault mount 是只读 FUSE view,需要 delegated Vault token;Windows 不支持,macOS 需要 macFUSE。`run-with-secret` 可在不把值写进命令行的情况下将 secret 注入子进程。 -## 10. 清理 +## 9. 清理 FUSE mount 先 drain,再卸载。WebDAV mount 跳过 drain: diff --git a/docs/priciples.md b/docs/priciples.md index 8b78b52..8706dde 100644 --- a/docs/priciples.md +++ b/docs/priciples.md @@ -6,7 +6,6 @@ ti is currently in Preview. Its feature and command contracts can change before ## Product Scope -- `ti organization` reads TiDB Cloud organization and project context. - `ti db` manages TiDB Cloud Starter clusters and branches, prepares SQL users, formats connection strings, and executes one SQL statement per invocation. - `ti fs` manages TiDB Cloud Filesystem resources, files, layers, packs, and mounts. - `ti fs-git`, `ti fs-journal`, and `ti fs-vault` expose Filesystem-backed Git workspace, append-only journal, and secret-management workflows. @@ -23,7 +22,7 @@ The command tree has at most two command levels: ti [subcommand] ``` -`configure` and `update` are intentional top-level verb exceptions. Other top-level commands identify product domains: `organization`, `db`, `fs`, `fs-git`, `fs-journal`, and `fs-vault`. +`configure` and `update` are intentional top-level verb exceptions. Other top-level commands identify product domains: `db`, `fs`, `fs-git`, `fs-journal`, and `fs-vault`. - Commands and flags use complete, self-explanatory names. - Flags are long-only. Do not add one-letter flags. @@ -91,7 +90,7 @@ Environment credentials must not create or select a synthetic `[env]` profile. A TiDB Cloud control-plane requests use HTTP Digest authentication. API keys must not be used as SQL Basic Auth credentials or Filesystem data-plane credentials. -## Configure And Default Project +## Configure `ti configure` collects: @@ -99,17 +98,11 @@ TiDB Cloud control-plane requests use HTTP Digest authentication. API keys must - a TiDB Cloud public API key; - a TiDB Cloud private API key. -After validating the keys, configure lists accessible projects, requires exactly one project whose type is `tidbx_virtual`, and stores its ID as the profile's `project_id`. It commits the profile only after discovery succeeds. +Configure validates the local inputs and stores them atomically. It does not call TiDB Cloud, validate the keys remotely, discover projects, or persist a project ID. Authentication and authorization failures are reported by the first remote command that uses the keys. `ti configure --non-interactive` reads flags first, then `TI_REGION_CODE`, `TIDB_CLOUD_PUBLIC_KEY`, and `TIDB_CLOUD_PRIVATE_KEY`, and fails instead of prompting for missing input. Interactive configure must handle Ctrl+C and exit with code 130. -Starter cluster creation resolves its project in this order: - -1. Explicit non-empty `--project-id`. -2. The selected profile's discovered `project_id`. -3. Otherwise omit the project label and let TiDB Cloud select the account default. - -Other DB operations identify existing resources by cluster or branch ID. Filesystem provisioning does not use the DB `project_id`. +Starter cluster creation always omits project selection and lets TiDB Cloud select its server-side default project. Project-related fields and labels returned by TiDB Cloud remain unchanged in command output. Other DB operations identify existing resources by cluster or branch ID. ## Local State And Credentials @@ -128,7 +121,6 @@ Example: # ~/.ti/config [default] region_code = "aws-us-east-1" -project_id = "..." # ~/.ti/credentials [default] diff --git a/docs/spec/0029-local-file-system-mount-inventory.md b/docs/spec/0029-local-file-system-mount-inventory.md deleted file mode 100644 index cc608d4..0000000 --- a/docs/spec/0029-local-file-system-mount-inventory.md +++ /dev/null @@ -1,277 +0,0 @@ -# Local File System Mount Inventory - -## Goal - -Add a read-only command that reports where ordinary TiDB Cloud Filesystem resources are mounted on the current machine for the current operating-system user: - -```bash -ti fs list-local-file-system-mounts -ti fs list-local-file-system-mounts --file-system-id -``` - -The command must report both the local mount path and the remote path exposed through that mount. Its scope is deliberately local. It must not claim to list mounts created by another user, another `TI_HOME`, another machine, a container that does not share the same ti home, or an organization-wide backend inventory. - -## Product Decisions - -- The command name is `list-local-file-system-mounts`, not `list-file-system-mounts`, because ti has no server-side mount inventory API. -- The command lists ordinary `ti fs mount-file-system` mounts only. `ti fs-vault mount-vault` remains a separate security boundary and is not included. -- A file system may have multiple local mount paths, and every independently tracked mount is returned. -- `--file-system-id` is optional and filters the local result to one server-assigned file system ID. -- The command is read-only, does not accept `--dry-run`, and does not contact TiDB Cloud or Drive9. -- Listing local mounts does not require TiDB Cloud public/private keys, an FS token, or a network connection. -- The result is based on ti-owned locator state plus an operating-system mount check. A locator is not by itself proof that a mount is still active. -- The command never removes stale state. Cleanup remains an explicit unmount operation, including `ti fs unmount-file-system --mount-path --ignore-absent` when the mount is already gone. - -## User-facing Command - -List all ordinary ti filesystem mounts recorded under the current `TI_HOME`: - -```bash -ti fs list-local-file-system-mounts -``` - -Filter by file system ID: - -```bash -ti fs list-local-file-system-mounts --file-system-id tnt_abc123 -``` - -The global `--profile` flag follows a special local-inventory rule: when omitted, the command returns mounts from every profile represented in the current user's locator directory; when explicitly provided, it filters to that profile. This prevents the implicit `default` profile from hiding local mounts created through another profile while preserving an explicit profile filter for automation. - -The command supports the ordinary read-only output contract: - -```bash -ti fs list-local-file-system-mounts --output text -ti fs list-local-file-system-mounts --query 'mounts[?status == `mounted`].mount_path' -``` - -## Output Contract - -JSON output has one stable envelope: - -```json -{ - "mounts": [ - { - "file_system_id": "tnt_abc123", - "profile": "default", - "region_code": "aws-us-east-1", - "mount_path": "/home/user/workspace", - "remote_path": "/projects/demo", - "driver": "fuse", - "status": "mounted", - "foreground": false - } - ] -} -``` - -Fields: - -- `file_system_id`: the Drive9 tenant ID exposed by ti as the public filesystem identifier. -- `profile`: the ti profile namespace that created the isolated companion context. -- `region_code`: the canonical ti placement code. -- `mount_path`: the canonical absolute local path. -- `remote_path`: the normalized remote path mounted at `mount_path`. It is omitted only for a legacy locator that cannot be enriched safely. -- `driver`: the actual recorded driver, `fuse` or `webdav`. A legacy or in-progress record may report `unknown`; do not guess from the operating system. -- `status`: `mounted`, `stale`, or `unknown`. -- `foreground`: whether ti started the companion in foreground mode. - -An implementation may add an optional `pid` only when it comes from structured ti-owned process state. It must not scrape Drive9 human-readable stderr to obtain a PID, and callers must not depend on `pid` being present. - -Results are sorted first by `file_system_id`, then by canonical `mount_path`. An empty inventory succeeds with: - -```json -{"mounts": []} -``` - -Text output is a compact table containing at least file system ID, status, driver, local mount path, and remote path. It must not include API keys, FS tokens, companion home paths, state-file paths, log paths, or raw mount-helper output. - -## Mount Status Semantics - -Status is evaluated independently for each locator: - -- `mounted`: the platform mount inspection definitively reports that the canonical local path is an active mount point. -- `stale`: the locator is valid but platform inspection definitively reports that the path is not mounted. -- `unknown`: mount state cannot be determined safely because the platform does not expose a supported check, access is denied, or the locator represents a foreground mount that has not yet become observable. - -The command must not report `mounted` merely because a locator file exists or a PID appears alive. It must not treat a directory that merely exists as a mount. Platform checks must avoid cgo and use operating-system facilities or established dependencies already present in the repository: - -- Linux: inspect mount information such as `/proc/self/mountinfo` with correct path unescaping and exact mount-point matching. -- macOS: inspect the mounted-filesystem table through a supported Go/syscall path or a stable system command with bounded execution and structured parsing. -- Windows: use an available mount/volume check where it can distinguish an active mount; otherwise return `unknown` rather than a false positive. - -A failed inspection for one entry must not hide valid entries. The result may include non-secret warnings for unreadable or malformed locator files, but warnings must not expose file contents or companion credentials. - -## Locator Schema And Lifecycle - -Extend the ti-owned locator schema under `~/.ti/mounts/` from `ti.fs.mount-locator/v1` to a version that records the information needed for listing: - -```json -{ - "schema": "ti.fs.mount-locator/v2", - "profile": "default", - "file_system_id": "tnt_abc123", - "region_code": "aws-us-east-1", - "companion_home": "/home/user/.ti/drive9-home/...", - "mount_path": "/home/user/workspace", - "remote_path": "/projects/demo", - "driver": "fuse", - "foreground": false, - "kind": "fs" -} -``` - -`companion_home` remains internal routing state. It is required by drain and unmount but is never rendered by the list command. - -Background mount lifecycle: - -1. Resolve the file system ID, region, companion home, normalized remote path, and canonical local path. -2. Invoke the bundled `ti-drive9 mount` command. -3. After Drive9 confirms readiness and ti has determined the actual driver, atomically write the v2 locator. -4. If locator persistence fails, invoke the companion unmount command and return an error, preserving the existing all-or-nothing routing invariant. -5. A successful `drain-file-system` keeps the locator because the mount still exists. -6. A successful `unmount-file-system` removes the locator. - -Foreground mount lifecycle: - -1. Write a provisional v2 locator before entering the blocking companion command, after local validation succeeds. -2. Mark it as `foreground: true`; record an explicit requested driver or `unknown` when driver selection is automatic. -3. Keep the locator while the foreground process is running so another ti process can discover the mount. -4. Remove the locator when the foreground command exits, whether it exits normally, is cancelled, or fails startup. -5. Platform mount inspection, not the provisional record, determines whether the status is `mounted` or `unknown`. - -Locator writes and removal remain atomic and owner-only. Concurrent listing must see either the previous complete record or the new complete record, never a partially written JSON file. - -## Compatibility With Existing Locators - -Existing v1 locators must continue to support drain and unmount and must appear in the new list command where possible. The v1 field `file_system_name` is interpreted as the selected filesystem identity used by that release; after the remote-inventory migration, values that are valid Drive9 tenant IDs map to `file_system_id`. - -For a v1 locator: - -- Preserve profile, region, companion home, canonical mount path, and kind. -- Do not invent a remote path or driver that the locator did not record. -- Report missing recoverable fields as absent or `unknown`. -- Do not read or depend on private Drive9 source packages, fixtures, or undocumented on-disk process-state formats to enrich the result. -- Do not rewrite the locator merely because it was listed. The next successful mount at that path writes v2. -- If a legacy value cannot be represented as a valid file system ID, keep drain/unmount compatibility but return a non-secret warning instead of silently assigning it to another remote filesystem. - -This compatibility is a local-state schema migration only. It must not call remote list/get APIs and must not require that the remote filesystem still exists. - -## Implementation Design - -- `internal/fs/mountlocator` owns locator v1/v2 decoding, strict validation, atomic writes, canonical path identity, directory enumeration, deterministic sorting, and removal. -- `internal/fs` owns local inventory orchestration, optional file system/profile filtering, status evaluation, and public result models. -- Platform-specific files under `internal/fs` or a focused subpackage own mount-point inspection. Keep package names short and do not introduce cgo. -- `internal/cli` registers `ti fs list-local-file-system-mounts` as a read-only command and routes output through the existing JSON/text/query path. -- The existing mount, drain, and unmount handlers remain the only writers/removers of ordinary filesystem locators. -- Do not import any package from `ref/drive9` or make runtime/tests depend on `ref/`. - -The locator directory may contain malformed, unsupported, or unrelated files. Enumeration must consider only the expected `*.locator.json` files, reject symlinks and non-regular files, enforce bounded file sizes, and validate that the filename matches the hash of the canonical mount path before trusting a record. - -## API And Call Chain - -This command adds no TiDB Cloud or Drive9 backend API request. - -List flow: - -1. Resolve the current ti home without loading cloud credentials or migrating unrelated profile state. -2. Enumerate ti-owned ordinary filesystem locator files under `~/.ti/mounts/`. -3. Parse and validate supported locator schemas. -4. Apply an explicitly supplied profile filter and optional `--file-system-id` filter. -5. Inspect each local mount path through the platform-specific mount checker. -6. Sort the results deterministically. -7. Apply `--query`, then render JSON or text. - -Mount and unmount continue to call the public bundled Drive9 CLI. The list command must not invoke Drive9 merely to inspect local state. - -## Dependencies And Platform Impact - -- No new third-party dependency is expected. -- No cgo dependency is allowed. -- Linux and macOS must distinguish active and stale mounts. -- Windows must return honest `unknown` status where active mount detection cannot be implemented reliably. -- The command must work without network connectivity. -- The command must not require FUSE libraries merely to list WebDAV or stale mount records. - -## Tests - -Unit tests must cover: - -- v2 locator round-trip, modes, atomic replacement, and deterministic enumeration; -- backward-compatible v1 reads; -- multiple local paths for one file system; -- multiple file systems and profiles; -- omitted versus explicitly supplied `--profile` behavior; -- `--file-system-id` filtering and an empty match; -- stable sorting; -- canonical path and locator filename validation; -- malformed JSON, unsupported schema, oversized files, symlinks, and non-regular files; -- no token, API key, companion home, state path, or log path in rendered output or errors; -- platform checker results for mounted, stale, and unknown states; -- background mount writes v2 only after readiness; -- failed background mount leaves no locator; -- foreground mount creates a provisional locator and removes it on every exit path; -- drain retains the locator and unmount removes it; -- list never deletes a stale locator. - -Black-box `make e2e` coverage must use the fake companion to mount two local paths for one filesystem and one path for another filesystem, verify list/filter/query/text behavior, unmount one path, and verify only that path disappears. - -Focused `make live-e2e-fs` coverage must mount a temporary remote path, list local mounts, verify the returned file system ID plus exact local and remote paths, drain when supported, unmount, and verify the locator is absent. The test must clean up only its own mount and remote paths. - -## Documentation Updates - -When implemented: - -- Add the command to `README.md` and its folded all-commands inventory. -- Add one command-reference page with examples to the PingCAP Preview documentation. -- Explain that this is current-machine, current-user state and not a backend-wide answer to "where is this filesystem mounted?". -- Document stale status and explicit cleanup without advising users to delete locator files manually. - -## After This Spec - -A user can inspect all locally tracked mounts without remembering their mount paths: - -```bash -ti fs list-local-file-system-mounts --output text -``` - -An agent can locate active local paths for one filesystem: - -```bash -ti fs list-local-file-system-mounts \ - --file-system-id tnt_abc123 \ - --query 'mounts[?status == `mounted`].{local: mount_path, remote: remote_path}' -``` - -The result is sufficient for local drain/unmount orchestration but does not claim to discover mounts on other hosts. - -## Acceptance Criteria - -- `ti fs list-local-file-system-mounts` lists ordinary filesystem mounts recorded under the current user's ti home. -- Every new mount record includes the public file system ID, canonical local mount path, normalized remote path, region, profile, driver, and foreground mode. -- `--file-system-id` filters without requiring cloud credentials or a token. -- Omitted `--profile` lists all local profile namespaces; an explicitly supplied profile filters them. -- Active mounts are not inferred from locator existence alone. -- Stale records are clearly marked and are never removed by the list command. -- Existing v1 locators remain usable for drain/unmount and appear without fabricated fields. -- Foreground and background mount lifecycles cannot leave a locator after the owning mount command has definitively failed or exited. -- JSON, text, and `--query` behavior is deterministic and contains no secrets or internal companion paths. -- `make test`, `make e2e`, and focused live FS coverage pass. - -## Out Of Scope - -- Organization-wide or server-side mount inventory. -- Discovering mounts on another machine, container, user account, or ti home. -- A backend mount registration, heartbeat, lease, or last-seen API. -- Listing Vault mounts. -- Automatically pruning stale locator files. -- Terminating mount processes from the list command. -- Reading private Drive9 source packages or undocumented Drive9 state files. - -## Dependencies - -- `docs/spec/done/0015-drive9-companion-wrapper-for-tdc-fs.md` -- `docs/spec/done/0020-explicit-file-system-selection.md` -- `docs/spec/done/0027-ti-cli-rename-and-migration.md` -- `docs/spec/done/0028-remote-fs-resource-inventory.md` diff --git a/docs/spec/0031-homebrew-and-scoop-distribution.md b/docs/spec/0030-homebrew-and-scoop-distribution.md similarity index 100% rename from docs/spec/0031-homebrew-and-scoop-distribution.md rename to docs/spec/0030-homebrew-and-scoop-distribution.md diff --git a/docs/spec/0032-serverless-function-deployment.md b/docs/spec/0031-serverless-function-deployment.md similarity index 100% rename from docs/spec/0032-serverless-function-deployment.md rename to docs/spec/0031-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..641980f 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/0030-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 `0030-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/0030-remove-db-project-selection.md b/docs/spec/done/0029-remove-db-project-selection.md similarity index 67% rename from docs/spec/0030-remove-db-project-selection.md rename to docs/spec/done/0029-remove-db-project-selection.md index e2cd5d6..31e625d 100644 --- a/docs/spec/0030-remove-db-project-selection.md +++ b/docs/spec/done/0029-remove-db-project-selection.md @@ -1,10 +1,12 @@ -# Remove DB Project Selection +# Remove TiDB Cloud Project Selection And Inventory ## Goal -Remove client-side project selection from TiDB Cloud CLI configuration and every `ti db` workflow. TiDB Cloud Starter cluster creation must always omit project selection and let the TiDB Cloud service choose its server-side default project. +Remove client-side TiDB Cloud project selection and project inventory from TiDB Cloud CLI. TiDB Cloud Starter cluster creation must always omit project selection and let the TiDB Cloud service choose its server-side default project. -Project is fading out as a user-facing TiDB Cloud concept. `ti` must not discover a default project, persist a project ID, accept a project selector, or infer a project from local state. +Project is fading out as a user-facing TiDB Cloud concept. `ti` must not discover a default project, persist a project ID, accept a project selector, infer a project from local state, or expose a standalone project-listing command. + +This removal applies only to ti-owned command inputs, configuration, discovery, and inventory. It must not alter the shape or values of TiDB Cloud API responses. If a cluster response contains project-related fields or `labels["tidb.cloud/project"]`, ti returns them unchanged as opaque service-owned resource metadata. This is an intentional breaking change that supersedes the active behavior originally introduced by `docs/spec/done/0017-default-virtual-project-resolution.md`. The completed spec remains unchanged as a historical record. @@ -16,8 +18,9 @@ This is an intentional breaking change that supersedes the active behavior origi - `ti configure` becomes a local AWS CLI-style configuration operation. It validates local input and writes the selected profile without making a TiDB Cloud API request. - Invalid or unauthorized API keys are reported by the first remote command that uses the permission required by that command, not by `ti configure`. - A Starter create request omits project placement entirely. It must not send `project_id: ""`, `labels: {}`, or `labels: {"tidb.cloud/project": ""}`. -- TiDB Cloud remains free to return `labels["tidb.cloud/project"]` on cluster resources. ti preserves the API response and must not hide, rewrite, or interpret that server-selected label as local configuration. -- `ti organization list-projects` remains available as an independent organization inventory command. It is no longer part of configure or DB creation. +- TiDB Cloud remains free to return project-related fields and `labels["tidb.cloud/project"]` on cluster resources. ti preserves the API response and must not hide, rewrite, rename, filter, or interpret those values as local configuration. +- Remove `ti organization list-projects` and the now-empty `ti organization` top-level command without a compatibility alias or placeholder. +- Remove the project-list API client surface and `organization.project.read` permission from ti. Do not remove IAM SQL-user APIs or the IAM endpoint resolver because DB SQL-user workflows still depend on them. - Existing cluster, branch, IAM SQL-user, and SQL operations continue to identify resources through cluster and branch IDs. They must not acquire a project parameter. - Future DB product providers must not reintroduce a generic `--project-id` on `ti db` without a new approved product design. @@ -70,6 +73,15 @@ ti db create-db-cluster \ It fails as a normal unknown flag usage error with exit code `2`. ti must not silently ignore the supplied project ID because doing so would create the cluster in a different placement than the caller requested. +The following commands are also removed: + +```bash +ti organization +ti organization list-projects +``` + +They fail as unknown commands. Project inventory is no longer part of the TiDB Cloud CLI command surface. + ## Configure Contract `ti configure` collects only: @@ -147,7 +159,7 @@ No request other than Starter create currently sends a project selection. Preser - Connection-string formatting and SQL execution use cluster ID plus locally managed SQL credentials. - Product dispatch discovers the cluster service plan through cluster metadata and does not use a project ID. -Do not add a project filter to list pagination, dispatch discovery, Starter guardrails, SQL credential paths, operation logs, or telemetry. +Do not add a project filter to list pagination, dispatch discovery, Starter guardrails, SQL credential paths, operation logs, or telemetry. Do not remove or rewrite project-related fields received in cluster responses; response preservation is independent of project selection. ## Dry-run Behavior @@ -191,7 +203,7 @@ Migration rules: - Every DB command ignores the legacy value immediately after upgrade. - Loading a profile must not copy the legacy value into the runtime `config.Profile` used by DB services. -- Ordinary DB, organization, FS, update, and help commands do not rewrite the config merely to remove the value. +- Ordinary DB, FS, update, and help commands do not rewrite the config merely to remove the value. - The next successful `ti configure` for that profile removes its `project_id` while updating region and credentials. - Reconfiguring one profile must not remove or change values in another profile. - A legacy `project_id` with malformed or unexpected content must remain inert and must not block profile loading or a projectless DB request. @@ -209,6 +221,9 @@ In `internal/cli`: - stop reading `project-id` in `createClusterOptions`; - update help, usage, and command tests; - keep `db-cluster-type`, `db-cluster-name`, spending limit, wait, and dry-run behavior unchanged. +- remove registration of the `organization` parent and `list-projects` child commands; +- remove organization-only service construction, help, aliases, permissions, and command-path mappings; +- ensure `ti organization` and `ti organization list-projects` both fail through the ordinary unknown-command path. ### Configure @@ -242,6 +257,41 @@ In `internal/api/starter`: No project-specific helper should remain in the create call path merely to pass an empty value. +### Organization And IAM Project Inventory + +- delete `internal/organization` because it has no non-project use case; +- remove `Project`, `ListProjectsOptions`, `ListProjectsResponse`, project response wire types, and `ListProjects` from `internal/api/iam`; +- retain SQL-user request and response types and methods in `internal/api/iam`; +- retain IAM endpoint routing used by SQL-user operations; +- remove `authz.OrganizationProjectRead` and its command permission mapping; +- remove the focused `make live-e2e-organization` target and organization live tests; +- remove organization from issue-template command-family choices when no other organization command remains; +- remove installer next steps that recommend project listing. + +Do not generalize this removal into deleting all uses of the word `project`. Vercel project IDs, source-code projects, and other unrelated provider concepts are outside this TiDB Cloud Project boundary. + +### API Response Fidelity + +Public TiDB Cloud response models remain faithful to the service contract: + +- preserve the generic cluster `labels` and `annotations` maps; +- preserve any project-related field that is part of an official cluster or future product response; +- do not redact `labels["tidb.cloud/project"]` from create, list, describe, update, wait, dry-run discovery, or error-context responses when that value came from TiDB Cloud; +- do not synthesize project fields when the service omitted them; +- do not copy a returned project value into local configuration or use it in a later request. + +Removing `ProjectID` and `ProjectType` from ti's configure result and removing the `--project-id` input do not authorize removing similarly named fields from an API resource response. Configure output is a ti-owned result, and `--project-id` is a ti-owned request selector; neither is an upstream API response field. + +The ownership boundary is: + +| Surface | Treatment | +| --- | --- | +| `ti configure` result fields `project_id` and `project_type` | Remove because ti synthesizes this local command result. | +| `ti db create-db-cluster --project-id` | Remove because it is a ti-owned request selector. | +| DB create project resolver and outgoing project label | Remove because ti must defer placement to the service. | +| Project-related fields returned by a TiDB Cloud resource API | Preserve exactly as returned. | +| `labels["tidb.cloud/project"]` returned on a cluster | Preserve inside the unmodified labels map. | + ## Error Behavior After this spec: @@ -251,6 +301,7 @@ After this spec: - `ti db create-db-cluster --project-id ...` fails with Cobra's unknown-flag usage error. - The first remote command reports authentication or authorization errors using that command's declared permission. - A server-side create rejection is returned unchanged through the existing API error mapping. +- `ti organization` and `ti organization list-projects` return the normal unknown-command usage error because those commands no longer exist. Do not add a warning merely because TiDB Cloud assigned a project label in the response. That is expected server behavior. @@ -275,19 +326,16 @@ ti db create-db-cluster -> ti validates Starter metadata and optionally waits for ACTIVE ``` -Organization project listing remains explicit: - -```text -ti organization list-projects -> GET /v1beta1/projects -``` +No runtime path calls `GET /v1beta1/projects`. ## Dependencies And Platform Impact - No new Go module is required. - No cgo dependency is introduced. - Configure becomes faster and works offline after the required local inputs are available. +- The CLI no longer declares or exercises `organization.project.read`. - The change is identical on macOS, Linux, and Windows. -- This is a CLI and configure-output breaking change because `--project-id`, `project_id`, `project_type`, and the saved default project are removed. +- This is a CLI and configure-output breaking change because `ti organization`, `ti organization list-projects`, `--project-id`, `project_id`, `project_type`, and the saved default project are removed. ## Tests @@ -303,7 +351,9 @@ Unit tests must cover: - dry-run output omits project and labels fields; - API response project labels remain present in rendered cluster output; - `--project-id` is absent from help and rejected as an unknown flag; -- organization project listing remains functional and retains its own permission mapping. +- the organization parent and project-list subcommand are absent from help and rejected as unknown commands; +- the IAM client retains SQL-user behavior after its project-listing types and methods are removed; +- cluster API response fixtures containing project-related fields or `labels["tidb.cloud/project"]` render those values unchanged. Black-box `make e2e` must cover: @@ -311,9 +361,9 @@ Black-box `make e2e` must cover: - config and configure output with no project fields; - a legacy config containing `project_id` followed by create dry-run and normal fake-API create, proving the request omits labels; - explicit `--project-id` rejection; -- the unchanged `ti organization list-projects` command through its dedicated fake IAM server. +- rejection of the removed `ti organization` and `ti organization list-projects` commands. -`make live-e2e-configure` must verify local persistence without requiring project discovery. `make live-e2e-db` must create a real Starter cluster without an explicit or configured project ID, wait for `ACTIVE`, preserve the non-empty server-selected project label in the returned resource, and complete the existing branch, SQL, update, and delete lifecycle. The live profile loader must not run configure merely because `project_id` is absent. +`make live-e2e-configure` must verify local persistence without requiring project discovery. Remove `make live-e2e-organization`. `make live-e2e-db` must create a real Starter cluster without an explicit or configured project ID, wait for `ACTIVE`, preserve any server-selected project metadata in the returned resource, and complete the existing branch, SQL, update, and delete lifecycle. The live profile loader must not run configure merely because `project_id` is absent. ## Documentation Updates @@ -322,7 +372,9 @@ When implemented, update: - `docs/priciples.md` as the product source of truth; - `AGENTS.md` current behavior, config examples, command examples, and live-e2e requirements; - `README.md` configure and Starter creation workflows; -- current PingCAP Preview documentation for configure, credentials, Starter DB, create command reference, organization concepts, troubleshooting, and examples; +- current PingCAP Preview documentation for configure, credentials, Starter DB, create command reference, troubleshooting, and examples; +- remove the organization command reference page and its TOC entries rather than leaving a page for a command that no longer exists; +- remove `ti organization list-projects` from installer next steps, command inventories, examples, and presentation material; - release notes to identify removal of `--project-id` and configure result fields as a breaking change. Do not rewrite completed specs or archived release notes to pretend the previous default-project behavior never existed. @@ -339,24 +391,19 @@ ti db create-db-cluster \ --wait ``` -Users who need project inventory can still request it explicitly: - -```bash -ti organization list-projects --output text -``` - -That inventory has no effect on later DB commands. +There is no TiDB Cloud project inventory command in ti after this spec. Users do not need to discover or select a project before creating a Starter cluster. ## Acceptance Criteria - No public `ti db` command accepts a project ID. - No `ti db` request sends a project ID or project-selection label. - Starter create omits the project label rather than sending an empty value. -- TiDB Cloud can select the project and ti preserves the returned project label as resource metadata. +- TiDB Cloud can select the project and ti preserves every returned project-related field or label as resource metadata. - `ti configure` performs no network request and stores no project ID. - Existing profile `project_id` values are ignored immediately and removed only when that profile is reconfigured. - Configure output contains no `project_id` or `project_type`. -- `ti organization list-projects` remains available but is not called implicitly. +- `ti organization` and `ti organization list-projects` are absent from help and rejected as unknown commands. +- No runtime code calls `GET /v1beta1/projects` or declares `organization.project.read`. - Dry-run, unit, black-box e2e, configure live-e2e, and DB live-e2e coverage prove the projectless request path. - README and current product documentation match the implemented behavior. @@ -364,10 +411,10 @@ That inventory has no effect on later DB commands. - Choosing or changing the TiDB Cloud service-side default project. - Moving an existing cluster between projects. -- Hiding project labels returned by TiDB Cloud. -- Removing the explicit `ti organization list-projects` command. +- Hiding, renaming, filtering, or otherwise changing project fields and labels returned by TiDB Cloud. - Modifying historical completed specs or old release notes. - Designing project behavior for unimplemented Essential, Premium, or Dedicated providers. +- Removing unrelated concepts such as Vercel project IDs. ## Dependencies diff --git a/e2e/cli_test.go b/e2e/cli_test.go index 1d27804..52e978f 100644 --- a/e2e/cli_test.go +++ b/e2e/cli_test.go @@ -82,8 +82,13 @@ func TestHelpAndVersion(t *testing.T) { createDBCluster.wantStdoutContains("--db-cluster-name (required)") createDBCluster.wantStdoutContains("--db-cluster-type (required)") createDBCluster.wantStdoutNotContains("[--db-cluster-type ]") - createDBCluster.wantStdoutContains("--project-id ") - createDBCluster.wantStdoutNotContains("--project-id (required)") + createDBCluster.wantStdoutNotContains("--project-id") + removedProjectFlag := runTI(t, bin, "db", "create-db-cluster", "--db-cluster-type", "starter", "--db-cluster-name", "demo", "--project-id", "project-1", "--dry-run") + removedProjectFlag.wantExitCode(2) + removedProjectFlag.wantStderrContains("unknown flag: --project-id") + removedOrganization := runTI(t, bin, "organization", "list-projects") + removedOrganization.wantExitCode(2) + removedOrganization.wantStderrContains(`unknown command "organization"`) configure := runTI(t, bin, "configure", "help") configure.wantExitCode(0) @@ -304,7 +309,7 @@ func TestTelemetryUsesFakeIngestionServer(t *testing.T) { "TI_REGION_CODE=aws-us-east-1", "TIDB_CLOUD_PUBLIC_KEY=must-not-appear-public-key", "TIDB_CLOUD_PRIVATE_KEY=must-not-appear-private-key", - }, "db", "create-db-cluster", "--db-cluster-type", "starter", "--db-cluster-name", secretName, "--project-id", "must-not-appear-project", "--dry-run") + }, "db", "create-db-cluster", "--db-cluster-type", "starter", "--db-cluster-name", secretName, "--dry-run") result.wantExitCode(0) result.wantStdoutContains(`"dry_run": true`) @@ -314,7 +319,7 @@ func TestTelemetryUsesFakeIngestionServer(t *testing.T) { case <-time.After(2 * time.Second): t.Fatal("ti did not send telemetry to the fake ingestion server") } - for _, prohibited := range []string{secretName, "must-not-appear-public-key", "must-not-appear-private-key", "must-not-appear-project"} { + for _, prohibited := range []string{secretName, "must-not-appear-public-key", "must-not-appear-private-key"} { if strings.Contains(string(body), prohibited) { t.Fatalf("telemetry payload leaked %q: %s", prohibited, body) } @@ -322,7 +327,7 @@ func TestTelemetryUsesFakeIngestionServer(t *testing.T) { if !strings.Contains(string(body), `"schema_version":2`) || !strings.Contains(string(body), `"command_path":"ti db create-db-cluster"`) || !strings.Contains(string(body), `"db-cluster-name"`) || - !strings.Contains(string(body), `"project-id"`) || + !strings.Contains(string(body), `"db-cluster-type"`) || !strings.Contains(string(body), `"tag":"e2b-preview"`) || !strings.Contains(string(body), `"extra":{"campaign":"launch","runtime":"e2b"}`) { t.Fatalf("unexpected telemetry payload: %s", body) @@ -482,11 +487,11 @@ func TestCreateDBClusterUsesServerDefaultProjectThroughBinary(t *testing.T) { return } created = true - _, _ = w.Write([]byte(`{"clusterId":"starter-1","displayName":"server-default-project","servicePlan":"Starter","state":"CREATING"}`)) + _, _ = w.Write([]byte(`{"clusterId":"starter-1","displayName":"server-default-project","servicePlan":"Starter","state":"CREATING","labels":{"tidb.cloud/project":"server-selected","custom":"preserved"}}`)) })) defer server.Close() - writeE2EFile(t, filepath.Join(home, ".ti", "config"), "[default]\nregion_code = 'aws-us-east-1'\n", 0o600) + writeE2EFile(t, filepath.Join(home, ".ti", "config"), "[default]\nregion_code = 'aws-us-east-1'\nproject_id = 'legacy-must-not-be-sent'\n", 0o600) writeE2EFile(t, filepath.Join(home, ".ti", "credentials"), "[default]\ntidb_cloud_public_key = 'public'\ntidb_cloud_private_key = 'private'\n", 0o600) env := []string{ "HOME=" + home, @@ -497,6 +502,8 @@ func TestCreateDBClusterUsesServerDefaultProjectThroughBinary(t *testing.T) { result := runTIWithInput(t, bin, "", env, "db", "create-db-cluster", "--db-cluster-type", "starter", "--db-cluster-name", "server-default-project") result.wantExitCode(0) result.wantStdoutContains(`"id": "starter-1"`) + result.wantStdoutContains(`"tidb.cloud/project": "server-selected"`) + result.wantStdoutContains(`"custom": "preserved"`) if !created { t.Fatal("create request was not sent") } @@ -515,7 +522,6 @@ func createClusterDryRunArgs() []string { "db", "create-db-cluster", "--db-cluster-type", "starter", "--db-cluster-name", "demo-cluster", - "--project-id", "project-1", "--wait", "--dry-run", } @@ -543,11 +549,11 @@ func artifactNameForRuntime(t *testing.T) string { func TestConfigureWritesLocalProfile(t *testing.T) { bin := tiBinary(t) home := t.TempDir() - env := append([]string{"HOME=" + home}, configureIAMEnv(t)...) + env := []string{"HOME=" + home} result := runTIWithInput(t, bin, "aws-us-east-1\npublic-key\nprivate-key\n", env, "configure", "--profile", "stage") result.wantExitCode(0) - result.wantStdoutContains(`"project_id": "virtual-e2e"`) + result.wantStdoutNotContains("project") result.wantStdoutNotContains("private-key") configBytes, err := os.ReadFile(filepath.Join(home, ".ti", "config")) @@ -563,7 +569,7 @@ func TestConfigureWritesLocalProfile(t *testing.T) { if !strings.Contains(string(configBytes), `[stage]`) || strings.Contains(string(configBytes), `cloud_provider`) || !strings.Contains(string(configBytes), `region_code = 'aws-us-east-1'`) || - !strings.Contains(string(configBytes), `project_id = 'virtual-e2e'`) { + strings.Contains(string(configBytes), `project_id`) { t.Fatalf("config did not contain expected stage profile:\n%s", string(configBytes)) } if !strings.Contains(string(credentialsBytes), `tidb_cloud_public_key = 'public-key'`) || @@ -595,10 +601,9 @@ func TestConfigureNonInteractiveFromEnvironment(t *testing.T) { "TIDB_CLOUD_PUBLIC_KEY=ci-public", "TIDB_CLOUD_PRIVATE_KEY=ci-private", } - env = append(env, configureIAMEnv(t)...) result := runTIWithInput(t, bin, "", env, "configure", "--profile", "ci", "--non-interactive") result.wantExitCode(0) - result.wantStdoutContains(`"project_id": "virtual-e2e"`) + result.wantStdoutNotContains("project") result.wantStdoutNotContains("ci-private") configBytes, err := os.ReadFile(filepath.Join(home, ".ti", "config")) @@ -611,7 +616,7 @@ func TestConfigureNonInteractiveFromEnvironment(t *testing.T) { } if !strings.Contains(string(configBytes), `[ci]`) || !strings.Contains(string(configBytes), `region_code = 'aws-us-east-1'`) || - !strings.Contains(string(configBytes), `project_id = 'virtual-e2e'`) || + strings.Contains(string(configBytes), `project_id`) || strings.Contains(string(configBytes), `cloud_provider`) { t.Fatalf("config did not contain expected ci profile:\n%s", string(configBytes)) } @@ -647,7 +652,6 @@ func TestFSRemoteInventoryAndIDCredentialSelectionAcrossCommandFamilies(t *testi "TI_ALLOW_TEST_ENDPOINTS=1", "TI_TEST_FS_MANIFEST_URL=" + manifestServer.URL, } - baseEnv = append(baseEnv, configureIAMEnv(t)...) configured := runTIWithInput(t, bin, "", append(baseEnv, "TI_REGION_CODE=aws-us-east-1", "TIDB_CLOUD_PUBLIC_KEY=e2e-public", @@ -1052,7 +1056,6 @@ func TestOperationLogWritesSafeJSONL(t *testing.T) { "TIDB_CLOUD_PUBLIC_KEY=ci-public-secret", "TIDB_CLOUD_PRIVATE_KEY=ci-private-secret", } - env = append(env, configureIAMEnv(t)...) result := runTIWithInput(t, bin, "", env, "configure", "--profile", "ci", "--non-interactive") result.wantExitCode(0) @@ -1079,22 +1082,6 @@ func TestOperationLogWritesSafeJSONL(t *testing.T) { } } -func configureIAMEnv(t *testing.T) []string { - t.Helper() - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/v1beta1/projects" { - http.NotFound(w, r) - return - } - _, _ = w.Write([]byte(`{"projects":[{"id":"virtual-e2e","type":"tidbx_virtual"}]}`)) - })) - t.Cleanup(server.Close) - return []string{ - "TI_ALLOW_TEST_ENDPOINTS=1", - "TI_TEST_IAM_BASE_URL=" + server.URL, - } -} - func TestOperationLogCanBeDisabled(t *testing.T) { bin := tiBinary(t) home := t.TempDir() diff --git a/e2e/live_test.go b/e2e/live_test.go index b0852a8..534546a 100644 --- a/e2e/live_test.go +++ b/e2e/live_test.go @@ -52,6 +52,14 @@ func TestLiveProfileConfigured(t *testing.T) { if profile.CloudProvider == "" || profile.RegionCode == "" { t.Fatalf("live e2e profile %q is incomplete", profile.Name) } + configured := runTIWithInput(t, bin, "", []string{ + "TI_REGION_CODE=" + profile.PlacementRegionCode, + "TIDB_CLOUD_PUBLIC_KEY=" + profile.TiDBCloudPublicKey, + "TIDB_CLOUD_PRIVATE_KEY=" + profile.TiDBCloudPrivateKey, + }, "configure", "--profile", profile.Name, "--non-interactive") + configured.wantExitCode(0) + configured.wantStdoutContains(`"region_code": "` + profile.PlacementRegionCode + `"`) + configured.wantStdoutNotContains("project") } func TestLiveDBAPIReadOnlyProbes(t *testing.T) { @@ -69,20 +77,6 @@ func TestLiveDBAPIReadOnlyProbes(t *testing.T) { liveGETJSON(t, starter, "/v1beta1/regions:listCloudProviders") } -func TestLiveOrganizationAPIReadOnlyProbes(t *testing.T) { - requireLive(t) - - profile := liveProfile(t) - resolver := endpoints.NewResolver() - - iamEndpoint, err := resolver.ResolveIAM() - if err != nil { - t.Fatalf("resolve IAM endpoint: %v", err) - } - iam := liveDigestClient(t, profile, iamEndpoint, authz.OrganizationProjectRead) - liveGETJSON(t, iam, "/v1beta1/projects") -} - func TestLiveFSRemoteInventoryLifecycle(t *testing.T) { requireLive(t) @@ -141,43 +135,6 @@ func TestLiveCLICommandSurface(t *testing.T) { checkUpdateHelp.wantStdoutNotContains("--yes") } -func TestLiveOrganizationCommandSurface(t *testing.T) { - requireLive(t) - bin := tiBinary(t) - profileName := liveProfileName(t) - testLiveHelpCommands(t, bin, [][]string{{"organization", "help"}}) - testLiveReadOnlyDryRunRejections(t, bin, profileName, [][]string{{"organization", "list-projects"}}) - - projects := runTI(t, bin, "--profile", profileName, "organization", "list-projects", "--page-size", "1") - projects.wantExitCode(0) - projects.wantStdoutContains(`"projects"`) - var projectList struct { - Projects []struct { - ID string `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - } `json:"projects"` - NextPageToken string `json:"next_page_token"` - } - if err := json.Unmarshal([]byte(projects.stdout), &projectList); err != nil { - t.Fatalf("decode organization list-projects output: %v\n%s", err, projects.stdout) - } - if len(projectList.Projects) == 0 || projectList.Projects[0].ID == "" || projectList.Projects[0].Type == "" { - t.Fatalf("expected live profile %q to see at least one project with an id and type:\n%s", profileName, projects.stdout) - } - - query := runTI(t, bin, "--profile", profileName, "organization", "list-projects", "--page-size", "1", "--query", "projects[0].id") - query.wantExitCode(0) - query.wantStdoutContains(projectList.Projects[0].ID) - - text := runTI(t, bin, "--profile", profileName, "organization", "list-projects", "--page-size", "1", "--output", "text") - text.wantExitCode(0) - text.wantStdoutContains("ID") - text.wantStdoutContains("TYPE") - text.wantStdoutContains(projectList.Projects[0].ID) - text.wantStdoutContains(projectList.Projects[0].Type) -} - func TestLiveDBCommandSurface(t *testing.T) { requireLive(t) bin := tiBinary(t) @@ -1511,8 +1468,8 @@ func TestLiveDBClusterLifecycle(t *testing.T) { if described.ClusterPlan != "" && described.ClusterPlan != "STARTER" { t.Fatalf("expected STARTER cluster, got %#v", described) } - if strings.TrimSpace(described.Labels["tidb.cloud/project"]) == "" { - t.Fatalf("server-selected project label is empty: %#v", described) + if project := strings.TrimSpace(created.Labels["tidb.cloud/project"]); project != "" && described.Labels["tidb.cloud/project"] != project { + t.Fatalf("server project metadata changed between create and describe: created=%#v described=%#v", created.Labels, described.Labels) } testLiveMutatingDryRuns(t, bin, profileName, [][]string{ {"db", "update-db-cluster", "--db-cluster-id", clusterID, "--db-cluster-name", updatedName}, @@ -2191,24 +2148,6 @@ func liveProfile(t *testing.T) *config.Profile { if err != nil { t.Fatalf("load live e2e profile %q: %v\nconfigure it with: bin/ti configure --profile %s", profileName, err, profileName) } - if profile.ProjectID != "" { - return profile - } - - configured := runTIWithInput(t, tiBinary(t), "", []string{ - "TI_REGION_CODE=" + profile.PlacementRegionCode, - "TIDB_CLOUD_PUBLIC_KEY=" + profile.TiDBCloudPublicKey, - "TIDB_CLOUD_PRIVATE_KEY=" + profile.TiDBCloudPrivateKey, - }, "configure", "--profile", profileName, "--non-interactive") - configured.wantExitCode(0) - configured.wantStdoutContains(`"project_type": "tidbx_virtual"`) - profile, err = load() - if err != nil { - t.Fatalf("reload live e2e profile %q after configure: %v", profileName, err) - } - if profile.ProjectID == "" { - t.Fatalf("live e2e profile %q has no project_id after configure", profileName) - } return profile } diff --git a/internal/api/iam/client.go b/internal/api/iam/client.go index 3822a47..40bb0c4 100644 --- a/internal/api/iam/client.go +++ b/internal/api/iam/client.go @@ -17,37 +17,16 @@ func New(client *api.Client) *Client { return &Client{api: client} } -type ListProjectsOptions struct { - PageSize int32 - PageToken string -} - type ListSQLUsersOptions struct { PageSize int32 PageToken string } -type ListProjectsResponse struct { - Projects []Project `json:"projects"` - NextPageToken string `json:"next_page_token,omitempty"` -} - type ListSQLUsersResponse struct { SQLUsers []SQLUser `json:"sql_users"` NextPageToken string `json:"next_page_token,omitempty"` } -type Project struct { - ID string `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - OrgID string `json:"org_id"` - ClusterCount int32 `json:"cluster_count"` - UserCount int32 `json:"user_count"` - CreateTimestamp string `json:"create_timestamp"` - AWSCMEKEnabled bool `json:"aws_cmek_enabled"` -} - type SQLUser struct { UserName string `json:"username"` AuthMethod string `json:"auth_method,omitempty"` @@ -70,30 +49,6 @@ type UpdateSQLUserRequest struct { CustomRoles []string } -func (c *Client) ListProjects(ctx context.Context, opts ListProjectsOptions) (ListProjectsResponse, error) { - requestPath := "/v1beta1/projects" - query := url.Values{} - if opts.PageToken != "" { - query.Set("pageToken", opts.PageToken) - } - if opts.PageSize > 0 { - query.Set("pageSize", strconv.FormatInt(int64(opts.PageSize), 10)) - } - if encoded := query.Encode(); encoded != "" { - requestPath += "?" + encoded - } - - req, err := c.api.NewRequest(ctx, http.MethodGet, requestPath, nil) - if err != nil { - return ListProjectsResponse{}, err - } - var response listProjectsWireResponse - if err := c.api.DoJSON(req, &response); err != nil { - return ListProjectsResponse{}, err - } - return response.toResponse(), nil -} - func (c *Client) ListSQLUsers(ctx context.Context, clusterID string, opts ListSQLUsersOptions) (ListSQLUsersResponse, error) { requestPath := "/v1beta1/clusters/" + url.PathEscape(clusterID) + "/sqlUsers" query := url.Values{} @@ -175,27 +130,6 @@ func (c *Client) DeleteSQLUser(ctx context.Context, clusterID, userName string) return c.api.DoJSON(req, nil) } -type listProjectsWireResponse struct { - Projects []Project `json:"projects"` - NextPageToken string `json:"nextPageToken"` - NextPageTokenAlt string `json:"next_page_token"` -} - -func (r listProjectsWireResponse) toResponse() ListProjectsResponse { - nextPageToken := r.NextPageToken - if nextPageToken == "" { - nextPageToken = r.NextPageTokenAlt - } - projects := r.Projects - if projects == nil { - projects = []Project{} - } - return ListProjectsResponse{ - Projects: projects, - NextPageToken: nextPageToken, - } -} - type listSQLUsersWireResponse struct { SQLUsers []sqlUserWire `json:"sqlUsers"` SQLUsersAlt []sqlUserWire `json:"sql_users"` diff --git a/internal/api/iam/client_test.go b/internal/api/iam/client_test.go index 134a126..b62a421 100644 --- a/internal/api/iam/client_test.go +++ b/internal/api/iam/client_test.go @@ -9,110 +9,9 @@ import ( "github.com/tidbcloud/ti-cli/internal/api" "github.com/tidbcloud/ti-cli/internal/api/endpoints" - "github.com/tidbcloud/ti-cli/internal/apperr" "github.com/tidbcloud/ti-cli/internal/authz" ) -func TestListProjects(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/v1beta1/projects" { - t.Fatalf("unexpected path %s", r.URL.Path) - } - if got := r.URL.Query().Get("pageSize"); got != "2" { - t.Fatalf("unexpected pageSize %q", got) - } - if got := r.URL.Query().Get("pageToken"); got != "token-1" { - t.Fatalf("unexpected pageToken %q", got) - } - _, _ = w.Write([]byte(`{ - "projects": [ - { - "id": "project-1", - "name": "Project 1", - "type": "tidbx_virtual", - "org_id": "org-1", - "cluster_count": 3, - "user_count": 4, - "create_timestamp": "1688460316", - "aws_cmek_enabled": true - } - ], - "nextPageToken": "token-2" - }`)) - })) - defer server.Close() - - client := New(newTestAPIClient(t, server.URL)) - response, err := client.ListProjects(context.Background(), ListProjectsOptions{ - PageSize: 2, - PageToken: "token-1", - }) - if err != nil { - t.Fatalf("ListProjects failed: %v", err) - } - if response.NextPageToken != "token-2" { - t.Fatalf("unexpected next page token %q", response.NextPageToken) - } - if len(response.Projects) != 1 || response.Projects[0].ID != "project-1" { - t.Fatalf("unexpected projects: %#v", response.Projects) - } - if response.Projects[0].Type != "tidbx_virtual" { - t.Fatalf("project type = %q, want tidbx_virtual", response.Projects[0].Type) - } - if !response.Projects[0].AWSCMEKEnabled { - t.Fatalf("expected aws_cmek_enabled to be true") - } -} - -func TestListProjectsAcceptsSnakeCaseNextPageToken(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte(`{"projects":[],"next_page_token":"snake-token"}`)) - })) - defer server.Close() - - client := New(newTestAPIClient(t, server.URL)) - response, err := client.ListProjects(context.Background(), ListProjectsOptions{}) - if err != nil { - t.Fatalf("ListProjects failed: %v", err) - } - if response.NextPageToken != "snake-token" { - t.Fatalf("unexpected next page token %q", response.NextPageToken) - } - if response.Projects == nil { - t.Fatal("expected projects to be an empty slice, not nil") - } -} - -func TestListProjectsMapsAuthAndPermissionErrors(t *testing.T) { - tests := []struct { - name string - statusCode int - exitCode int - }{ - {name: "unauthenticated", statusCode: http.StatusUnauthorized, exitCode: 3}, - {name: "permission denied", statusCode: http.StatusForbidden, exitCode: 4}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(tt.statusCode) - _, _ = w.Write([]byte(`{"message":"denied"}`)) - })) - defer server.Close() - - client := New(newTestAPIClient(t, server.URL)) - _, err := client.ListProjects(context.Background(), ListProjectsOptions{}) - if err == nil { - t.Fatal("expected ListProjects to fail") - } - if got := apperr.ExitCodeFor(err); got != tt.exitCode { - t.Fatalf("expected exit code %d, got %d", tt.exitCode, got) - } - }) - } -} - func TestSQLUserLifecycleRequests(t *testing.T) { requests := make([]string, 0, 4) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -210,7 +109,7 @@ func newTestAPIClient(t *testing.T, baseURL string) *api.Client { BaseURL: baseURL, }, ProfileName: "test", - Permission: authz.OrganizationProjectRead, + Permission: authz.StarterSQLUserRead, HTTPClient: http.DefaultClient, }) if err != nil { diff --git a/internal/api/starter/client.go b/internal/api/starter/client.go index 20057af..a39e48e 100644 --- a/internal/api/starter/client.go +++ b/internal/api/starter/client.go @@ -10,8 +10,6 @@ import ( "github.com/tidbcloud/ti-cli/internal/api" ) -const ProjectLabelKey = "tidb.cloud/project" - type Client struct { api *api.Client } @@ -41,7 +39,6 @@ type GetClusterOptions struct { type CreateClusterRequest struct { DisplayName string RegionName string - ProjectID string SpendingLimit *SpendingLimit } @@ -141,18 +138,11 @@ func (c *Client) ListClusters(ctx context.Context, opts ListClustersOptions) (Li } func (c *Client) CreateCluster(ctx context.Context, input CreateClusterRequest) (Cluster, error) { - var labels map[string]string - if projectID := strings.TrimSpace(input.ProjectID); projectID != "" { - labels = map[string]string{ - ProjectLabelKey: projectID, - } - } body := createClusterWireRequest{ DisplayName: input.DisplayName, Region: ®ionWire{ Name: input.RegionName, }, - Labels: labels, SpendingLimit: input.SpendingLimit, } req, err := c.api.NewRequest(ctx, http.MethodPost, "/v1beta1/clusters", body) @@ -373,10 +363,9 @@ type authorizedNetworkWire struct { } type createClusterWireRequest struct { - DisplayName string `json:"displayName"` - Region *regionWire `json:"region,omitempty"` - Labels map[string]string `json:"labels,omitempty"` - SpendingLimit *SpendingLimit `json:"spendingLimit,omitempty"` + DisplayName string `json:"displayName"` + Region *regionWire `json:"region,omitempty"` + SpendingLimit *SpendingLimit `json:"spendingLimit,omitempty"` } type updateClusterWireRequest struct { diff --git a/internal/api/starter/client_test.go b/internal/api/starter/client_test.go index e5b72e6..edeee70 100644 --- a/internal/api/starter/client_test.go +++ b/internal/api/starter/client_test.go @@ -94,15 +94,14 @@ func TestCreateCluster(t *testing.T) { if body["displayName"] != "demo-cluster" { t.Fatalf("unexpected body: %#v", body) } - labels := body["labels"].(map[string]any) - if labels[ProjectLabelKey] != "project-1" { - t.Fatalf("unexpected labels: %#v", labels) + if _, ok := body["labels"]; ok { + t.Fatalf("create request must omit labels: %#v", body) } region := body["region"].(map[string]any) if region["name"] != "regions/aws-us-east-1" { t.Fatalf("unexpected region: %#v", region) } - _, _ = w.Write([]byte(`{"clusterId":"cluster-1","displayName":"demo-cluster","clusterPlan":"STARTER"}`)) + _, _ = w.Write([]byte(`{"clusterId":"cluster-1","displayName":"demo-cluster","clusterPlan":"STARTER","labels":{"tidb.cloud/project":"server-project","custom":"value"}}`)) })) defer server.Close() @@ -110,7 +109,6 @@ func TestCreateCluster(t *testing.T) { cluster, err := client.CreateCluster(context.Background(), CreateClusterRequest{ DisplayName: "demo-cluster", RegionName: "regions/aws-us-east-1", - ProjectID: "project-1", }) if err != nil { t.Fatalf("CreateCluster failed: %v", err) @@ -118,6 +116,9 @@ func TestCreateCluster(t *testing.T) { if cluster.ID != "cluster-1" || cluster.DisplayName != "demo-cluster" { t.Fatalf("unexpected cluster: %#v", cluster) } + if cluster.Labels["tidb.cloud/project"] != "server-project" || cluster.Labels["custom"] != "value" { + t.Fatalf("response labels were not preserved: %#v", cluster.Labels) + } } func TestCreateClusterOmitsProjectLabelWhenUnset(t *testing.T) { diff --git a/internal/authz/authz.go b/internal/authz/authz.go index 6d6f42b..85b0f53 100644 --- a/internal/authz/authz.go +++ b/internal/authz/authz.go @@ -10,43 +10,41 @@ import ( type Permission string const ( - OrganizationProjectRead Permission = "organization.project.read" - DBClusterDiscover Permission = "db.cluster.discover" - StarterClusterRead Permission = "starter.cluster.read" - StarterClusterCreate Permission = "starter.cluster.create" - StarterClusterUpdate Permission = "starter.cluster.update" - StarterClusterDelete Permission = "starter.cluster.delete" - StarterBranchRead Permission = "starter.branch.read" - StarterBranchCreate Permission = "starter.branch.create" - StarterBranchDelete Permission = "starter.branch.delete" - StarterSQLUserRead Permission = "starter.sql_user.read" - StarterSQLUserCreate Permission = "starter.sql_user.create" - StarterSQLUserUpdate Permission = "starter.sql_user.update" - StarterSQLExecute Permission = "starter.sql.execute" - FSVolumeRead Permission = "fs.volume.read" - FSVolumeCreate Permission = "fs.volume.create" - FSVolumeDelete Permission = "fs.volume.delete" - FSFileRead Permission = "fs.file.read" - FSFileWrite Permission = "fs.file.write" - FSVaultSecretRead Permission = "fs.vault.secret.read" - FSVaultSecretCreate Permission = "fs.vault.secret.create" - FSVaultSecretUpdate Permission = "fs.vault.secret.update" - FSVaultSecretDelete Permission = "fs.vault.secret.delete" - FSVaultGrantCreate Permission = "fs.vault.grant.create" - FSVaultGrantDelete Permission = "fs.vault.grant.delete" - FSVaultAuditRead Permission = "fs.vault.audit.read" - FSJournalCreate Permission = "fs.journal.create" - FSJournalAppend Permission = "fs.journal.append" - FSJournalRead Permission = "fs.journal.read" - FSJournalSearch Permission = "fs.journal.search" - FSJournalVerify Permission = "fs.journal.verify" - FSGitWorkspaceRead Permission = "fs.git_workspace.read" - FSGitWorkspaceWrite Permission = "fs.git_workspace.write" - FSMount Permission = "fs.mount" + DBClusterDiscover Permission = "db.cluster.discover" + StarterClusterRead Permission = "starter.cluster.read" + StarterClusterCreate Permission = "starter.cluster.create" + StarterClusterUpdate Permission = "starter.cluster.update" + StarterClusterDelete Permission = "starter.cluster.delete" + StarterBranchRead Permission = "starter.branch.read" + StarterBranchCreate Permission = "starter.branch.create" + StarterBranchDelete Permission = "starter.branch.delete" + StarterSQLUserRead Permission = "starter.sql_user.read" + StarterSQLUserCreate Permission = "starter.sql_user.create" + StarterSQLUserUpdate Permission = "starter.sql_user.update" + StarterSQLExecute Permission = "starter.sql.execute" + FSVolumeRead Permission = "fs.volume.read" + FSVolumeCreate Permission = "fs.volume.create" + FSVolumeDelete Permission = "fs.volume.delete" + FSFileRead Permission = "fs.file.read" + FSFileWrite Permission = "fs.file.write" + FSVaultSecretRead Permission = "fs.vault.secret.read" + FSVaultSecretCreate Permission = "fs.vault.secret.create" + FSVaultSecretUpdate Permission = "fs.vault.secret.update" + FSVaultSecretDelete Permission = "fs.vault.secret.delete" + FSVaultGrantCreate Permission = "fs.vault.grant.create" + FSVaultGrantDelete Permission = "fs.vault.grant.delete" + FSVaultAuditRead Permission = "fs.vault.audit.read" + FSJournalCreate Permission = "fs.journal.create" + FSJournalAppend Permission = "fs.journal.append" + FSJournalRead Permission = "fs.journal.read" + FSJournalSearch Permission = "fs.journal.search" + FSJournalVerify Permission = "fs.journal.verify" + FSGitWorkspaceRead Permission = "fs.git_workspace.read" + FSGitWorkspaceWrite Permission = "fs.git_workspace.write" + FSMount Permission = "fs.mount" ) var commandPermissions = map[string]Permission{ - "ti organization list-projects": OrganizationProjectRead, "ti fs create-file-system": FSVolumeCreate, "ti fs delete-file-system": FSVolumeDelete, "ti fs list-file-systems": FSVolumeRead, diff --git a/internal/authz/authz_test.go b/internal/authz/authz_test.go index b260a88..5771e40 100644 --- a/internal/authz/authz_test.go +++ b/internal/authz/authz_test.go @@ -9,11 +9,10 @@ import ( func TestForCommand(t *testing.T) { tests := map[string]Permission{ - "ti organization list-projects": OrganizationProjectRead, - "ti fs create-file-system": FSVolumeCreate, - "ti fs search-file-content": FSFileRead, - "ti fs mount-file-system": FSMount, - "ti fs-vault mount-vault": FSVaultSecretRead, + "ti fs create-file-system": FSVolumeCreate, + "ti fs search-file-content": FSFileRead, + "ti fs mount-file-system": FSMount, + "ti fs-vault mount-vault": FSVaultSecretRead, } for command, want := range tests { diff --git a/internal/cli/commands.go b/internal/cli/commands.go index c8a97e3..793f75d 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -20,7 +20,6 @@ import ( "github.com/tidbcloud/ti-cli/internal/dryrun" tifs "github.com/tidbcloud/ti-cli/internal/fs" "github.com/tidbcloud/ti-cli/internal/fs/fscred" - "github.com/tidbcloud/ti-cli/internal/organization" outputpkg "github.com/tidbcloud/ti-cli/internal/output" "github.com/tidbcloud/ti-cli/internal/update" "github.com/tidbcloud/ti-cli/internal/version" @@ -65,10 +64,6 @@ func newConfigureCommand(info version.Info) *cobra.Command { if err != nil { return err } - debug, err := cmd.Flags().GetBool("debug") - if err != nil { - return err - } result, err := cfgconfigure.Run(cmd.Context(), cfgconfigure.Options{ Profile: profile, RegionCode: regionCode, @@ -77,8 +72,6 @@ func newConfigureCommand(info version.Info) *cobra.Command { NonInteractive: nonInteractive, In: cmd.InOrStdin(), Out: cmd.OutOrStdout(), - Debug: debug, - DebugWriter: cmd.ErrOrStderr(), }) if err != nil { return err @@ -220,7 +213,6 @@ func newDBCreateClusterCommand(info version.Info) *cobra.Command { }, info) cmd.Flags().String("db-cluster-name", "", "Starter database cluster display name.") cmd.Flags().String("db-cluster-type", "", "Database cluster type; must be starter.") - cmd.Flags().String("project-id", "", "TiDB Cloud project ID. Omit this value to use the default project for the profile.") cmd.Flags().Int32("monthly-spending-limit-usd-cents", -1, "The monthly spending limit in USD cents; omit to use the default.") cmd.Flags().Bool("wait", false, "Wait until the created cluster becomes ACTIVE before returning") markUsageRequired(cmd, "db-cluster-type", "db-cluster-name") @@ -665,10 +657,6 @@ func createClusterOptions(ctx commandContext, profile *config.Profile) (db.Creat if err != nil { return db.CreateClusterOptions{}, err } - projectID, err := ctx.StringFlag("project-id") - if err != nil { - return db.CreateClusterOptions{}, err - } spendingLimit, err := ctx.Int32Flag("monthly-spending-limit-usd-cents") if err != nil { return db.CreateClusterOptions{}, err @@ -678,12 +666,10 @@ func createClusterOptions(ctx commandContext, profile *config.Profile) (db.Creat return db.CreateClusterOptions{}, err } return db.CreateClusterOptions{ - Profile: profile, - DisplayName: name, - ClusterType: clusterType, - ProjectID: projectID, - ProjectIDExplicit: ctx.FlagChanged("project-id"), - WaitUntilActive: waitUntilActive, + Profile: profile, + DisplayName: name, + ClusterType: clusterType, + WaitUntilActive: waitUntilActive, Product: dbstarter.CreateOptions{ MonthlySpendingLimitUSDCents: spendingLimit, }, @@ -3540,51 +3526,3 @@ func newJournalVerifyCommand(info version.Info) *cobra.Command { markUsageRequired(cmd, "journal-id") return cmd } - -func newOrganizationCommand(info version.Info) *cobra.Command { - cmd := newParentCommand("organization", "Inspect TiDB Cloud organization resources.", info) - cmd.AddCommand( - newOrganizationListProjectsCommand(info), - ) - return cmd -} - -func newOrganizationListProjectsCommand(info version.Info) *cobra.Command { - cmd := newControlPlaneCommand(controlPlaneCommandSpec{ - Use: "list-projects", - Short: "List TiDB Cloud projects.", - Mutation: readOnlyCommand, - Permission: authz.OrganizationProjectRead, - Run: func(ctx commandContext) (any, error) { - profile, err := ctx.LoadProfile() - if err != nil { - return nil, err - } - pageSize, err := ctx.Int32Flag("page-size") - if err != nil { - return nil, err - } - pageToken, err := ctx.StringFlag("page-token") - if err != nil { - return nil, err - } - debug, err := ctx.BoolFlag("debug") - if err != nil { - return nil, err - } - service := organization.Service{ - Timeout: 30 * time.Second, - Debug: debug, - DebugWriter: ctx.cmd.ErrOrStderr(), - } - return service.ListProjects(ctx.cmd.Context(), organization.ListProjectsOptions{ - Profile: profile, - PageSize: pageSize, - PageToken: pageToken, - }) - }, - }, info) - cmd.Flags().Int32("page-size", 0, "The number of projects to request; 0 uses the default.") - cmd.Flags().String("page-token", "", "The page token returned by a previous list-projects call.") - return cmd -} diff --git a/internal/cli/root.go b/internal/cli/root.go index 2b85d84..a6764d4 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -92,7 +92,6 @@ func NewRootCommand(info version.Info) *cobra.Command { root.AddCommand(newFSCommand(info)) root.AddCommand(newFSGitCommand(info)) root.AddCommand(newFSJournalCommand(info)) - root.AddCommand(newOrganizationCommand(info)) root.AddCommand(newFSVaultCommand(info)) installHelpCommands(root, info) diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 9b35131..cfc4963 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -158,7 +158,6 @@ func TestCommandOperationLogRecordsSafeSummary(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) t.Setenv("TI_LOGGING", "on") - configureIAMForTest(t) _, _, err := executeForTest( "configure", @@ -428,7 +427,6 @@ func TestTelemetrySendsCanonicalSafeCommandEvent(t *testing.T) { "db", "create-db-cluster", "--db-cluster-type", "starter", "--db-cluster-name", clusterName, - "--project-id", "must-not-appear-project-id", "--dry-run", ) if err != nil { @@ -443,7 +441,7 @@ func TestTelemetrySendsCanonicalSafeCommandEvent(t *testing.T) { case <-time.After(2 * time.Second): t.Fatal("eligible command did not send telemetry") } - for _, prohibited := range []string{clusterName, "must-not-appear-profile", "must-not-appear-project-id", "test-public", "test-private"} { + for _, prohibited := range []string{clusterName, "must-not-appear-profile", "test-public", "test-private"} { if strings.Contains(string(body), prohibited) { t.Fatalf("telemetry payload leaked %q: %s", prohibited, body) } @@ -472,7 +470,7 @@ func TestTelemetrySendsCanonicalSafeCommandEvent(t *testing.T) { if event.CommandPath != "ti db create-db-cluster" || event.ExitCode != 0 || event.ErrorCode != "" || event.CloudProvider != "aws" || event.RegionCode != "aws-us-east-1" || event.ProfileSource != "explicit" || event.Tag != "e2b-preview" || string(event.Extra) != `{"campaign":"launch","runtime":"e2b"}` { t.Fatalf("unexpected telemetry event: %#v", event) } - for _, want := range []string{"db-cluster-name", "dry-run", "profile", "project-id"} { + for _, want := range []string{"db-cluster-name", "db-cluster-type", "dry-run", "profile"} { if !containsString(event.FlagNames, want) { t.Fatalf("missing changed flag %q in %#v", want, event.FlagNames) } @@ -560,7 +558,7 @@ func TestTelemetryFailureDoesNotChangeCommandResult(t *testing.T) { t.Setenv("HOME", home) t.Setenv("TI_LOGGING", "off") withConfigEnv(t) - args := []string{"db", "create-db-cluster", "--db-cluster-name", "demo", "--project-id", "project-1", "--dry-run"} + args := []string{"db", "create-db-cluster", "--db-cluster-name", "demo", "--db-cluster-type", "starter", "--dry-run"} t.Setenv("TI_TELEMETRY", "off") wantStdout, wantStderr, wantErr := executeForTestWithInfo(testVersion(), args...) @@ -666,9 +664,6 @@ func TestHelpUsageShowsRequiredFirstAndOptionalBracketed(t *testing.T) { if err != nil { t.Fatalf("expected db create help to succeed, got %v", err) } - if !strings.Contains(stdout, " [--project-id ]") { - t.Fatalf("expected --project-id to be optional, got:\n%s", stdout) - } if !strings.Contains(stdout, " [--wait]") { t.Fatalf("expected --wait to be optional, got:\n%s", stdout) } @@ -676,15 +671,14 @@ func TestHelpUsageShowsRequiredFirstAndOptionalBracketed(t *testing.T) { "--db-cluster-name (required)", "--db-cluster-type ", "--db-cluster-type (required)", - "--project-id ", "--monthly-spending-limit-usd-cents ", } { if !strings.Contains(stdout, want) { t.Fatalf("expected formatted flag %q, got:\n%s", want, stdout) } } - if strings.Contains(stdout, "--project-id (required)") { - t.Fatalf("optional --project-id must not be marked required, got:\n%s", stdout) + if strings.Contains(stdout, "--project-id") { + t.Fatalf("db create help exposes removed project selection:\n%s", stdout) } stdout, _, err = executeForTest("db", "delete-db-cluster", "help") if err != nil { @@ -741,8 +735,7 @@ func TestServiceCommandsDeclarePermissions(t *testing.T) { !strings.HasPrefix(path, "ti fs ") && !strings.HasPrefix(path, "ti fs-git ") && !strings.HasPrefix(path, "ti fs-journal ") && - !strings.HasPrefix(path, "ti fs-vault ") && - !strings.HasPrefix(path, "ti organization ") { + !strings.HasPrefix(path, "ti fs-vault ") { return } if _, err := authz.ForCommand(path); err != nil { @@ -997,7 +990,7 @@ func TestControlPlaneCommandSpecRendersImplementedResult(t *testing.T) { Use: "implemented-command", Short: "Implemented command.", Mutation: readOnlyCommand, - Permission: authz.OrganizationProjectRead, + Permission: authz.StarterClusterRead, Run: func(commandContext) (any, error) { return map[string]any{ "items": []map[string]string{ @@ -1065,7 +1058,7 @@ func TestControlPlaneCommandSpecUsesCustomDryRun(t *testing.T) { func TestMutatingControlPlaneDryRunRendersJSON(t *testing.T) { withConfigEnv(t) - stdout, _, err := executeForTest("db", "create-db-cluster", "--db-cluster-type", "starter", "--db-cluster-name", "demo-cluster", "--project-id", "project-1", "--wait", "--dry-run") + stdout, _, err := executeForTest("db", "create-db-cluster", "--db-cluster-type", "starter", "--db-cluster-name", "demo-cluster", "--wait", "--dry-run") if err != nil { t.Fatalf("expected dry-run to succeed, got %v", err) } @@ -1101,7 +1094,7 @@ func TestRegionOverrideWinsOverEnvironmentCredentials(t *testing.T) { t.Setenv("TIDB_CLOUD_PUBLIC_KEY", "test-public") t.Setenv("TIDB_CLOUD_PRIVATE_KEY", "test-private") - stdout, _, err := executeForTest("--region", "aws-ap-southeast-1", "db", "create-db-cluster", "--db-cluster-name", "demo-cluster", "--db-cluster-type", "starter", "--project-id", "project-1", "--dry-run") + stdout, _, err := executeForTest("--region", "aws-ap-southeast-1", "db", "create-db-cluster", "--db-cluster-name", "demo-cluster", "--db-cluster-type", "starter", "--dry-run") if err != nil { t.Fatalf("expected dry-run to succeed, got %v", err) } @@ -1115,7 +1108,7 @@ func TestRegionOverrideAllowsEnvironmentCredentialsWithoutEnvRegion(t *testing.T t.Setenv("TIDB_CLOUD_PUBLIC_KEY", "test-public") t.Setenv("TIDB_CLOUD_PRIVATE_KEY", "test-private") - stdout, _, err := executeForTest("--region", "ali-ap-southeast-1", "db", "create-db-cluster", "--db-cluster-name", "demo-cluster", "--db-cluster-type", "starter", "--project-id", "project-1", "--dry-run") + stdout, _, err := executeForTest("--region", "ali-ap-southeast-1", "db", "create-db-cluster", "--db-cluster-name", "demo-cluster", "--db-cluster-type", "starter", "--dry-run") if err != nil { t.Fatalf("expected dry-run to succeed, got %v", err) } @@ -1124,24 +1117,7 @@ func TestRegionOverrideAllowsEnvironmentCredentialsWithoutEnvRegion(t *testing.T } } -func TestCreateClusterUsesConfiguredDefaultProject(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - t.Setenv("TI_REGION_CODE", "") - t.Setenv("TIDB_CLOUD_PUBLIC_KEY", "") - t.Setenv("TIDB_CLOUD_PRIVATE_KEY", "") - writeCompleteProfile(t, home, "default") - - stdout, _, err := executeForTest("db", "create-db-cluster", "--db-cluster-name", "demo-cluster", "--db-cluster-type", "starter", "--dry-run") - if err != nil { - t.Fatalf("expected profile project fallback to succeed: %v", err) - } - if !strings.Contains(stdout, `"tidb.cloud/project": "virtual-test"`) { - t.Fatalf("dry-run did not use configured project:\n%s", stdout) - } -} - -func TestCreateClusterAllowsMissingConfiguredProject(t *testing.T) { +func TestCreateClusterOmitsProjectSelection(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) t.Setenv("TI_REGION_CODE", "") @@ -1158,27 +1134,13 @@ func TestCreateClusterAllowsMissingConfiguredProject(t *testing.T) { stdout, _, err := executeForTest("db", "create-db-cluster", "--db-cluster-type", "starter", "--db-cluster-name", "demo-cluster", "--dry-run") if err != nil { - t.Fatalf("expected server-default project fallback to succeed: %v", err) + t.Fatalf("expected create dry-run to succeed: %v", err) } if strings.Contains(stdout, "tidb.cloud/project") || strings.Contains(stdout, `"labels"`) { t.Fatalf("dry-run unexpectedly included a project label:\n%s", stdout) } } -func TestCreateClusterExplicitEmptyProjectDoesNotFallback(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - t.Setenv("TI_REGION_CODE", "") - t.Setenv("TIDB_CLOUD_PUBLIC_KEY", "") - t.Setenv("TIDB_CLOUD_PRIVATE_KEY", "") - writeCompleteProfile(t, home, "default") - - _, _, err := executeForTest("db", "create-db-cluster", "--db-cluster-name", "demo-cluster", "--db-cluster-type", "starter", "--project-id", "", "--dry-run") - if apperr.CodeFor(err) != "db.empty_project_id" { - t.Fatalf("unexpected error: %v", err) - } -} - func TestDBTypeSelectedCommandsRequireExactStarter(t *testing.T) { withConfigEnv(t) @@ -1204,8 +1166,6 @@ func TestConfigureUsesTIProfileNamespace(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) t.Setenv("TI_PROFILE", "stage") - configureIAMForTest(t) - stdout, _, err := executeForTest( "configure", "--non-interactive", "--region-code", "aws-us-east-1", @@ -1222,8 +1182,8 @@ func TestConfigureUsesTIProfileNamespace(t *testing.T) { if err != nil { t.Fatal(err) } - if doc["stage"].ProjectID != "virtual-test" { - t.Fatalf("stage project was not stored: %#v", doc) + if doc["stage"].LegacyProjectID != "" { + t.Fatalf("configure unexpectedly stored project state: %#v", doc) } if _, exists := doc["default"]; exists { t.Fatalf("configure unexpectedly wrote default profile: %#v", doc) @@ -1233,7 +1193,6 @@ func TestConfigureUsesTIProfileNamespace(t *testing.T) { func TestConfigureDoesNotDisplayTelemetryNotice(t *testing.T) { t.Setenv("HOME", t.TempDir()) t.Setenv("TI_TELEMETRY", "off") - configureIAMForTest(t) _, stderr, err := executeForTest( "configure", "--non-interactive", "--region-code", "aws-us-east-1", @@ -1264,7 +1223,7 @@ func TestExplicitEmptyRegionFails(t *testing.T) { func TestMutatingControlPlaneDryRunSupportsTextOutput(t *testing.T) { withConfigEnv(t) - stdout, _, err := executeForTest("db", "create-db-cluster", "--db-cluster-name", "demo-cluster", "--db-cluster-type", "starter", "--project-id", "project-1", "--dry-run", "--output", "text") + stdout, _, err := executeForTest("db", "create-db-cluster", "--db-cluster-name", "demo-cluster", "--db-cluster-type", "starter", "--dry-run", "--output", "text") if err != nil { t.Fatalf("expected dry-run to succeed, got %v", err) } @@ -1276,7 +1235,7 @@ func TestMutatingControlPlaneDryRunSupportsTextOutput(t *testing.T) { func TestQueryAppliesToDryRunResult(t *testing.T) { withConfigEnv(t) - stdout, _, err := executeForTest("db", "create-db-cluster", "--db-cluster-name", "demo-cluster", "--db-cluster-type", "starter", "--project-id", "project-1", "--dry-run", "--query", "command") + stdout, _, err := executeForTest("db", "create-db-cluster", "--db-cluster-name", "demo-cluster", "--db-cluster-type", "starter", "--dry-run", "--query", "command") if err != nil { t.Fatalf("expected query to succeed, got %v", err) } @@ -1288,7 +1247,7 @@ func TestQueryAppliesToDryRunResult(t *testing.T) { func TestInvalidQueryFails(t *testing.T) { withConfigEnv(t) - _, _, err := executeForTest("db", "create-db-cluster", "--db-cluster-name", "demo-cluster", "--db-cluster-type", "starter", "--project-id", "project-1", "--dry-run", "--query", "command[") + _, _, err := executeForTest("db", "create-db-cluster", "--db-cluster-name", "demo-cluster", "--db-cluster-type", "starter", "--dry-run", "--query", "command[") if err == nil { t.Fatal("expected invalid query to fail") } @@ -1307,7 +1266,7 @@ func TestDryRunRequiresConfigAndCredentials(t *testing.T) { t.Setenv("TIDB_CLOUD_PRIVATE_KEY", "") writeConfigOnlyProfile(t, "default") - _, _, err := executeForTest("db", "create-db-cluster", "--db-cluster-name", "demo-cluster", "--db-cluster-type", "starter", "--project-id", "project-1", "--dry-run") + _, _, err := executeForTest("db", "create-db-cluster", "--db-cluster-name", "demo-cluster", "--db-cluster-type", "starter", "--dry-run") if err == nil { t.Fatal("expected missing config to fail") } @@ -1328,7 +1287,7 @@ func TestTIProfileEnvironmentSelectsFileProfile(t *testing.T) { t.Setenv("TIDB_CLOUD_PRIVATE_KEY", "") writeCompleteProfile(t, home, "stage") - stdout, _, err := executeForTest("db", "create-db-cluster", "--db-cluster-name", "demo-cluster", "--db-cluster-type", "starter", "--project-id", "project-1", "--dry-run") + stdout, _, err := executeForTest("db", "create-db-cluster", "--db-cluster-name", "demo-cluster", "--db-cluster-type", "starter", "--dry-run") if err != nil { t.Fatalf("expected dry-run to succeed, got %v", err) } @@ -1376,7 +1335,6 @@ func writeCompleteProfile(t *testing.T, home, profileName string) { t.Helper() err := store.WriteProfile(home, profileName, store.ConfigProfile{ RegionCode: "aws-us-east-1", - ProjectID: "virtual-test", }, store.CredentialsProfile{ TiDBCloudPublicKey: "test-public", TiDBCloudPrivateKey: "test-private", @@ -1453,20 +1411,6 @@ func executeForTestWithInfo(info version.Info, args ...string) (string, string, return stdout.String(), stderr.String(), err } -func configureIAMForTest(t *testing.T) { - t.Helper() - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/v1beta1/projects" { - http.NotFound(w, r) - return - } - _, _ = w.Write([]byte(`{"projects":[{"id":"virtual-test","type":"tidbx_virtual"}]}`)) - })) - t.Cleanup(server.Close) - t.Setenv("TI_ALLOW_TEST_ENDPOINTS", "1") - t.Setenv("TI_TEST_IAM_BASE_URL", server.URL) -} - func testVersion() version.Info { return version.Info{ Version: "0.0.0-test", diff --git a/internal/config/configure/wizard.go b/internal/config/configure/wizard.go index ed87196..fa56a07 100644 --- a/internal/config/configure/wizard.go +++ b/internal/config/configure/wizard.go @@ -5,17 +5,10 @@ import ( "context" "fmt" "io" - "net/http" "os" - "sort" "strings" - "time" - "github.com/tidbcloud/ti-cli/internal/api" - "github.com/tidbcloud/ti-cli/internal/api/endpoints" - apiiam "github.com/tidbcloud/ti-cli/internal/api/iam" "github.com/tidbcloud/ti-cli/internal/apperr" - "github.com/tidbcloud/ti-cli/internal/authz" "github.com/tidbcloud/ti-cli/internal/config" "github.com/tidbcloud/ti-cli/internal/config/envcompat" "github.com/tidbcloud/ti-cli/internal/config/region" @@ -33,32 +26,16 @@ type Options struct { Env map[string]string In io.Reader Out io.Writer - Resolver endpoints.Resolver - Transport http.RoundTripper - Timeout time.Duration - Debug bool - DebugWriter io.Writer } type Result struct { Profile string `json:"profile"` RegionCode string `json:"region_code"` - ProjectID string `json:"project_id"` - ProjectType string `json:"project_type"` CredentialsStored bool `json:"credentials_stored"` } func (r Result) Human() string { - return fmt.Sprintf("Profile: %s\nRegion: %s\nProject: %s (%s)\nCredentials stored: %t", r.Profile, r.RegionCode, r.ProjectID, r.ProjectType, r.CredentialsStored) -} - -const ( - virtualProjectType = "tidbx_virtual" - projectPageSize = 100 -) - -type projectLister interface { - ListProjects(context.Context, apiiam.ListProjectsOptions) (apiiam.ListProjectsResponse, error) + return fmt.Sprintf("Profile: %s\nRegion: %s\nCredentials stored: %t", r.Profile, r.RegionCode, r.CredentialsStored) } func Run(ctx context.Context, opts Options) (Result, error) { @@ -120,22 +97,8 @@ func Run(ctx context.Context, opts Options) (Result, error) { return Result{}, err } - profile := &config.Profile{ - Name: opts.Profile, - PlacementRegionCode: placement.Code, - CloudProvider: placement.Provider, - RegionCode: placement.NativeCode, - TiDBCloudPublicKey: publicKey, - TiDBCloudPrivateKey: privateKey, - } - project, err := discoverVirtualProject(ctx, opts, profile) - if err != nil { - return Result{}, err - } - - if err := store.WriteProfile(opts.HomeDir, opts.Profile, store.ConfigProfile{ + if err := store.WriteConfiguredProfile(opts.HomeDir, opts.Profile, store.ConfigProfile{ RegionCode: placement.Code, - ProjectID: project.ID, }, store.CredentialsProfile{ TiDBCloudPublicKey: publicKey, TiDBCloudPrivateKey: privateKey, @@ -146,92 +109,10 @@ func Run(ctx context.Context, opts Options) (Result, error) { return Result{ Profile: opts.Profile, RegionCode: placement.Code, - ProjectID: project.ID, - ProjectType: project.Type, CredentialsStored: true, }, nil } -func discoverVirtualProject(ctx context.Context, opts Options, profile *config.Profile) (apiiam.Project, error) { - resolver := opts.Resolver - if resolver.IsZero() { - resolver = endpoints.NewResolver() - } - endpoint, err := resolver.ResolveIAM() - if err != nil { - return apiiam.Project{}, err - } - timeout := opts.Timeout - if timeout <= 0 { - timeout = 30 * time.Second - } - client, err := api.NewDigestClient(profile, endpoint, authz.OrganizationProjectRead, api.Options{ - Action: "discover default virtual project", - Transport: opts.Transport, - Timeout: timeout, - Debug: opts.Debug, - DebugWriter: opts.DebugWriter, - UserAgent: "ti configure", - }) - if err != nil { - return apiiam.Project{}, err - } - return selectVirtualProject(ctx, apiiam.New(client)) -} - -func selectVirtualProject(ctx context.Context, client projectLister) (apiiam.Project, error) { - if client == nil { - return apiiam.Project{}, apperr.New("config.project_client_missing", "config", 1, "internal project discovery client is missing") - } - var matches []apiiam.Project - matchedIDs := map[string]struct{}{} - seenTokens := map[string]struct{}{} - pageToken := "" - for { - response, err := client.ListProjects(ctx, apiiam.ListProjectsOptions{PageSize: projectPageSize, PageToken: pageToken}) - if err != nil { - return apiiam.Project{}, err - } - for _, project := range response.Projects { - if project.Type != virtualProjectType { - continue - } - projectID := strings.TrimSpace(project.ID) - if projectID == "" { - return apiiam.Project{}, apperr.New("config.invalid_virtual_project", "api", 1, "TiDB Cloud returned a tidbx_virtual project without an id") - } - if _, exists := matchedIDs[projectID]; exists { - continue - } - project.ID = projectID - matchedIDs[projectID] = struct{}{} - matches = append(matches, project) - } - next := strings.TrimSpace(response.NextPageToken) - if next == "" { - break - } - if _, exists := seenTokens[next]; exists { - return apiiam.Project{}, apperr.New("config.repeated_project_page_token", "api", 1, "TiDB Cloud project pagination returned a repeated page token") - } - seenTokens[next] = struct{}{} - pageToken = next - } - - if len(matches) == 0 { - return apiiam.Project{}, apperr.New("config.virtual_project_not_found", "config", 2, "no tidbx_virtual project is available for this TiDB Cloud account") - } - if len(matches) > 1 { - ids := make([]string, 0, len(matches)) - for _, project := range matches { - ids = append(ids, project.ID) - } - sort.Strings(ids) - return apiiam.Project{}, apperr.New("config.virtual_project_ambiguous", "config", 2, fmt.Sprintf("multiple tidbx_virtual projects are available: %s", strings.Join(ids, ", "))) - } - return matches[0], nil -} - func valueOrPrompt(ctx context.Context, in io.Reader, out io.Writer, value, fieldName, prompt, defaultValue string, secret, nonInteractive bool) (string, error) { if value != "" { return strings.TrimSpace(value), nil diff --git a/internal/config/configure/wizard_test.go b/internal/config/configure/wizard_test.go index 8c2e2b4..d2da9cf 100644 --- a/internal/config/configure/wizard_test.go +++ b/internal/config/configure/wizard_test.go @@ -3,71 +3,56 @@ package configure import ( "bytes" "context" - "errors" - "net/http" - "net/http/httptest" "os" + "path/filepath" "strings" "testing" - "github.com/tidbcloud/ti-cli/internal/api/endpoints" - apiiam "github.com/tidbcloud/ti-cli/internal/api/iam" "github.com/tidbcloud/ti-cli/internal/apperr" "github.com/tidbcloud/ti-cli/internal/config" "github.com/tidbcloud/ti-cli/internal/config/store" ) -func TestRunWritesProfileAndDoesNotPrintSecret(t *testing.T) { +func TestRunWritesProfileLocallyAndDoesNotPrintSecret(t *testing.T) { home := t.TempDir() input := strings.NewReader("aws-us-east-1\npublic-key\nprivate-key\n") var output bytes.Buffer result, err := Run(context.Background(), Options{ - Profile: "stage", - HomeDir: home, - In: input, - Out: &output, - Resolver: testProjectResolver(t, `{"projects":[{"id":"virtual-1","type":"tidbx_virtual"}]}`), + Profile: "stage", + HomeDir: home, + In: input, + Out: &output, }) if err != nil { t.Fatalf("Run failed: %v", err) } - if strings.Contains(output.String(), "private-key") { t.Fatalf("configure output leaked private key:\n%s", output.String()) } if !strings.Contains(output.String(), "Default region code") { t.Fatalf("configure output missing default region prompt:\n%s", output.String()) } - if result.ProjectID != "virtual-1" || result.ProjectType != virtualProjectType || !result.CredentialsStored { + if result.Profile != "stage" || result.RegionCode != "aws-us-east-1" || !result.CredentialsStored { t.Fatalf("unexpected configure result: %#v", result) } + if human := result.Human(); strings.Contains(strings.ToLower(human), "project") || !strings.Contains(human, "Credentials stored: true") { + t.Fatalf("unexpected configure text result: %q", human) + } - profile, err := config.Load(context.Background(), config.LoadOptions{ - Profile: "stage", - ProfileExplicit: true, - HomeDir: home, - }) + profile, err := config.Load(context.Background(), config.LoadOptions{Profile: "stage", ProfileExplicit: true, HomeDir: home}) if err != nil { t.Fatalf("Load failed: %v", err) } - if profile.CloudProvider != "aws" || profile.RegionCode != "us-east-1" { + if profile.CloudProvider != "aws" || profile.RegionCode != "us-east-1" || profile.TiDBCloudPrivateKey != "private-key" { t.Fatalf("unexpected profile: %#v", profile) } - if profile.TiDBCloudPrivateKey != "private-key" { - t.Fatal("private key was not stored") - } - if profile.ProjectID != "virtual-1" { - t.Fatalf("project id was not stored: %#v", profile) - } } func TestRunRejectsUnsupportedProviderRegion(t *testing.T) { - input := strings.NewReader("ali-us-east-1\npublic-key\nprivate-key\n") - _, err := Run(context.Background(), Options{ HomeDir: t.TempDir(), - In: input, + In: strings.NewReader("ali-us-east-1\npublic-key\nprivate-key\n"), Out: &bytes.Buffer{}, }) if err == nil { @@ -75,10 +60,8 @@ func TestRunRejectsUnsupportedProviderRegion(t *testing.T) { } } -func TestRunNonInteractiveUsesEnvironment(t *testing.T) { +func TestRunNonInteractiveUsesEnvironmentWithoutNetwork(t *testing.T) { home := t.TempDir() - var output bytes.Buffer - result, err := Run(context.Background(), Options{ Profile: "ci", HomeDir: home, @@ -88,35 +71,20 @@ func TestRunNonInteractiveUsesEnvironment(t *testing.T) { "TIDB_CLOUD_PUBLIC_KEY": "env-public", "TIDB_CLOUD_PRIVATE_KEY": "env-private", }, - Out: &output, - Resolver: testProjectResolver(t, `{"projects":[{"id":"virtual-env","type":"tidbx_virtual"}]}`), + Out: &bytes.Buffer{}, }) if err != nil { t.Fatalf("Run failed: %v", err) } - if strings.Contains(output.String(), "env-private") { - t.Fatalf("configure output leaked private key:\n%s", output.String()) - } - if result.Profile != "ci" || result.ProjectID != "virtual-env" { + if result.Profile != "ci" || result.RegionCode != "aws-us-east-1" || !result.CredentialsStored { t.Fatalf("unexpected configure result: %#v", result) } - - profile, err := config.Load(context.Background(), config.LoadOptions{ - Profile: "ci", - ProfileExplicit: true, - HomeDir: home, - }) + profile, err := config.Load(context.Background(), config.LoadOptions{Profile: "ci", ProfileExplicit: true, HomeDir: home}) if err != nil { - t.Fatalf("Load failed: %v", err) - } - if profile.CloudProvider != "aws" || profile.RegionCode != "us-east-1" { - t.Fatalf("unexpected profile: %#v", profile) + t.Fatal(err) } if profile.TiDBCloudPublicKey != "env-public" || profile.TiDBCloudPrivateKey != "env-private" { - t.Fatalf("env credentials not stored: %#v", profile) - } - if profile.ProjectID != "virtual-env" { - t.Fatalf("env configure project id not stored: %#v", profile) + t.Fatalf("environment credentials not stored: %#v", profile) } } @@ -138,109 +106,19 @@ func TestRunNonInteractiveRequiresMissingValues(t *testing.T) { } } -func TestSelectVirtualProjectPaginatesAndIgnoresRegularProjects(t *testing.T) { - lister := &scriptedProjectLister{responses: []apiiam.ListProjectsResponse{ - {Projects: []apiiam.Project{{ID: "regular-1", Type: "tidbx"}}, NextPageToken: "page-2"}, - {Projects: []apiiam.Project{{ID: "virtual-1", Type: virtualProjectType}}}, - }} - project, err := selectVirtualProject(context.Background(), lister) - if err != nil { - t.Fatalf("select virtual project: %v", err) - } - if project.ID != "virtual-1" { - t.Fatalf("unexpected project: %#v", project) - } - if len(lister.options) != 2 || lister.options[0].PageToken != "" || lister.options[1].PageToken != "page-2" { - t.Fatalf("unexpected pagination: %#v", lister.options) - } -} - -func TestSelectVirtualProjectRejectsMissingAndMultipleMatches(t *testing.T) { - t.Run("missing", func(t *testing.T) { - _, err := selectVirtualProject(context.Background(), &scriptedProjectLister{responses: []apiiam.ListProjectsResponse{{ - Projects: []apiiam.Project{{ID: "regular-1", Type: "tidbx"}}, - }}}) - if apperr.CodeFor(err) != "config.virtual_project_not_found" { - t.Fatalf("unexpected error: %v", err) - } - }) - - t.Run("multiple", func(t *testing.T) { - _, err := selectVirtualProject(context.Background(), &scriptedProjectLister{responses: []apiiam.ListProjectsResponse{{ - Projects: []apiiam.Project{{ID: "virtual-b", Type: virtualProjectType}, {ID: "virtual-a", Type: virtualProjectType}}, - }}}) - if apperr.CodeFor(err) != "config.virtual_project_ambiguous" || !strings.Contains(apperr.MessageFor(err), "virtual-a, virtual-b") { - t.Fatalf("unexpected error: %v", err) - } - }) -} - -func TestSelectVirtualProjectRejectsRepeatedTokenAndMissingID(t *testing.T) { - t.Run("repeated token", func(t *testing.T) { - _, err := selectVirtualProject(context.Background(), &scriptedProjectLister{responses: []apiiam.ListProjectsResponse{ - {NextPageToken: "repeat"}, - {NextPageToken: "repeat"}, - }}) - if apperr.CodeFor(err) != "config.repeated_project_page_token" { - t.Fatalf("unexpected error: %v", err) - } - }) - - t.Run("missing id", func(t *testing.T) { - _, err := selectVirtualProject(context.Background(), &scriptedProjectLister{responses: []apiiam.ListProjectsResponse{{ - Projects: []apiiam.Project{{Type: virtualProjectType}}, - }}}) - if apperr.CodeFor(err) != "config.invalid_virtual_project" { - t.Fatalf("unexpected error: %v", err) - } - }) -} - -func TestSelectVirtualProjectPropagatesCancellation(t *testing.T) { - _, err := selectVirtualProject(context.Background(), &scriptedProjectLister{err: context.Canceled}) - if !errors.Is(err, context.Canceled) { - t.Fatalf("expected cancellation, got %v", err) - } -} - -func TestRunDiscoveryFailurePreservesExistingProfile(t *testing.T) { +func TestRunReconfigureRemovesOnlySelectedLegacyProjectID(t *testing.T) { home := t.TempDir() - if err := store.WriteProfile(home, "stage", store.ConfigProfile{RegionCode: "aws-us-west-2", ProjectID: "virtual-old"}, store.CredentialsProfile{TiDBCloudPublicKey: "old-public", TiDBCloudPrivateKey: "old-private"}); err != nil { + if err := os.MkdirAll(filepath.Dir(store.ConfigPath(home)), 0o700); err != nil { t.Fatal(err) } - beforeConfig, err := os.ReadFile(store.ConfigPath(home)) - if err != nil { + configData := "[default]\nregion_code = 'aws-us-east-1'\nproject_id = 'default-project'\n\n[stage]\nregion_code = 'aws-us-west-2'\nproject_id = 'stage-project'\n" + if err := os.WriteFile(store.ConfigPath(home), []byte(configData), 0o644); err != nil { t.Fatal(err) } - beforeCredentials, err := os.ReadFile(store.CredentialsPath(home)) - if err != nil { + if err := os.WriteFile(store.CredentialsPath(home), []byte("[default]\ntidb_cloud_public_key = 'default-public'\ntidb_cloud_private_key = 'default-private'\n\n[stage]\ntidb_cloud_public_key = 'old-public'\ntidb_cloud_private_key = 'old-private'\n"), 0o600); err != nil { t.Fatal(err) } - _, err = Run(context.Background(), Options{ - Profile: "stage", - HomeDir: home, - NonInteractive: true, - RegionCode: "aws-us-east-1", - TiDBCloudPublicKey: "new-public", - TiDBCloudPrivateKey: "new-private", - Resolver: testProjectResolver(t, `{"projects":[{"id":"regular-1","type":"tidbx"}]}`), - }) - if apperr.CodeFor(err) != "config.virtual_project_not_found" { - t.Fatalf("unexpected error: %v", err) - } - afterConfig, _ := os.ReadFile(store.ConfigPath(home)) - afterCredentials, _ := os.ReadFile(store.CredentialsPath(home)) - if !bytes.Equal(beforeConfig, afterConfig) || !bytes.Equal(beforeCredentials, afterCredentials) { - t.Fatalf("configure discovery failure changed profile files\nconfig before:\n%s\nconfig after:\n%s", beforeConfig, afterConfig) - } -} - -func TestRunReconfigureReplacesProjectID(t *testing.T) { - home := t.TempDir() - if err := store.WriteProfile(home, "stage", store.ConfigProfile{RegionCode: "aws-us-west-2", ProjectID: "virtual-old"}, store.CredentialsProfile{TiDBCloudPublicKey: "old-public", TiDBCloudPrivateKey: "old-private"}); err != nil { - t.Fatal(err) - } _, err := Run(context.Background(), Options{ Profile: "stage", HomeDir: home, @@ -248,51 +126,25 @@ func TestRunReconfigureReplacesProjectID(t *testing.T) { RegionCode: "aws-us-east-1", TiDBCloudPublicKey: "new-public", TiDBCloudPrivateKey: "new-private", - Resolver: testProjectResolver(t, `{"projects":[{"id":"virtual-new","type":"tidbx_virtual"}]}`), }) if err != nil { - t.Fatalf("reconfigure: %v", err) + t.Fatalf("reconfigure failed: %v", err) } - profile, err := config.Load(context.Background(), config.LoadOptions{Profile: "stage", ProfileExplicit: true, HomeDir: home}) + doc, err := store.ReadConfig(home) if err != nil { t.Fatal(err) } - if profile.ProjectID != "virtual-new" || profile.TiDBCloudPublicKey != "new-public" { - t.Fatalf("profile was not refreshed: %#v", profile) + if doc["stage"].LegacyProjectID != "" { + t.Fatalf("selected profile retained project id: %#v", doc["stage"]) } -} - -type scriptedProjectLister struct { - responses []apiiam.ListProjectsResponse - options []apiiam.ListProjectsOptions - err error -} - -func (l *scriptedProjectLister) ListProjects(_ context.Context, opts apiiam.ListProjectsOptions) (apiiam.ListProjectsResponse, error) { - l.options = append(l.options, opts) - if l.err != nil { - return apiiam.ListProjectsResponse{}, l.err + if doc["default"].LegacyProjectID != "default-project" { + t.Fatalf("unselected profile changed: %#v", doc["default"]) + } + raw, err := os.ReadFile(store.ConfigPath(home)) + if err != nil { + t.Fatal(err) } - if len(l.responses) == 0 { - return apiiam.ListProjectsResponse{}, nil + if strings.Contains(string(raw), "stage-project") || !strings.Contains(string(raw), "default-project") { + t.Fatalf("unexpected config migration:\n%s", raw) } - response := l.responses[0] - l.responses = l.responses[1:] - return response, nil -} - -func testProjectResolver(t *testing.T, response string) endpoints.Resolver { - t.Helper() - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/v1beta1/projects" { - t.Fatalf("unexpected project path %s", r.URL.Path) - } - if r.URL.Query().Get("pageSize") != "100" { - t.Fatalf("unexpected page size %q", r.URL.Query().Get("pageSize")) - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(response)) - })) - t.Cleanup(server.Close) - return endpoints.Resolver{IAMBaseURL: server.URL} } diff --git a/internal/config/profile.go b/internal/config/profile.go index bac9c94..f0c3338 100644 --- a/internal/config/profile.go +++ b/internal/config/profile.go @@ -29,7 +29,6 @@ type Profile struct { PlacementRegionCode string CloudProvider string RegionCode string - ProjectID string TiDBCloudPublicKey string TiDBCloudPrivateKey string FSResourceName string @@ -98,7 +97,6 @@ func Load(ctx context.Context, opts LoadOptions) (*Profile, error) { PlacementRegionCode: placement.Code, CloudProvider: placement.Provider, RegionCode: placement.NativeCode, - ProjectID: cfg.ProjectID, TiDBCloudPublicKey: publicKey, TiDBCloudPrivateKey: privateKey, FSResourceName: cfg.FSResourceName, @@ -162,7 +160,6 @@ func LoadLocal(ctx context.Context, opts LoadOptions) (*Profile, error) { Name: profileName, HomeDir: opts.HomeDir, Source: source, - ProjectID: cfg.ProjectID, FSResourceName: cfg.FSResourceName, FSTenantID: cfg.FSTenantID, FSPlacementRegionCode: fsPlacement.Code, diff --git a/internal/config/profile_test.go b/internal/config/profile_test.go index c3b2d52..da7be17 100644 --- a/internal/config/profile_test.go +++ b/internal/config/profile_test.go @@ -31,17 +31,23 @@ func TestLoadExplicitProfile(t *testing.T) { } } -func TestLoadReadsProjectID(t *testing.T) { +func TestLoadIgnoresLegacyProjectID(t *testing.T) { home := t.TempDir() - if err := store.WriteProfile(home, "stage", store.ConfigProfile{RegionCode: "aws-us-east-1", ProjectID: "virtual-1"}, store.CredentialsProfile{TiDBCloudPublicKey: "public", TiDBCloudPrivateKey: "private"}); err != nil { + if err := os.MkdirAll(filepath.Join(home, store.TIDirName), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(store.ConfigPath(home), []byte("[stage]\nregion_code = 'aws-us-east-1'\nproject_id = 'virtual-1'\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(store.CredentialsPath(home), []byte("[stage]\ntidb_cloud_public_key = 'public'\ntidb_cloud_private_key = 'private'\n"), 0o600); err != nil { t.Fatal(err) } profile, err := Load(context.Background(), LoadOptions{Profile: "stage", ProfileExplicit: true, HomeDir: home}) if err != nil { t.Fatal(err) } - if profile.ProjectID != "virtual-1" { - t.Fatalf("project id = %q, want virtual-1", profile.ProjectID) + if profile.RegionCode != "us-east-1" || profile.TiDBCloudPublicKey != "public" { + t.Fatalf("legacy project id affected profile loading: %#v", profile) } } diff --git a/internal/config/store/store.go b/internal/config/store/store.go index 8334010..3de17c6 100644 --- a/internal/config/store/store.go +++ b/internal/config/store/store.go @@ -25,7 +25,7 @@ type ConfigDocument map[string]ConfigProfile type ConfigProfile struct { CloudProvider string `toml:"cloud_provider,omitempty"` RegionCode string `toml:"region_code,omitempty"` - ProjectID string `toml:"project_id,omitempty"` + LegacyProjectID string `toml:"project_id,omitempty"` FSResourceName string `toml:"fs_resource_name,omitempty"` FSTenantID string `toml:"fs_tenant_id,omitempty"` FSCloudProvider string `toml:"fs_cloud_provider,omitempty"` @@ -175,6 +175,16 @@ func resolveCredentialField(path, profileName, canonicalName, canonicalValue, le } func WriteProfile(homeDir, profileName string, cfg ConfigProfile, creds CredentialsProfile) error { + return writeProfile(homeDir, profileName, cfg, creds, false) +} + +// WriteConfiguredProfile updates one profile and removes the retired project +// selector from that profile without changing legacy values in other profiles. +func WriteConfiguredProfile(homeDir, profileName string, cfg ConfigProfile, creds CredentialsProfile) error { + return writeProfile(homeDir, profileName, cfg, creds, true) +} + +func writeProfile(homeDir, profileName string, cfg ConfigProfile, creds CredentialsProfile, removeLegacyProject bool) error { if profileName == "" { profileName = "default" } @@ -197,8 +207,8 @@ func WriteProfile(homeDir, profileName string, cfg ConfigProfile, creds Credenti existingConfig.RegionCode = cfg.RegionCode existingConfig.CloudProvider = "" } - if cfg.ProjectID != "" { - existingConfig.ProjectID = cfg.ProjectID + if removeLegacyProject { + existingConfig.LegacyProjectID = "" } if cfg.FSResourceName != "" { existingConfig.FSResourceName = cfg.FSResourceName diff --git a/internal/config/store/store_test.go b/internal/config/store/store_test.go index 94c7396..7f315a2 100644 --- a/internal/config/store/store_test.go +++ b/internal/config/store/store_test.go @@ -15,7 +15,6 @@ func TestWriteProfileCreatesFilesAndRestrictsCredentials(t *testing.T) { err := WriteProfile(home, "default", ConfigProfile{ RegionCode: "aws-us-east-1", - ProjectID: "virtual-1", FSResourceName: "workspace", FSTenantID: "tenant", FSCloudProvider: "aws", @@ -47,9 +46,6 @@ func TestWriteProfileCreatesFilesAndRestrictsCredentials(t *testing.T) { if cfg["default"].FSResourceName != "workspace" { t.Fatalf("fs resource name was not persisted: %#v", cfg["default"]) } - if cfg["default"].ProjectID != "virtual-1" { - t.Fatalf("project id was not persisted: %#v", cfg["default"]) - } creds, err := ReadCredentials(home) if err != nil { @@ -127,7 +123,7 @@ fs_default_file_system_name = "scratch" if err != nil { t.Fatal(err) } - if got := configDoc["default"]; got.RegionCode != "aws-us-east-1" || got.ProjectID != "project-1" { + if got := configDoc["default"]; got.RegionCode != "aws-us-east-1" || got.LegacyProjectID != "project-1" { t.Fatalf("default profile changed: %#v", got) } data, err := os.ReadFile(ConfigPath(home)) diff --git a/internal/db/contracts.go b/internal/db/contracts.go index 4a4672d..e42ccbd 100644 --- a/internal/db/contracts.go +++ b/internal/db/contracts.go @@ -35,14 +35,12 @@ type ListClustersOptions struct { } type CreateClusterOptions struct { - Profile *config.Profile - DisplayName string - ClusterType string - ProjectID string - ProjectIDExplicit bool - WaitUntilActive bool - Product ProductOptions - Dispatch DispatchContext + Profile *config.Profile + DisplayName string + ClusterType string + WaitUntilActive bool + Product ProductOptions + Dispatch DispatchContext } type DescribeClusterOptions struct { diff --git a/internal/db/product/starter/cluster.go b/internal/db/product/starter/cluster.go index cb8d2f2..65fb8a8 100644 --- a/internal/db/product/starter/cluster.go +++ b/internal/db/product/starter/cluster.go @@ -199,11 +199,6 @@ func (s Service) DryRunCreateCluster(ctx context.Context, commandPath string, op }, "spendingLimit": request.SpendingLimit, } - if request.ProjectID != "" { - body["labels"] = map[string]string{ - apistarter.ProjectLabelKey: request.ProjectID, - } - } return dryrun.New( commandPath, "create_db_cluster", @@ -469,10 +464,6 @@ func (s Service) createRequestAndEndpoint(opts CreateClusterOptions) (apistarter if err := validate.ClusterName(opts.DisplayName); err != nil { return apistarter.CreateClusterRequest{}, endpoints.Endpoint{}, err } - projectID, err := resolveCreateProjectID(opts) - if err != nil { - return apistarter.CreateClusterRequest{}, endpoints.Endpoint{}, err - } product, err := createOptions(opts.Product) if err != nil { return apistarter.CreateClusterRequest{}, endpoints.Endpoint{}, err @@ -487,28 +478,10 @@ func (s Service) createRequestAndEndpoint(opts CreateClusterOptions) (apistarter return apistarter.CreateClusterRequest{ DisplayName: opts.DisplayName, RegionName: endpoint.RegionName, - ProjectID: projectID, SpendingLimit: spendingLimit(product.MonthlySpendingLimitUSDCents), }, endpoint, nil } -func resolveCreateProjectID(opts CreateClusterOptions) (string, error) { - projectID := strings.TrimSpace(opts.ProjectID) - if opts.ProjectIDExplicit && projectID == "" { - return "", apperr.New("db.empty_project_id", "usage", 2, "--project-id cannot be empty") - } - if projectID != "" { - return projectID, nil - } - if opts.Profile != nil { - projectID = strings.TrimSpace(opts.Profile.ProjectID) - } - if projectID != "" { - return projectID, nil - } - return "", nil -} - func (s Service) updateRequest(opts UpdateClusterOptions) (string, apistarter.UpdateClusterRequest, error) { clusterID, request, _, err := s.updateRequestAndEndpoint(opts) return clusterID, request, err diff --git a/internal/db/product/starter/cluster_plan_test.go b/internal/db/product/starter/cluster_plan_test.go index 0396437..2eb5380 100644 --- a/internal/db/product/starter/cluster_plan_test.go +++ b/internal/db/product/starter/cluster_plan_test.go @@ -178,7 +178,7 @@ func TestCreatePlanFailureRetainsAcceptedClusterIdentity(t *testing.T) { defer server.Close() _, err := testService(server.URL).CreateCluster(context.Background(), CreateClusterOptions{ - Profile: testProfile(), DisplayName: "demo", ClusterType: "starter", ProjectID: "project-1", Product: CreateOptions{MonthlySpendingLimitUSDCents: -1}, + Profile: testProfile(), DisplayName: "demo", ClusterType: "starter", Product: CreateOptions{MonthlySpendingLimitUSDCents: -1}, }) if apperr.CodeFor(err) != "db.not_starter_cluster" { t.Fatalf("unexpected error: %v", err) @@ -228,7 +228,7 @@ func TestCreateWaitPlanFailureRetainsAcceptedClusterIdentity(t *testing.T) { service.ClusterWaitTimeout = time.Second service.ClusterWaitPollInterval = time.Millisecond _, err := service.CreateCluster(context.Background(), CreateClusterOptions{ - Profile: testProfile(), DisplayName: "demo", ClusterType: "starter", ProjectID: "project-1", Product: CreateOptions{MonthlySpendingLimitUSDCents: -1}, WaitUntilActive: true, + Profile: testProfile(), DisplayName: "demo", ClusterType: "starter", Product: CreateOptions{MonthlySpendingLimitUSDCents: -1}, WaitUntilActive: true, }) if apperr.CodeFor(err) != "db.not_starter_cluster" { t.Fatalf("unexpected error: %v", err) diff --git a/internal/db/product/starter/cluster_test.go b/internal/db/product/starter/cluster_test.go index 0bae8f5..ff254ef 100644 --- a/internal/db/product/starter/cluster_test.go +++ b/internal/db/product/starter/cluster_test.go @@ -31,9 +31,8 @@ func TestCreateCluster(t *testing.T) { if region["name"] != "regions/aws-us-east-1" { t.Fatalf("unexpected region: %#v", region) } - labels := body["labels"].(map[string]any) - if labels["tidb.cloud/project"] != "project-1" { - t.Fatalf("unexpected project label: %#v", labels) + if _, ok := body["labels"]; ok { + t.Fatalf("create request must not select a project: %#v", body) } _, _ = w.Write([]byte(`{"clusterId":"cluster-1","displayName":"demo-cluster","clusterPlan":"STARTER","region":{"name":"regions/aws-us-east-1"}}`)) })) @@ -43,7 +42,6 @@ func TestCreateCluster(t *testing.T) { Profile: testProfile(), DisplayName: "demo-cluster", ClusterType: "starter", - ProjectID: "project-1", Product: CreateOptions{MonthlySpendingLimitUSDCents: -1}, }) if err != nil { @@ -85,7 +83,6 @@ func TestCreateClusterWaitsUntilActive(t *testing.T) { Profile: testProfile(), DisplayName: "demo-cluster", ClusterType: "starter", - ProjectID: "project-1", Product: CreateOptions{MonthlySpendingLimitUSDCents: -1}, WaitUntilActive: true, }) @@ -115,7 +112,6 @@ func TestCreateClusterWaitReturnsImmediatelyWhenCreateIsActive(t *testing.T) { Profile: testProfile(), DisplayName: "demo-cluster", ClusterType: "starter", - ProjectID: "project-1", Product: CreateOptions{MonthlySpendingLimitUSDCents: -1}, WaitUntilActive: true, }) @@ -185,7 +181,6 @@ func TestCreateClusterWaitErrorsPreserveCreatedCluster(t *testing.T) { Profile: testProfile(), DisplayName: "demo-cluster", ClusterType: "starter", - ProjectID: "project-1", Product: CreateOptions{MonthlySpendingLimitUSDCents: -1}, WaitUntilActive: true, }) @@ -200,34 +195,6 @@ func TestCreateClusterWaitErrorsPreserveCreatedCluster(t *testing.T) { } } -func TestCreateClusterUsesProfileProjectAndAllowsExplicitOverride(t *testing.T) { - var projectIDs []string - 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) - } - labels := body["labels"].(map[string]any) - projectIDs = append(projectIDs, labels["tidb.cloud/project"].(string)) - _, _ = w.Write([]byte(`{"clusterId":"cluster-1","displayName":"demo-cluster","clusterPlan":"STARTER"}`)) - })) - defer server.Close() - - profile := testProfile() - profile.ProjectID = "profile-project" - for _, opts := range []CreateClusterOptions{ - {Profile: profile, DisplayName: "demo-cluster", ClusterType: "starter", Product: CreateOptions{MonthlySpendingLimitUSDCents: -1}}, - {Profile: profile, DisplayName: "demo-cluster", ClusterType: "starter", ProjectID: "explicit-project", ProjectIDExplicit: true, Product: CreateOptions{MonthlySpendingLimitUSDCents: -1}}, - } { - if _, err := testService(server.URL).CreateCluster(context.Background(), opts); err != nil { - t.Fatalf("CreateCluster failed: %v", err) - } - } - if got := strings.Join(projectIDs, ","); got != "profile-project,explicit-project" { - t.Fatalf("unexpected project resolution %q", got) - } -} - func TestCreateClusterAllowsServerDefaultProject(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var body map[string]any @@ -251,20 +218,6 @@ func TestCreateClusterAllowsServerDefaultProject(t *testing.T) { } } -func TestCreateClusterProjectResolutionErrors(t *testing.T) { - t.Run("explicit empty", func(t *testing.T) { - profile := testProfile() - profile.ProjectID = "profile-project" - _, err := Service{}.DryRunCreateCluster(context.Background(), "ti db create-db-cluster", CreateClusterOptions{ - Profile: profile, DisplayName: "demo", ClusterType: "starter", ProjectIDExplicit: true, Product: CreateOptions{MonthlySpendingLimitUSDCents: -1}, - }) - if apperr.CodeFor(err) != "db.empty_project_id" { - t.Fatalf("unexpected error: %v", err) - } - }) - -} - func TestListClusters(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Query().Get("pageSize") != "1" { @@ -493,7 +446,6 @@ func TestDryRunCreateClusterDoesNotSendRequest(t *testing.T) { Profile: testProfile(), DisplayName: "demo-cluster", ClusterType: "starter", - ProjectID: "project-1", Product: CreateOptions{MonthlySpendingLimitUSDCents: -1}, WaitUntilActive: true, }) @@ -580,7 +532,6 @@ func TestCreateAcceptsExplicitStarterType(t *testing.T) { Profile: testProfile(), DisplayName: "demo-cluster", ClusterType: "starter", - ProjectID: "project-1", Product: CreateOptions{MonthlySpendingLimitUSDCents: -1}, }) if err != nil { @@ -602,7 +553,6 @@ func TestCreateRejectsUnsupportedClusterType(t *testing.T) { Profile: testProfile(), DisplayName: "demo-cluster", ClusterType: "essential", - ProjectID: "project-1", }) if apperr.CodeFor(err) != "db.unsupported_cluster_type" { t.Fatalf("expected unsupported cluster type error, got %v", err) @@ -613,7 +563,6 @@ func TestCreateRejectsMissingClusterType(t *testing.T) { _, err := Service{}.DryRunCreateCluster(context.Background(), "ti db create-db-cluster", CreateClusterOptions{ Profile: testProfile(), DisplayName: "demo-cluster", - ProjectID: "project-1", }) if apperr.CodeFor(err) != "db.missing_required_flag" { t.Fatalf("expected missing cluster type error, got %v", err) diff --git a/internal/fs/fscred/fscred_test.go b/internal/fs/fscred/fscred_test.go index 45d9024..30e1a74 100644 --- a/internal/fs/fscred/fscred_test.go +++ b/internal/fs/fscred/fscred_test.go @@ -404,7 +404,7 @@ func TestLegacyMigrationRejectsRegistryMismatch(t *testing.T) { func TestDeletePreservesOtherResourceWithoutChangingMainConfig(t *testing.T) { home := t.TempDir() profile := registryProfile(home) - if err := store.WriteProfile(home, profile.Name, store.ConfigProfile{RegionCode: "aws-us-east-1", ProjectID: "project-1"}, store.CredentialsProfile{}); err != nil { + if err := store.WriteProfile(home, profile.Name, store.ConfigProfile{RegionCode: "aws-us-east-1"}, store.CredentialsProfile{}); err != nil { t.Fatal(err) } if err := Store(home, profile, "workspace", "tenant-1", "aws", "aws-us-east-1", "key-1"); err != nil { @@ -426,7 +426,7 @@ func TestDeletePreservesOtherResourceWithoutChangingMainConfig(t *testing.T) { if err != nil { t.Fatal(err) } - if got := configDoc[profile.Name]; got.RegionCode != "aws-us-east-1" || got.ProjectID != "project-1" { + if got := configDoc[profile.Name]; got.RegionCode != "aws-us-east-1" { t.Fatalf("main config changed: %#v", got) } } diff --git a/internal/organization/organization.go b/internal/organization/organization.go deleted file mode 100644 index 26fa342..0000000 --- a/internal/organization/organization.go +++ /dev/null @@ -1,115 +0,0 @@ -package organization - -import ( - "context" - "fmt" - "io" - "net/http" - "strings" - "text/tabwriter" - "time" - - "github.com/tidbcloud/ti-cli/internal/api" - "github.com/tidbcloud/ti-cli/internal/api/endpoints" - apiiam "github.com/tidbcloud/ti-cli/internal/api/iam" - "github.com/tidbcloud/ti-cli/internal/apperr" - "github.com/tidbcloud/ti-cli/internal/authz" - "github.com/tidbcloud/ti-cli/internal/config" -) - -type Service struct { - Resolver endpoints.Resolver - HTTPClient *http.Client - Transport http.RoundTripper - Timeout time.Duration - Debug bool - DebugWriter io.Writer -} - -type ListProjectsOptions struct { - Profile *config.Profile - PageSize int32 - PageToken string -} - -type ListProjectsResult struct { - Projects []apiiam.Project `json:"projects"` - NextPageToken string `json:"next_page_token,omitempty"` -} - -func (s Service) ListProjects(ctx context.Context, opts ListProjectsOptions) (ListProjectsResult, error) { - if opts.PageSize < 0 { - return ListProjectsResult{}, apperr.New("organization.invalid_page_size", "usage", 2, "--page-size must be greater than or equal to 0") - } - if opts.Profile == nil { - return ListProjectsResult{}, apperr.New("organization.missing_profile", "config", 2, "active profile is required") - } - - resolver := s.resolver() - endpoint, err := resolver.ResolveIAM() - if err != nil { - return ListProjectsResult{}, err - } - - client, err := api.NewDigestClient(opts.Profile, endpoint, authz.OrganizationProjectRead, api.Options{ - Action: "list projects", - HTTPClient: s.HTTPClient, - Transport: s.Transport, - Timeout: s.Timeout, - Debug: s.Debug, - DebugWriter: s.DebugWriter, - UserAgent: "ti organization list-projects", - }) - if err != nil { - return ListProjectsResult{}, err - } - - response, err := apiiam.New(client).ListProjects(ctx, apiiam.ListProjectsOptions{ - PageSize: opts.PageSize, - PageToken: opts.PageToken, - }) - if err != nil { - return ListProjectsResult{}, err - } - - projects := response.Projects - if projects == nil { - projects = []apiiam.Project{} - } - return ListProjectsResult{ - Projects: projects, - NextPageToken: response.NextPageToken, - }, nil -} - -func (s Service) resolver() endpoints.Resolver { - if s.Resolver.IsZero() { - return endpoints.NewResolver() - } - return s.Resolver -} - -func (r ListProjectsResult) Human() string { - var out strings.Builder - writer := tabwriter.NewWriter(&out, 0, 0, 2, ' ', 0) - _, _ = fmt.Fprintln(writer, "ID\tNAME\tTYPE\tORG_ID\tCLUSTERS\tUSERS\tCREATED\tAWS_CMEK") - for _, project := range r.Projects { - _, _ = fmt.Fprintf( - writer, - "%s\t%s\t%s\t%s\t%d\t%d\t%s\t%t\n", - project.ID, - project.Name, - project.Type, - project.OrgID, - project.ClusterCount, - project.UserCount, - project.CreateTimestamp, - project.AWSCMEKEnabled, - ) - } - if r.NextPageToken != "" { - _, _ = fmt.Fprintf(writer, "next_page_token\t%s\t\t\t\t\t\t\n", r.NextPageToken) - } - _ = writer.Flush() - return strings.TrimRight(out.String(), "\n") -} diff --git a/internal/organization/organization_test.go b/internal/organization/organization_test.go deleted file mode 100644 index 5825d03..0000000 --- a/internal/organization/organization_test.go +++ /dev/null @@ -1,118 +0,0 @@ -package organization - -import ( - "context" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/tidbcloud/ti-cli/internal/api/endpoints" - "github.com/tidbcloud/ti-cli/internal/apperr" - "github.com/tidbcloud/ti-cli/internal/config" -) - -func TestListProjects(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if got := r.URL.Query().Get("pageSize"); got != "1" { - t.Fatalf("unexpected pageSize %q", got) - } - if got := r.URL.Query().Get("pageToken"); got != "token-1" { - t.Fatalf("unexpected pageToken %q", got) - } - _, _ = w.Write([]byte(`{ - "projects": [ - { - "id": "project-1", - "name": "Project 1", - "type": "tidbx_virtual", - "org_id": "org-1", - "cluster_count": 2, - "user_count": 3, - "create_timestamp": "1688460316", - "aws_cmek_enabled": false - } - ], - "nextPageToken": "token-2" - }`)) - })) - defer server.Close() - - service := Service{ - Resolver: endpoints.Resolver{IAMBaseURL: server.URL}, - } - result, err := service.ListProjects(context.Background(), ListProjectsOptions{ - Profile: testProfile(), - PageSize: 1, - PageToken: "token-1", - }) - if err != nil { - t.Fatalf("ListProjects failed: %v", err) - } - if len(result.Projects) != 1 || result.Projects[0].ID != "project-1" { - t.Fatalf("unexpected projects: %#v", result.Projects) - } - if result.Projects[0].Type != "tidbx_virtual" { - t.Fatalf("project type = %q, want tidbx_virtual", result.Projects[0].Type) - } - if result.NextPageToken != "token-2" { - t.Fatalf("unexpected next page token %q", result.NextPageToken) - } - if human := result.Human(); !strings.Contains(human, "TYPE") || !strings.Contains(human, "tidbx_virtual") || !strings.Contains(human, "next_page_token") { - t.Fatalf("unexpected text output:\n%s", human) - } -} - -func TestListProjectsValidatesPageSize(t *testing.T) { - _, err := Service{}.ListProjects(context.Background(), ListProjectsOptions{ - Profile: testProfile(), - PageSize: -1, - }) - if err == nil { - t.Fatal("expected negative page size to fail") - } - if got := apperr.ExitCodeFor(err); got != 2 { - t.Fatalf("expected exit code 2, got %d", got) - } -} - -func TestListProjectsAuthAndPermissionErrors(t *testing.T) { - tests := []struct { - name string - statusCode int - exitCode int - }{ - {name: "unauthenticated", statusCode: http.StatusUnauthorized, exitCode: 3}, - {name: "permission denied", statusCode: http.StatusForbidden, exitCode: 4}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(tt.statusCode) - _, _ = w.Write([]byte(`{"message":"denied"}`)) - })) - defer server.Close() - - _, err := Service{ - Resolver: endpoints.Resolver{IAMBaseURL: server.URL}, - }.ListProjects(context.Background(), ListProjectsOptions{Profile: testProfile()}) - if err == nil { - t.Fatal("expected ListProjects to fail") - } - if got := apperr.ExitCodeFor(err); got != tt.exitCode { - t.Fatalf("expected exit code %d, got %d", tt.exitCode, got) - } - }) - } -} - -func testProfile() *config.Profile { - return &config.Profile{ - Name: "test", - CloudProvider: "aws", - RegionCode: "us-east-1", - TiDBCloudPublicKey: "public", - TiDBCloudPrivateKey: "private", - } -} diff --git a/internal/settings/settings_test.go b/internal/settings/settings_test.go index 94e292c..784443f 100644 --- a/internal/settings/settings_test.go +++ b/internal/settings/settings_test.go @@ -225,7 +225,7 @@ max_files = 2 if err != nil { t.Fatal(err) } - if profiles["default"].ProjectID != "project-default" || profiles["stage"].ProjectID != "project-stage" { + if profiles["default"].LegacyProjectID != "project-default" || profiles["stage"].LegacyProjectID != "project-stage" { t.Fatalf("profiles changed during migration: %#v", profiles) } afterCredentials, err := os.ReadFile(store.CredentialsPath(home)) diff --git a/internal/telemetry/telemetry_test.go b/internal/telemetry/telemetry_test.go index c46195c..8fb6bf9 100644 --- a/internal/telemetry/telemetry_test.go +++ b/internal/telemetry/telemetry_test.go @@ -475,7 +475,7 @@ func TestDeliveryFailuresAreSilentByDefault(t *testing.T) { cfg.Endpoint = server.URL var debug strings.Builder cfg.DebugWriter = &debug - Start(cfg).Finish(EventInput{CommandPath: "ti organization list-projects"}) + Start(cfg).Finish(EventInput{CommandPath: "ti db list-db-clusters"}) server.Close() if debug.Len() != 0 { t.Fatalf("status %d produced normal output: %q", status, debug.String()) @@ -497,7 +497,7 @@ func TestDeliveryDoesNotFollowRedirects(t *testing.T) { defer server.Close() cfg := enabledConfig(t.TempDir()) cfg.Endpoint = server.URL - Start(cfg).Finish(EventInput{CommandPath: "ti organization list-projects"}) + Start(cfg).Finish(EventInput{CommandPath: "ti db list-db-clusters"}) if redirected { t.Fatal("telemetry followed a redirect away from its configured endpoint") } diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 0b1e865..c98c0e0 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -123,8 +123,8 @@ function Print-NextSteps { Write-Output " 2. Configure credentials" Write-Output " ti configure" Write-Output "" - Write-Output " 3. List projects" - Write-Output " ti organization list-projects --output text" + Write-Output " 3. Create a Starter database" + Write-Output " ti db create-db-cluster --db-cluster-type starter --db-cluster-name my-database --wait" Write-Output "" Write-Output " 4. Create or check ti fs" Write-Output ' $env:TI_FS_FILE_SYSTEM_ID = ti fs create-file-system --query file_system_id --output text' diff --git a/scripts/install.sh b/scripts/install.sh index d3e43ef..88dab7e 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -310,8 +310,8 @@ print_next_steps() { printf " ${BOLD}2.${RESET} Configure credentials\n" printf " ${DIM}\$${RESET} ti configure\n" printf "\n" - printf " ${BOLD}3.${RESET} List projects\n" - printf " ${DIM}\$${RESET} ti organization list-projects --output text\n" + printf " ${BOLD}3.${RESET} Create a Starter database\n" + printf " ${DIM}\$${RESET} ti db create-db-cluster --db-cluster-type starter --db-cluster-name my-database --wait\n" printf "\n" printf " ${BOLD}4.${RESET} Create or check ti fs\n" printf " ${DIM}\$${RESET} export TI_FS_FILE_SYSTEM_ID=\$(ti fs create-file-system --query file_system_id --output text)\n"