diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index b5c3a70..e9348a4 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -59,7 +59,7 @@ VM startup metadata 只负责幂等主机供给。应用部署生成不可变 re ## 当前约束 -- `development` 已真实部署并通过 destroy/rebuild、正式发布、回滚和双协议 E2E。 +- `development` 已迁移到专用的 `kyson-proxy-dev` Project,并以 `proxy-*` 资源名完成正式发布和双协议 E2E。 - `development` 的 bootstrap/platform plan 当前为零变更,GitHub 配置审计通过。 - `production` 资源与秘密尚未激活,任何写操作仍需显式审批。 - Cloud Run request log 按精确服务名排除,避免 query token 被平台日志持久化;应用仅记录脱敏事件。 diff --git a/docs/design/environments-and-delivery.md b/docs/design/environments-and-delivery.md index e8271d2..6b1a94b 100644 --- a/docs/design/environments-and-delivery.md +++ b/docs/design/environments-and-delivery.md @@ -2,7 +2,7 @@ 本文唯一拥有环境配置归属、GitHub Variables/Secrets 名称、工作流接口以及 VM 与 Cloud Run 的发布协议。 -**实现状态:** development GitHub/GCP 已配置,并通过正式部署、协调回滚与双协议 E2E;production 尚未激活。 +**实现状态:** development 已在 `kyson-proxy-dev` 配置 GitHub/GCP,并通过正式部署、协调回滚与双协议 E2E;production 尚未激活。 ## 环境模型 @@ -171,7 +171,7 @@ make deploy ENV= [GIT_REF=] make destroy ENV= STACK=platform ``` -命令只作为稳定入口;首次 development 激活步骤见 [Runbook](../runbooks/development-first-activation.md)。 +命令只作为稳定入口;空 development 的首次激活见 [Runbook](../runbooks/development-first-activation.md),迁移到新的 GCP Project 且复用秘密时见 [项目迁移 Runbook](../runbooks/development-project-migration.md)。 `secrets-init` 只在本地新建五项随机秘密,不发布或调用 GCP;已有目录时拒绝覆盖。用户变更也只改本地 `users.json`,必须随后显式执行 `secrets-publish` 和 `deploy`。`subscription-url` 从 platform state 读取公开服务 URL,从本地文件读取已启用用户 token,并只向终端输出最终 URL。 diff --git a/docs/design/infrastructure-and-identity.md b/docs/design/infrastructure-and-identity.md index a8310f5..53d4149 100644 --- a/docs/design/infrastructure-and-identity.md +++ b/docs/design/infrastructure-and-identity.md @@ -2,7 +2,7 @@ 本文唯一拥有 OpenTofu 目录、stack/state 划分、模块接口、GCP 资源拓扑以及 IAM/WIF 边界。 -**实现状态:** development 已 apply、重建并通过零变更 plan;production 尚未激活 +**实现状态:** development 已在 `kyson-proxy-dev` apply、部署并通过零变更 plan;production 尚未激活 ## 目录接口 @@ -55,7 +55,7 @@ environment = "development" # 或 production project_id = "gcp-project-id" region = "asia-northeast3" zone = "asia-northeast3-a" -resource_prefix = "proxy" +resource_prefix = "proxy" # 完整资源名前缀,不自动拼接环境 network_cidr = "10.20.0.0/24" network_tier = "STANDARD" vm_machine_type = "e2-micro" @@ -68,6 +68,8 @@ labels = { application = "proxy-builder", environment = "development `environment` 只能是 `development` 或 `production`。`labels.application` 与 `labels.environment` 必须应用于支持 label 的所有资源,作为旧资源清理时的归属条件。 +`resource_prefix` 是资源 ID 的完整前缀,环境名不参与资源 ID 派生。当前两个环境均为 `proxy`,因此新资源命名为 `proxy-network`、`proxy-vm`、`proxy-subscription`、`proxy-github` 和 `proxy-images`。GitHub 身份为 `proxy-gh-plan`、`proxy-gh-apply`、`proxy-gh-deploy`;运行身份为 `proxy-vm-sa` 与 `proxy-subscription-sa`。Project、GitHub Environment 与 labels 继续承担环境隔离。 + 名称包含 `_secret`、`password`、`private_key`、`token` 或证书内容的变量禁止进入 OpenTofu 输入。 ## Stack 所有权 diff --git a/docs/runbooks/README.md b/docs/runbooks/README.md index 324031b..c4202bd 100644 --- a/docs/runbooks/README.md +++ b/docs/runbooks/README.md @@ -5,6 +5,7 @@ | 需求 | 文档 | | --- | --- | | 首次创建并激活 development 新平台 | [development 首次激活](development-first-activation.md) | +| 迁移 development 到新的 GCP Project 并复用秘密 | [development 项目迁移](development-project-migration.md) | | 新增、启停或轮换 development 用户 | [development 用户管理](development-user-management.md) | | 保留 bootstrap 与秘密,重建 development platform | [development platform 重建](development-platform-rebuild.md) | diff --git a/docs/runbooks/development-project-migration.md b/docs/runbooks/development-project-migration.md new file mode 100644 index 0000000..1d11181 --- /dev/null +++ b/docs/runbooks/development-project-migration.md @@ -0,0 +1,70 @@ +# Development 项目迁移 + +**使用时机:** 把 development 从一个已激活的 GCP Project 迁到另一个 Project,并保留本地应用秘密 +**风险等级:** 极高 +**最后复核:** 2026-08-24 + +## 前置条件 + +- [ ] source 与 target Project ID、目标 `resource_prefix` 已书面确认;不删除任何 GCP Project、Billing 或共享 API。 +- [ ] 本地管理员对两个 Project 有 Owner 权限;`.secrets/development/` 五个文件存在且均为 `0600`。 +- [ ] 所有 development workflow 已结束;`make github-audit ENV=development` 成功。 +- [ ] source platform、bootstrap state bucket 的名称已从 source Project ID 推导,未凭记忆输入。 + +## 步骤 + +1. **销毁 source platform** + + 在 source tfvars 仍指向旧 Project 时,从 `main` 触发 `destroy.yml`,输入 `environment=development` 和精确 source Project ID。等待 workflow 成功,再以 GCP 控制面确认 VM、Cloud Run、Artifact Registry、Secret Manager 容器和静态 IP 均不存在。 + +2. **以本地 Owner 清理 source bootstrap 与 state** + + 本地 Owner 必须执行此步;GitHub apply 身份不能安全删除自身与 backend 权限。 + + ```bash + tofu -chdir=infra/stacks/bootstrap init -reconfigure -input=false \ + -backend-config="bucket=-proxy-builder-tfstate" -backend-config="prefix=bootstrap" + tofu -chdir=infra/stacks/bootstrap destroy -auto-approve -input=false \ + -var-file=../../environments/development.tfvars + gcloud storage rm --recursive --all-versions "gs://-proxy-builder-tfstate/**" + gcloud storage buckets delete "gs://-proxy-builder-tfstate" --quiet + ``` + + 预期:WIF、GitHub Service Account、自定义 Role、它们的 IAM 绑定和 source state bucket 不存在;已启用 GCP APIs 保留。 + +3. **切换仓库配置并验证** + + 将 development tfvars 的 `project_id` 与完整 `resource_prefix` 更新为 target 值;执行 `make validate`。在 target Project 创建 state bucket,再以本地 Owner apply bootstrap: + + ```bash + make bootstrap ENV=development + make infra-apply ENV=development STACK=bootstrap + make github-configure ENV=development + make github-audit ENV=development + make secrets-publish ENV=development + ``` + + 预期:GitHub 的 `DEV_` Variables 与 development Environment Variables 全部指向 target Project;秘密值不读取、不重建。 + +4. **重建并验收 target platform** + + 从 GitHub 触发并等待 development 的 bootstrap WIF 验证、platform apply 和 deploy。最后运行 `make infra-plan ENV=development STACK=platform` 与 `make subscription-url ENV=development USER=`;导入新的 URL,确认 Base64/Clash 订阅和 VLESS/Hysteria2 E2E 成功。 + +## 失败处理 + +| 现象 | 处理 | +| --- | --- | +| source platform destroy 失败 | 停止,修复 OpenTofu 错误;不得手工删除未记录资源。 | +| source bootstrap/state 清理失败 | 保持 source tfvars,不切换 GitHub Variables;用本地 Owner 修复后重试。 | +| target bootstrap 失败 | source 已释放时不要回写旧 GitHub 身份;修复 target 权限或 API 后重试。 | +| target deploy/E2E 失败 | platform 保留,检查 workflow 的脱敏诊断并重新部署;首次发布没有 VM previous release 是预期边界。 | + +## 成功标准 + +- source 不存在本项目 platform、bootstrap identity 或 state bucket;source GCP APIs 与 Project 保留。 +- target 的 GitHub audit 成功、platform 第二次 plan 为零变更、五项秘密原样保留。 +- target Cloud Run `/v1/health`、两种订阅格式和双协议 E2E 全部成功;旧订阅 URL 停止使用。 + +## 关联 + +- Design:[基础设施与身份](../design/infrastructure-and-identity.md)、[环境与交付](../design/environments-and-delivery.md) diff --git a/infra/environments/development.tfvars b/infra/environments/development.tfvars index ed878bc..bcc526b 100644 --- a/infra/environments/development.tfvars +++ b/infra/environments/development.tfvars @@ -1,5 +1,5 @@ environment = "development" -project_id = "kyson-lab" +project_id = "kyson-proxy-dev" region = "us-west1" zone = "us-west1-b" resource_prefix = "proxy" diff --git a/infra/modules/artifact_registry/main.tf b/infra/modules/artifact_registry/main.tf index 6d474b9..58d9767 100644 --- a/infra/modules/artifact_registry/main.tf +++ b/infra/modules/artifact_registry/main.tf @@ -1,6 +1,5 @@ locals { - environment_short = var.environment == "production" ? "prod" : "dev" - repository_id = "${var.resource_prefix}-${local.environment_short}-images" + repository_id = "${var.resource_prefix}-images" } resource "google_artifact_registry_repository" "images" { diff --git a/infra/modules/github_identity/main.tf b/infra/modules/github_identity/main.tf index a625857..44d0684 100644 --- a/infra/modules/github_identity/main.tf +++ b/infra/modules/github_identity/main.tf @@ -1,8 +1,7 @@ locals { - environment_short = var.environment == "production" ? "prod" : "dev" - name_prefix = "${var.resource_prefix}-${local.environment_short}" - environment_sub = "repo:${var.github_repository}:environment:${var.environment}" - pull_request_sub = "repo:${var.github_repository}:pull_request" + name_prefix = var.resource_prefix + environment_sub = "repo:${var.github_repository}:environment:${var.environment}" + pull_request_sub = "repo:${var.github_repository}:pull_request" required_services = toset([ "artifactregistry.googleapis.com", @@ -90,7 +89,7 @@ resource "google_service_account" "github" { resource "google_project_iam_custom_role" "secret_metadata_admin" { project = var.project_id - role_id = "${replace(title(var.resource_prefix), "-", "")}${title(local.environment_short)}SecretMetadataAdmin" + role_id = "${replace(title(var.resource_prefix), "-", "")}SecretMetadataAdmin" title = "Proxy secret metadata admin (${var.environment})" description = "Manage Secret Manager containers and IAM without reading payloads" stage = "GA" @@ -121,7 +120,7 @@ resource "google_project_iam_custom_role" "state_iam" { } project = var.project_id - role_id = "${replace(title(var.resource_prefix), "-", "")}${title(local.environment_short)}StateIam${title(each.key)}" + role_id = "${replace(title(var.resource_prefix), "-", "")}StateIam${title(each.key)}" title = "Proxy state IAM ${each.key} (${var.environment})" description = "Read or manage the proxy-builder state bucket IAM policy" stage = "GA" diff --git a/infra/modules/network/main.tf b/infra/modules/network/main.tf index 891548f..4e697fe 100644 --- a/infra/modules/network/main.tf +++ b/infra/modules/network/main.tf @@ -1,7 +1,6 @@ locals { - environment_short = var.environment == "production" ? "prod" : "dev" - name_prefix = "${var.resource_prefix}-${local.environment_short}" - proxy_tag = "${local.name_prefix}-proxy" + name_prefix = var.resource_prefix + proxy_tag = "${local.name_prefix}-vm" } resource "google_compute_network" "proxy" { diff --git a/infra/modules/proxy_vm/main.tf b/infra/modules/proxy_vm/main.tf index 9bba5b6..42703d5 100644 --- a/infra/modules/proxy_vm/main.tf +++ b/infra/modules/proxy_vm/main.tf @@ -1,8 +1,7 @@ locals { - environment_short = var.environment == "production" ? "prod" : "dev" - name_prefix = "${var.resource_prefix}-${local.environment_short}" - startup_script = file("${path.module}/files/startup.sh") - startup_sha256 = sha256(local.startup_script) + name_prefix = var.resource_prefix + startup_script = file("${path.module}/files/startup.sh") + startup_sha256 = sha256(local.startup_script) } data "google_compute_image" "debian" { @@ -12,7 +11,7 @@ data "google_compute_image" "debian" { resource "google_service_account" "runtime" { project = var.project_id - account_id = "${local.name_prefix}-proxy" + account_id = "${local.name_prefix}-vm-sa" display_name = "Proxy runtime (${var.environment})" description = "Runtime identity for the proxy VM; no project roles" } diff --git a/infra/modules/proxy_vm/tests/contracts.tftest.hcl b/infra/modules/proxy_vm/tests/contracts.tftest.hcl index 1f4d0ba..c52e346 100644 --- a/infra/modules/proxy_vm/tests/contracts.tftest.hcl +++ b/infra/modules/proxy_vm/tests/contracts.tftest.hcl @@ -24,11 +24,11 @@ run "startup_metadata_contract" { machine_type = "e2-micro" boot_disk_gb = 10 network_tier = "STANDARD" - subnetwork_self_link = "projects/example-project/regions/us-west1/subnetworks/proxy-dev" + subnetwork_self_link = "projects/example-project/regions/us-west1/subnetworks/proxy-subnet" external_ip_address = "203.0.113.10" - network_tag = "proxy-dev-proxy" + network_tag = "proxy-vm" labels = { application = "proxy-builder", environment = "development" } - deploy_service_account_email = "proxy-dev-gh-deploy@example-project.iam.gserviceaccount.com" + deploy_service_account_email = "proxy-gh-deploy@example-project.iam.gserviceaccount.com" } assert { @@ -48,4 +48,9 @@ run "startup_metadata_contract" { ) error_message = "deploy 身份必须只能 actAs 当前环境的 VM runtime service account。" } + + assert { + condition = google_service_account.runtime.account_id == "proxy-vm-sa" + error_message = "VM runtime service account 必须使用完整 resource_prefix 的 proxy-vm-sa 命名。" + } } diff --git a/infra/modules/secret_runtime/main.tf b/infra/modules/secret_runtime/main.tf index c610ffb..483db22 100644 --- a/infra/modules/secret_runtime/main.tf +++ b/infra/modules/secret_runtime/main.tf @@ -1,6 +1,5 @@ locals { - environment_short = var.environment == "production" ? "prod" : "dev" - name_prefix = "${var.resource_prefix}-${local.environment_short}" + name_prefix = var.resource_prefix secrets = { proxy_users = "${local.name_prefix}-proxy-users" @@ -10,7 +9,7 @@ locals { resource "google_service_account" "runtime" { project = var.project_id - account_id = "${local.name_prefix}-subscription" + account_id = "${local.name_prefix}-subscription-sa" display_name = "Subscription runtime (${var.environment})" description = "Runtime identity for the Cloud Run subscription service" } diff --git a/infra/modules/subscription_service/main.tf b/infra/modules/subscription_service/main.tf index c7da7ce..96f730e 100644 --- a/infra/modules/subscription_service/main.tf +++ b/infra/modules/subscription_service/main.tf @@ -1,6 +1,5 @@ locals { - environment_short = var.environment == "production" ? "prod" : "dev" - service_name = "${var.resource_prefix}-${local.environment_short}-subscription" + service_name = "${var.resource_prefix}-subscription" } resource "google_cloud_run_v2_service" "subscription" { diff --git a/infra/modules/subscription_service/tests/contracts.tftest.hcl b/infra/modules/subscription_service/tests/contracts.tftest.hcl index 5e4003b..f39d9c4 100644 --- a/infra/modules/subscription_service/tests/contracts.tftest.hcl +++ b/infra/modules/subscription_service/tests/contracts.tftest.hcl @@ -34,14 +34,14 @@ run "startup_probe_contract" { } assert { - condition = google_logging_project_exclusion.subscription_requests.name == "proxy-dev-subscription-request-logs" - error_message = "日志排除名称必须隔离环境。" + condition = google_logging_project_exclusion.subscription_requests.name == "proxy-subscription-request-logs" + error_message = "日志排除名称必须从完整 resource_prefix 推导。" } assert { condition = alltrue([ strcontains(google_logging_project_exclusion.subscription_requests.filter, "cloud_run_revision"), - strcontains(google_logging_project_exclusion.subscription_requests.filter, "proxy-dev-subscription"), + strcontains(google_logging_project_exclusion.subscription_requests.filter, "proxy-subscription"), strcontains(google_logging_project_exclusion.subscription_requests.filter, "run.googleapis.com/requests"), ]) error_message = "日志排除必须只匹配当前 subscription 服务的 Cloud Run request log。" diff --git a/infra/stacks/bootstrap/tests/contracts.tftest.hcl b/infra/stacks/bootstrap/tests/contracts.tftest.hcl index c35238a..7ab032d 100644 --- a/infra/stacks/bootstrap/tests/contracts.tftest.hcl +++ b/infra/stacks/bootstrap/tests/contracts.tftest.hcl @@ -70,11 +70,11 @@ run "identity_contract" { assert { condition = module.github_identity.environment_isolation_contract == { - pool_id = "${var.resource_prefix}-${var.environment == "production" ? "prod" : "dev"}-github" + pool_id = "${var.resource_prefix}-github" service_account_ids = { - plan = "${var.resource_prefix}-${var.environment == "production" ? "prod" : "dev"}-gh-plan" - apply = "${var.resource_prefix}-${var.environment == "production" ? "prod" : "dev"}-gh-apply" - deploy = "${var.resource_prefix}-${var.environment == "production" ? "prod" : "dev"}-gh-deploy" + plan = "${var.resource_prefix}-gh-plan" + apply = "${var.resource_prefix}-gh-apply" + deploy = "${var.resource_prefix}-gh-deploy" } environment_wif_members = { apply = "principal://iam.googleapis.com/projects/123456789/locations/global/workloadIdentityPools/mock-pool/subject/repo:${var.github_repository}:environment:${var.environment}" diff --git a/infra/stacks/bootstrap/variables.tf b/infra/stacks/bootstrap/variables.tf index b65df5f..963e0a3 100644 --- a/infra/stacks/bootstrap/variables.tf +++ b/infra/stacks/bootstrap/variables.tf @@ -34,7 +34,7 @@ variable "resource_prefix" { validation { condition = can(regex("^[a-z][a-z0-9-]{1,12}[a-z0-9]$", var.resource_prefix)) - error_message = "resource_prefix 必须是 3-14 位小写字母、数字或连字符。" + error_message = "resource_prefix 必须是完整资源名前缀,且为 3-14 位小写字母、数字或连字符。" } } diff --git a/infra/stacks/platform/tests/contracts.tftest.hcl b/infra/stacks/platform/tests/contracts.tftest.hcl index 276351f..4ad65d6 100644 --- a/infra/stacks/platform/tests/contracts.tftest.hcl +++ b/infra/stacks/platform/tests/contracts.tftest.hcl @@ -20,13 +20,13 @@ run "platform_contract" { command = plan assert { - condition = module.network.proxy_network_tag == "${var.resource_prefix}-${var.environment == "production" ? "prod" : "dev"}-proxy" - error_message = "proxy network tag 必须包含环境缩写。" + condition = module.network.proxy_network_tag == "${var.resource_prefix}-vm" + error_message = "proxy network tag 必须从完整 resource_prefix 派生。" } assert { - condition = module.artifact_registry.repository_id == "${var.resource_prefix}-${var.environment == "production" ? "prod" : "dev"}-images" - error_message = "Artifact Registry 名称必须包含环境缩写。" + condition = module.artifact_registry.repository_id == "${var.resource_prefix}-images" + error_message = "Artifact Registry 名称必须从完整 resource_prefix 派生。" } assert { @@ -54,7 +54,7 @@ run "platform_contract" { assert { - condition = output.subscription_request_log_exclusion_name == "${var.resource_prefix}-${var.environment == "production" ? "prod" : "dev"}-subscription-request-logs" - error_message = "platform 必须公开环境隔离的 subscription 请求日志排除名称。" + condition = output.subscription_request_log_exclusion_name == "${var.resource_prefix}-subscription-request-logs" + error_message = "platform 必须公开从完整 resource_prefix 派生的 subscription 请求日志排除名称。" } } diff --git a/infra/stacks/platform/variables.tf b/infra/stacks/platform/variables.tf index b65df5f..963e0a3 100644 --- a/infra/stacks/platform/variables.tf +++ b/infra/stacks/platform/variables.tf @@ -34,7 +34,7 @@ variable "resource_prefix" { validation { condition = can(regex("^[a-z][a-z0-9-]{1,12}[a-z0-9]$", var.resource_prefix)) - error_message = "resource_prefix 必须是 3-14 位小写字母、数字或连字符。" + error_message = "resource_prefix 必须是完整资源名前缀,且为 3-14 位小写字母、数字或连字符。" } } diff --git a/scripts/infra/test-guards.sh b/scripts/infra/test-guards.sh index ead179b..e6cd6c3 100755 --- a/scripts/infra/test-guards.sh +++ b/scripts/infra/test-guards.sh @@ -32,5 +32,12 @@ production_file=$(infra::require_environment production) development_project=$(infra::read_tfvar "$development_file" project_id) production_project=$(infra::read_tfvar "$production_file" project_id) [[ "$development_project" != "$production_project" ]] || infra::die "两个环境不能共用 GCP Project" +[[ "$development_project" == "kyson-proxy-dev" ]] || infra::die "development 必须使用 kyson-proxy-dev Project" +[[ "$(infra::read_tfvar "$development_file" resource_prefix)" == "proxy" ]] || infra::die "development 必须使用 proxy 完整资源名前缀" +[[ "$(infra::read_tfvar "$production_file" resource_prefix)" == "proxy" ]] || infra::die "production 必须使用 proxy 完整资源名前缀" + +if rg -q 'environment_short' "${INFRA_ROOT}/infra/modules"; then + infra::die "资源模块不得从 environment 自动追加资源名后缀" +fi printf '基础设施保护条件测试通过\n'