
Volcengine Landing Zone
- 25 installs
- 16 repo stars
- Updated August 3, 2026
- volcengine/volcengine-skills
Helps with ai & agent building tasks.
About
volcengine-landing-zone is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- volcengine-landing-zone
- AI & Agent Building
- AI-coding skill
Volcengine Landing Zone by the numbers
- 25 all-time installs (skills.sh)
- Ranked #9,740 of 16,556 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/volcengine/volcengine-skills --skill volcengine-landing-zoneAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 25 |
|---|---|
| repo stars | ★ 16 |
| Last updated | August 3, 2026 |
| Repository | volcengine/volcengine-skills ↗ |
What it does
Helps with ai & agent building tasks.
Files
Volcengine Landing Zone
This skill supports consulting, analysis, setup, and management tasks for a Volcengine Landing Zone, including organization design, account structure, finance relationships, identity, audit, and network foundations.
For clarity, the skill is organized as an intent router plus shared playbooks and path-specific guidebooks.
Path Anchors (Resolve Once Before Any Real Execution)
Relative paths in this skill such as ./skills/volcengine-landing-zone/... and ./volcengine-landing-zone-workspace/... are not relative to an arbitrary current working directory. Resolve them against these two absolute anchors once, then reuse them throughout the run before any preflight, login, Terraform, or write action:
SKILL_ROOT: the absolute install root of this skill, which contains thisSKILL.md. Read-only assets such as built-in blueprints, HTML templates,tos_activate.py, andbaseline.schema.jsonlive here. Any./skills/volcengine-landing-zone/<x>path resolves to${SKILL_ROOT}/<x>.WORKSPACE_ROOT: the writable runtime root for this run. By default it is<current working directory>/volcengine-landing-zone-workspace/, unless the user explicitly provides another writable location. Execution copies, output files, and baseline runtime state all live here. Any./volcengine-landing-zone-workspace/<x>path resolves to${WORKSPACE_ROOT}/<x>.
Use the resolved absolute paths for all later reads and writes. Do not rely on process cwd.
Local File Display
Whenever a local file must be placed in front of the user, such as a solution confirmation HTML file, login information, initial passwords, or a summary report, follow the protocol in display-protocol.md: copy into the workspace, then open the file for the user first; if opening is unavailable or fails, degrade to delivering its absolute path plus one short guidance line; in the worst case, retell the plan in chat as an explicitly marked degraded fallback. Keep the safety boundary, then stop and wait. G1, G4, STEP 0, and STEP 3 all reference that protocol.
Hard rule: display means delivering the file itself in an openable form, not paraphrasing or summarizing its contents in chat.
The priority is mandatory: open the file first (the default must-do action); only if opening is unavailable or fails, degrade to delivering the absolute path plus one short guidance line; only when neither is possible, retell the plan in chat as an explicitly marked degraded fallback. Do not read the file first and convert the body into a chat summary.
Entry Routing (Intent Router)
Once volcengine-landing-zone is activated, it must automatically route into the main path that matches the user's intent. Do not start by asking the user to choose a path. Ask a follow-up only when multiple paths truly match at the same time, or when the minimum information required to continue is missing.
| Trigger semantics | Route into |
|---|---|
landing zone 的方案/理念/最佳实践/阶段价值/实施顺序/组织设计建议/账号规划建议/是否值得做/怎么落地 | Consulting and Solution Design |
landing zone、首次搭建、组织、OU、核心账号、网络底座 | Initial Landing Zone Setup |
新增账号、开账号、创建账号、baseline、应用 baseline、设置基线、创建 baseline | Account Creation and Baseline Setup |
失败恢复、失败重试、修复失败执行、上次执行报错 | Failure Recovery |
Handling principles:
- The user does not need to understand directory layout, phase numbers, or blueprint structure first. The agent should directly take ownership of the goal and do the necessary reference reading and execution preparation.
Create baselineandapply baselineare not listed as a separate first-screen path, but if the user asks for them directly, take the request as-is and do not push them back into path selection.- Exception for Initial Landing Zone Setup: once routed into
Initial Landing Zone Setup, "execution preparation" does not include preflight, login, reading phase blueprints for warm-up, init, plan, or any write action. The only allowed first step is to display the solution confirmation file under G1 and stop. Before the user explicitly confirms the solution, do not pre-read phase blueprints and do not start any real execution.
Hard Gates Before Any Real Execution
Violating the literal wording of a hard gate also violates its intent. If any gate below is triggered, stop immediately no matter how simple the task feels, how urgent the user is, or whether some earlier step was already confirmed.
G1. Solution Confirmation (Initial Landing Zone Setup)
Trigger: the user has entered the real execution path for initial setup, and the solution confirmation file has not yet been displayed in this run. Action: before any preflight, login, Terraform, or write action, the first thing you do must be to follow display-protocol.md and put the solution confirmation file ./skills/volcengine-landing-zone/assets/html/landing-zone-solution-plan.html in front of the user: open it for the user first; only if opening is unavailable or fails, degrade to delivering its workspace absolute path plus a short guidance line; only when neither is possible, retell the plan in chat as an explicitly marked degraded fallback. Ask the user to confirm the solution or request changes, then stop and wait. The only allowed outward wording is one short guidance line such as "I have opened the solution confirmation file, please review it in the browser and confirm whether we should proceed". Do not output a body summary, section-by-section explanation, or key-point rewrite before the file is in front of the user. Forbidden: before the user explicitly confirms, do not start preflight, do not run init/plan/apply, do not "prepare things in the background first", and do not assume consent just because the user previously said they wanted a landing zone. Never treat a chat summary or paraphrase of the solution HTML as if the file had been displayed. The openable HTML file itself must be delivered. Self-check: have I delivered the solution HTML according to the display protocol, rather than merely retelling it in chat, and obtained explicit confirmation from the user? If not, stop now.
G1 Output Contract (Must Follow Literally)
For a direct setup request such as 帮我搭建火山引擎landingzone, your first user-facing turn in STEP 0 must follow this shape and nothing else:
1. put landing-zone-solution-plan.html in front of the user (open it first; degrade to its workspace absolute path if opening is unavailable or fails) 2. say one short guidance line asking the user to review the file 3. ask one confirmation question 4. stop and wait
The same turn must not contain any of the following before the user confirms:
- a solution summary
- a phase overview
- an explanation of organization / finance / identity / log / network design
- a proposed implementation sequence
- any preflight or login preparation language
Bad pattern:
I have reviewed the solution and here is the plan: ...
Good pattern:
Please review the solution confirmation HTML file I just opened for you. Do you want me to proceed with this plan, or would you like any changes first?
G2. Phase Confirmation (Every Deployment Task / Phase)
Trigger: you are about to enter a deployment phase. For 01-organization through 05-network, each phase is separate. For account factory, create account and apply baseline are separate phases. Action: before the phase starts, present the overall impact summary for that phase and get a dedicated confirmation. After confirmation, all write actions inside the phase run continuously without prompting for each individual write. Forbidden: do not ask the user to confirm each individual Terraform apply or CLI write action one by one. The user does not care about implementation granularity. Do not reuse a previous phase confirmation as a substitute for this phase. Self-check: do I have explicit confirmation for this phase? If not, stop.
G3. Workspace Isolation
Trigger: any Terraform or write action. Action: real execution happens only inside ${WORKSPACE_ROOT}/ (that is, ./volcengine-landing-zone-workspace/). See the path anchors above. Forbidden: do not execute from or write back into ${SKILL_ROOT}/assets/blueprints/. Built-in blueprint sources are always read-only. Self-check: am I running from a workspace copy under ${WORKSPACE_ROOT}, rather than from the built-in blueprint source? If not, stop.
G4. File Review Pause
Trigger: a phase produces a local file that the user needs to view, such as an initial password, login information, or a summary report. Action: deliver the file according to display-protocol.md, tell the user to review it, and pause there waiting for the next instruction. Forbidden: do not skip the pause because you want to show multiple files later together. G2 does not exempt this pause. Do not echo the full initial password in chat, and do not replace file delivery with a text restatement. Self-check: after producing a sensitive or user-review file, have I opened or delivered it according to the display protocol and then stopped? If not, stop.
G5. Consulting Is Read-Only
Trigger: the user intent is consulting, design, evaluation, or learning about concepts, ordering, or value. Action: provide explanation and recommendations only. Forbidden: do not run preflight, do not invoke ve login, do not run Terraform, and do not perform any write action, even if it would be convenient. Self-check: has the user clearly asked for real execution? If not, explain only and do not act.
G6. Authorization Does Not Carry Forward
Trigger: the workflow spans multiple steps and something earlier was already confirmed. Action: phases must be re-confirmed between steps. Forbidden: the existence of a checklist, an earlier path selection, or a previous phase confirmation does not mean later write actions are already authorized. Self-check: does the exact next step I want to run have its own authorization? If not, stop.
Danger Signals
- "The user already agreed earlier, so I don't need to confirm this step again" -> violates G2/G6
- "I'll prepare things in the background and confirm later" -> violates G1/G2
- "The solution is small enough, let's just do it" -> violates G1
- "The file is generated, I'll show it together with the next one later" -> violates G4
- "The user was only asking, but I executed it anyway" -> violates G5
Always design and implement against the official Volcengine Landing Zone methodology.
Paths
Consulting and Solution Design
- Use this path when the user wants to understand concepts, phase value, rollout order, organization design suggestions, or a practical landing path before real execution.
Initial Landing Zone Setup
The step order in this path is mandatory. Until STEP 0 is complete, meaning the user has explicitly confirmed the solution, do not enter STEP 1 or anything later, including preflight, login, reading phase blueprints for execution prep, init, plan, or apply.
- STEP 0. Display the Solution and Stop (G1, mandatory and the only allowed first step): after entering this path, the first thing you do is automatically put the solution confirmation document
./skills/volcengine-landing-zone/assets/html/landing-zone-solution-plan.htmlin front of the user per display-protocol.md (open it first; degrade to its workspace absolute path plus one guidance line if opening is unavailable or fails; retell in chat only as an explicitly marked last-resort fallback), ask the user to confirm the solution or request adjustments, then stop and wait. This step only allows "put the file in front of the user (open / degrade to path) + one guidance line + one confirmation question". Do not summarize the HTML content before sending it to the user. Before explicit confirmation arrives, do nothing else. - STEP 0 response budget is intentionally tiny: treat this as a file-delivery checkpoint, not a discussion turn. If your first reply contains any concrete solution details from the HTML body, you have already violated G1.
- STEP 1. Preflight Checks: after the user confirms the solution, run preflight-checks.md.
- STEP 2. Execute Phase by Phase: the overall workflow is in guidebook.md. Before each phase, follow G2 and obtain a dedicated confirmation. Phase documents:
- 01-organization.md
- 02-finance.md
- 03-identity.md
- 04-log.md
- 05-network.md
- STEP 3. Summary Report: after execution, generate the summary report using the template
./skills/volcengine-landing-zone/assets/html/landing-zone-setup-report-template.html.
Account Creation and Baseline Setup
- The overall execution flow is in guidebook.md.
Failure Recovery
- The recovery flow is in failure-recovery.md.
- Use this path for recovery of
landing-zone-setup,account-factory account create, andaccount-factory baseline applyfailures.
Reference Files
Soft vs. hard layering: all hard execution requirements such as solution confirmation, phase confirmation, workspace isolation, file pause, read-only consulting, and non-carrying authorization are defined only by G1-G6 above. The reference files below contain only path-specific details.interaction-contract.mdcovers outward communication and result style only. Eachguidebook.mdcontains only path-specific flow details and should reference hard-gate numbers instead of restating the rules.
- Outward communication and result conventions: interaction-contract.md
- Mechanical preflight checks: preflight-checks.md
- Landing Zone setup: guidebook.md
- Account factory workflow: guidebook.md
- Failure recovery: failure-recovery.md
terraform {
required_providers {
volcenginecc = {
source = "volcengine/volcenginecc"
version = ">= 0.0.41"
}
}
}
provider "volcenginecc" {
region = var.region
}
locals {
account_tags_cli_args = join(" ", flatten([
for idx, tag in var.account_tags : [
format("--Tags.%d.Key %s", idx + 1, jsonencode(tag.key)),
format("--Tags.%d.Value %s", idx + 1, jsonencode(tag.value))
]
]))
account_tag_validation_pairs = join("\n", [
for tag in var.account_tags : "${tag.key}\t${tag.value}"
])
}
resource "volcenginecc_organization_account" "account" {
account_name = var.account_name
show_name = var.show_name
org_unit_id = var.target_ou_id
}
resource "null_resource" "financial_relation" {
triggers = {
account_id = volcenginecc_organization_account.account.account_id
financial_relation_type = var.financial_relation_type
financial_relation_auth_list_str = var.financial_relation_auth_list_str
financial_relation_account_alias = var.financial_relation_account_alias
}
provisioner "local-exec" {
interpreter = ["/bin/sh", "-c"]
command = <<-EOT
set -eu
relation_name="${var.financial_relation_type}"
case "$relation_name" in
Financial_Hosting)
relation_code="1"
;;
Financial_Association)
relation_code="4"
;;
*)
echo "Unsupported financial relation type: $relation_name" >&2
exit 1
;;
esac
sub_account_id="${volcenginecc_organization_account.account.account_id}"
auth_list_str="${var.financial_relation_auth_list_str}"
requested_account_alias="${var.financial_relation_account_alias}"
account_alias="$requested_account_alias"
account_alias_source="user"
if [ -z "$account_alias" ]; then
account_alias="${var.account_name}"
account_alias_source="auto"
fi
fallback_account_alias="${var.account_name}-$sub_account_id"
list_body=$(printf '{"AccountIDSearchList":["%s"],"Relation":["%s"]}' "$sub_account_id" "$relation_code")
list_output=$(ve billing ListFinancialRelation --body "$list_body" 2>&1) || {
echo "Failed to list financial relation for account $sub_account_id" >&2
echo "$list_output" >&2
exit 1
}
if printf '%s' "$list_output" | grep -Eq "\"SubAccountI[dD]\"[[:space:]]*:[[:space:]]*\"?$sub_account_id\"?" &&
printf '%s' "$list_output" | grep -Eq "\"Relation\"[[:space:]]*:[[:space:]]*(\\[)?\"?$relation_code\"?(\\])?"; then
echo "Financial relation already exists for account $sub_account_id, skip"
exit 0
fi
# CreateFinancialRelation binds the master account from the current caller context.
build_create_body() {
create_account_alias="$1"
if [ -n "$auth_list_str" ] && [ -n "$create_account_alias" ]; then
printf '{"SubAccountID":%s,"Relation":%s,"AuthListStr":"%s","AccountAlias":"%s"}' "$sub_account_id" "$relation_code" "$auth_list_str" "$create_account_alias"
elif [ -n "$auth_list_str" ]; then
printf '{"SubAccountID":%s,"Relation":%s,"AuthListStr":"%s"}' "$sub_account_id" "$relation_code" "$auth_list_str"
elif [ -n "$create_account_alias" ]; then
printf '{"SubAccountID":%s,"Relation":%s,"AccountAlias":"%s"}' "$sub_account_id" "$relation_code" "$create_account_alias"
else
printf '{"SubAccountID":%s,"Relation":%s}' "$sub_account_id" "$relation_code"
fi
}
create_output=""
create_body="$(build_create_body "$account_alias")"
if create_output=$(ve billing CreateFinancialRelation --body "$create_body" 2>&1); then
:
elif printf '%s' "$create_output" | grep -Eqi 'OperationDenied\.AccountAliasExist|AccountAliasExist'; then
if [ "$account_alias_source" = "user" ]; then
echo "Financial relation alias conflict for account $sub_account_id: explicit AccountAlias '$account_alias' already exists" >&2
echo "$create_output" >&2
exit 1
fi
if [ "$fallback_account_alias" = "$account_alias" ]; then
echo "Financial relation alias conflict for account $sub_account_id and no alternate alias is available" >&2
echo "$create_output" >&2
exit 1
fi
account_alias="$fallback_account_alias"
create_body="$(build_create_body "$account_alias")"
if create_output=$(ve billing CreateFinancialRelation --body "$create_body" 2>&1); then
:
elif ! printf '%s' "$create_output" | grep -Eqi 'already exists|duplicate|重复|已存在'; then
echo "Failed to create financial relation for account $sub_account_id" >&2
echo "$create_output" >&2
exit 1
fi
elif ! printf '%s' "$create_output" | grep -Eqi 'already exists|duplicate|重复|已存在'; then
echo "Failed to create financial relation for account $sub_account_id" >&2
echo "$create_output" >&2
exit 1
fi
verify_output=$(ve billing ListFinancialRelation --body "$list_body" 2>&1) || {
echo "Failed to verify financial relation for account $sub_account_id" >&2
echo "$verify_output" >&2
exit 1
}
if ! printf '%s' "$verify_output" | grep -Eq "\"SubAccountI[dD]\"[[:space:]]*:[[:space:]]*\"?$sub_account_id\"?" ||
! printf '%s' "$verify_output" | grep -Eq "\"Relation\"[[:space:]]*:[[:space:]]*(\\[)?\"?$relation_code\"?(\\])?"; then
echo "Financial relation verification failed for account $sub_account_id" >&2
echo "$verify_output" >&2
exit 1
fi
echo "$create_output"
EOT
}
depends_on = [volcenginecc_organization_account.account]
}
resource "null_resource" "account_tags" {
count = length(var.account_tags) > 0 ? 1 : 0
triggers = {
account_id = volcenginecc_organization_account.account.account_id
account_tags = jsonencode(var.account_tags)
}
provisioner "local-exec" {
interpreter = ["/bin/sh", "-c"]
command = <<-EOT
set -eu
account_id="${volcenginecc_organization_account.account.account_id}"
tag_output=$(ve organization TagResources \
--ResourceIds.1 "$account_id" \
--ResourceType "account" \
${local.account_tags_cli_args} 2>&1) || {
echo "Failed to tag account $account_id" >&2
echo "$tag_output" >&2
exit 1
}
verify_output=$(ve organization ListTagResources \
--ResourceIds.1 "$account_id" \
--ResourceType "account" 2>&1) || {
echo "Failed to verify account tags for $account_id" >&2
echo "$verify_output" >&2
exit 1
}
while IFS="$(printf '\t')" read -r expected_key expected_value; do
[ -n "$expected_key" ] || continue
if ! printf '%s' "$verify_output" | grep -Eq "\"(TagKey|Key)\"[[:space:]]*:[[:space:]]*\"$expected_key\"" ||
! printf '%s' "$verify_output" | grep -Eq "\"(TagValue|Value)\"[[:space:]]*:[[:space:]]*\"$expected_value\""; then
echo "Account tag verification failed for $account_id: $expected_key=$expected_value" >&2
echo "$verify_output" >&2
exit 1
fi
done <<'EOF'
${local.account_tag_validation_pairs}
EOF
echo "$tag_output"
EOT
}
depends_on = [volcenginecc_organization_account.account]
}
output "account_id" {
description = "新创建账号的账号 ID"
value = volcenginecc_organization_account.account.account_id
}
output "account_name" {
description = "新创建账号的账号名"
value = volcenginecc_organization_account.account.account_name
}
output "show_name" {
description = "新创建账号的展示名称"
value = volcenginecc_organization_account.account.show_name
}
output "target_ou_id" {
description = "账号放置的目标 OU ID"
value = var.target_ou_id
}
variable "region" {
description = "火山引擎主 Region"
type = string
default = "cn-beijing"
}
variable "account_name" {
description = "待创建账号的登录名/唯一账号名"
type = string
}
variable "show_name" {
description = "待创建账号的展示名称"
type = string
}
variable "target_ou_id" {
description = "目标 OU ID,账号创建后将放置在该 OU 下"
type = string
}
variable "account_tags" {
description = "账号标签列表"
type = list(object({
key = string
value = string
}))
default = []
}
variable "financial_relation_type" {
description = "财务关系类型:Financial_Hosting 或 Financial_Association"
type = string
}
variable "financial_relation_auth_list_str" {
description = "可选授权列表,多个权限点以逗号分隔;为空时表示不附带授权列表"
type = string
default = ""
}
variable "financial_relation_account_alias" {
description = "可选财务关系账号别名;为空时默认使用 account_name,若自动生成的别名冲突会追加账号 ID 后缀重试一次"
type = string
default = ""
}
terraform {
required_providers {
null = {
source = "hashicorp/null"
version = ">= 3.2.1"
}
volcenginecc = {
source = "volcengine/volcenginecc"
version = ">= 0.0.41"
}
}
}
provider "volcenginecc" {
region = var.region
}
locals {
identity_payload = jsondecode(var.identity_payload_json)
config_payload = jsondecode(var.config_payload_json)
scp_payload = jsondecode(var.scp_payload_json)
custom_extensions = jsondecode(var.custom_extensions_json)
config_rules = try(local.config_payload.config_rules, [])
scp_policies = try(local.scp_payload.scp_policies, [])
network_payload = try(local.config_payload.network, {})
network_enabled = try(local.network_payload.enabled, false)
workspace_root = abspath(var.workspace_root)
workspace_parent = abspath("${local.workspace_root}/..")
blueprint_root = abspath("${path.module}/..")
runtime_root = "${local.workspace_root}/account-factory/runs/${var.current_account_id}"
config_summary = "${local.runtime_root}/config/summary.json"
scp_summary = "${local.runtime_root}/scp/summary.json"
network_run_dir = "${local.runtime_root}/network-cross-account-connectivity"
network_plan_txt = "${local.network_run_dir}/plan.txt"
network_apply_sh = "${local.network_run_dir}/apply.sh"
custom_runs_root = "${local.runtime_root}/custom-extensions"
enabled_config_rules = [
for rule in local.config_rules : rule
if try(rule.enabled, true)
]
enabled_scp_policies = [
for policy in local.scp_policies : policy
if try(policy.enabled, true)
]
identity_users = {
for user in try(local.identity_payload.users, []) :
user.username => {
username = user.username
display_name = try(user.display_name, user.username)
email = try(user.email, null)
description = try(user.description, null)
permission_sets = try(user.permission_sets, [])
}
}
identity_groups = {
for group in try(local.identity_payload.groups, []) :
group.name => {
name = group.name
display_name = try(group.display_name, group.name)
description = try(group.description, null)
join_type = try(group.join_type, "Manual")
permission_sets = try(group.permission_sets, [])
members = try(group.members, [])
}
}
explicit_assignments = [
for assignment in try(local.identity_payload.assignments, []) : {
key = "${assignment.principal_type}:${assignment.principal_name}:${assignment.permission_set}"
principal_type = assignment.principal_type
principal_name = assignment.principal_name
permission_set = assignment.permission_set
scope = try(assignment.scope, "current_account")
}
]
derived_user_assignments = flatten([
for username, user in local.identity_users : [
for permission_set in user.permission_sets : {
key = "User:${username}:${permission_set}"
principal_type = "User"
principal_name = username
permission_set = permission_set
scope = "current_account"
}
]
])
derived_group_assignments = flatten([
for group_name, group in local.identity_groups : [
for permission_set in group.permission_sets : {
key = "Group:${group_name}:${permission_set}"
principal_type = "Group"
principal_name = group_name
permission_set = permission_set
scope = "current_account"
}
]
])
effective_assignments = {
for assignment in concat(
local.derived_user_assignments,
local.derived_group_assignments,
local.explicit_assignments
) :
assignment.key => assignment
if assignment.scope == "current_account"
}
permission_set_names = toset(distinct(concat(
flatten([for _, user in local.identity_users : user.permission_sets]),
flatten([for _, group in local.identity_groups : group.permission_sets]),
[for _, assignment in local.effective_assignments : assignment.permission_set]
)))
permission_sets = {
for name in local.permission_set_names :
name => {
name = name
description = "Account Factory baseline managed permission set ${name}"
session_duration = "PT4H"
permission_policies = [{
permission_policy_name = name
permission_policy_type = "System"
permission_policy_document = ""
}]
}
}
custom_extension_map = {
for extension in local.custom_extensions :
extension.name => {
name = extension.name
type = try(extension.type, "terraform")
source = extension.source
source_path = startswith(extension.source, "/") ? extension.source : (
startswith(extension.source, "volcengine-landing-zone-workspace/")
? "${local.workspace_parent}/${extension.source}"
: "${local.workspace_root}/${extension.source}"
)
description = try(extension.description, "")
apply_after = try(extension.apply_after, [])
}
}
custom_extension_runs = {
for name, extension in local.custom_extension_map :
name => {
safe_name = regexreplace(name, "[^0-9A-Za-z._-]", "-")
runtime_dir = "${local.custom_runs_root}/${regexreplace(name, "[^0-9A-Za-z._-]", "-")}"
plan_summary_path = "${local.custom_runs_root}/${regexreplace(name, "[^0-9A-Za-z._-]", "-")}/plan.txt"
apply_script_path = "${local.custom_runs_root}/${regexreplace(name, "[^0-9A-Za-z._-]", "-")}/apply.sh"
}
}
}
# Cloud Identity resources in this module are generated dynamically from the
# merged baseline payload. Terraform cannot safely express a per-instance
# serial chain for these dynamic resources, so callers should run this module
# with `terraform plan/apply -parallelism=1` to avoid Volcengine control-plane
# ConcurrentException responses during identity writes.
resource "volcenginecc_cloudidentity_permission_set" "baseline" {
for_each = local.permission_sets
name = each.value.name
description = each.value.description
session_duration = each.value.session_duration
permission_policies = [
for policy in each.value.permission_policies : {
permission_policy_name = policy.permission_policy_name
permission_policy_type = policy.permission_policy_type
permission_policy_document = policy.permission_policy_document
}
]
}
resource "volcenginecc_cloudidentity_user" "baseline" {
for_each = local.identity_users
user_name = each.value.username
display_name = each.value.display_name
description = each.value.description
email = each.value.email
}
resource "volcenginecc_cloudidentity_group" "baseline" {
for_each = local.identity_groups
group_name = each.value.name
display_name = each.value.display_name
description = each.value.description
join_type = each.value.join_type
members = [
for member_name in each.value.members : {
user_id = volcenginecc_cloudidentity_user.baseline[member_name].user_id
}
]
}
resource "volcenginecc_cloudidentity_permission_set_assignment" "baseline" {
for_each = local.effective_assignments
permission_set_id = volcenginecc_cloudidentity_permission_set.baseline[each.value.permission_set].permission_set_id
principal_type = each.value.principal_type
principal_id = (
each.value.principal_type == "Group"
? volcenginecc_cloudidentity_group.baseline[each.value.principal_name].group_id
: volcenginecc_cloudidentity_user.baseline[each.value.principal_name].user_id
)
target_id = var.current_account_id
}
resource "volcenginecc_cloudidentity_permission_set_provisioning" "baseline" {
for_each = {
for permission_set_name in toset([
for _, assignment in local.effective_assignments : assignment.permission_set
]) :
permission_set_name => permission_set_name
}
permission_set_id = volcenginecc_cloudidentity_permission_set.baseline[each.key].permission_set_id
target_id = var.current_account_id
depends_on = [volcenginecc_cloudidentity_permission_set_assignment.baseline]
}
resource "null_resource" "identity_stage" {
triggers = {
current_account_id = var.current_account_id
user_count = tostring(length(local.identity_users))
group_count = tostring(length(local.identity_groups))
assignment_count = tostring(length(local.effective_assignments))
permission_set_count = tostring(length(local.permission_sets))
}
provisioner "local-exec" {
interpreter = ["/bin/sh", "-c"]
command = <<-EOT
set -eu
echo "Identity stage completed for account ${var.current_account_id}"
echo "Users: ${length(local.identity_users)}"
echo "Groups: ${length(local.identity_groups)}"
echo "Assignments: ${length(local.effective_assignments)}"
echo "Permission sets: ${length(local.permission_sets)}"
EOT
}
depends_on = [
volcenginecc_cloudidentity_permission_set.baseline,
volcenginecc_cloudidentity_user.baseline,
volcenginecc_cloudidentity_group.baseline,
volcenginecc_cloudidentity_permission_set_assignment.baseline,
volcenginecc_cloudidentity_permission_set_provisioning.baseline,
]
}
resource "null_resource" "config_stage" {
triggers = {
current_account_id = var.current_account_id
config_rule_count = tostring(length(local.enabled_config_rules))
config_payload = var.config_payload_json
}
provisioner "local-exec" {
interpreter = ["/bin/sh", "-c"]
command = <<-EOT
set -eu
summary_file="${local.config_summary}"
mkdir -p "$(dirname "$summary_file")"
printf '%s\n' '${jsonencode({
stage = "config"
status = "dry_run_only"
current_account_id = var.current_account_id
enabled_rule_count = length(local.enabled_config_rules)
enabled_rules = local.enabled_config_rules
})}' > "$summary_file"
echo "Config stage prepared dry-run summary for account ${var.current_account_id}"
echo "Enabled config rules: ${length(local.enabled_config_rules)}"
echo "Summary file: $summary_file"
EOT
}
depends_on = [null_resource.identity_stage]
}
resource "null_resource" "scp_stage" {
triggers = {
current_account_id = var.current_account_id
scp_policy_count = tostring(length(local.enabled_scp_policies))
scp_payload = var.scp_payload_json
}
provisioner "local-exec" {
interpreter = ["/bin/sh", "-c"]
command = <<-EOT
set -eu
summary_file="${local.scp_summary}"
mkdir -p "$(dirname "$summary_file")"
printf '%s\n' '${jsonencode({
stage = "scp"
status = "dry_run_only"
current_account_id = var.current_account_id
enabled_policy_count = length(local.enabled_scp_policies)
enabled_policies = local.enabled_scp_policies
})}' > "$summary_file"
echo "SCP stage prepared dry-run summary for account ${var.current_account_id}"
echo "Enabled SCP policies: ${length(local.enabled_scp_policies)}"
echo "Summary file: $summary_file"
EOT
}
depends_on = [null_resource.config_stage]
}
resource "null_resource" "network_module_stage" {
triggers = {
current_account_id = var.current_account_id
network_enabled = tostring(local.network_enabled)
network_payload = jsonencode(local.network_payload)
}
provisioner "local-exec" {
interpreter = ["/bin/sh", "-c"]
command = <<-EOT
set -eu
run_dir="${local.network_run_dir}"
summary_file="${local.network_plan_txt}"
apply_script="${local.network_apply_sh}"
module_dir="${local.blueprint_root}/baseline-modules/network-cross-account-connectivity"
mkdir -p "${local.runtime_root}"
if [ "${tostring(local.network_enabled)}" != "true" ]; then
mkdir -p "$run_dir"
printf '%s\n' '${jsonencode({
stage = "network"
status = "skipped"
current_account_id = var.current_account_id
enabled = false
})}' > "$summary_file"
rm -f "$apply_script"
echo "Network module not enabled for this baseline"
exit 0
fi
if [ ! -d "$module_dir" ]; then
echo "Network module directory does not exist: $module_dir" >&2
exit 1
fi
if [ ! -f "$module_dir/main.tf" ]; then
echo "Network module directory missing main.tf: $module_dir" >&2
exit 1
fi
rm -rf "$run_dir"
mkdir -p "$run_dir"
cp -R "$module_dir"/. "$run_dir"/
rm -rf "$run_dir/.terraform"
rm -f "$run_dir/terraform.tfstate" "$run_dir/terraform.tfstate.backup" "$run_dir/tfplan"
workload_vpc_cidr='${try(local.network_payload.workload_vpc_cidr, "")}'
workload_subnet_cidr_az_a='${try(local.network_payload.workload_subnet_cidr_az_a, "")}'
workload_subnet_cidr_az_b='${try(local.network_payload.workload_subnet_cidr_az_b, "")}'
if [ -z "$workload_vpc_cidr" ] || [ -z "$workload_subnet_cidr_az_a" ] || [ -z "$workload_subnet_cidr_az_b" ]; then
echo "network baseline requires workload_vpc_cidr, workload_subnet_cidr_az_a, and workload_subnet_cidr_az_b" >&2
exit 1
fi
terraform -chdir="$run_dir" init -backend=false
terraform -chdir="$run_dir" plan -out=tfplan \
-var "current_account_id=${var.current_account_id}" \
-var "network_account_id=${try(local.network_payload.network_account_id, "")}" \
-var "transit_router_id=${try(local.network_payload.transit_router_id, "")}" \
-var "workload_vpc_cidr=$workload_vpc_cidr" \
-var "workload_subnet_cidr_az_a=$workload_subnet_cidr_az_a" \
-var "workload_subnet_cidr_az_b=$workload_subnet_cidr_az_b" \
-var "availability_zone_a=${try(local.network_payload.availability_zone_a, "cn-beijing-a")}" \
-var "availability_zone_b=${try(local.network_payload.availability_zone_b, "cn-beijing-b")}" \
-var "attach_to_shared_network=${tostring(try(local.network_payload.attach_to_shared_network, true))}"
terraform -chdir="$run_dir" show -no-color tfplan > "$summary_file"
cat > "$apply_script" <<'EOF'
#!/bin/sh
set -eu
terraform -chdir="${local.network_run_dir}" apply tfplan
EOF
chmod +x "$apply_script"
echo "Network module plan prepared for account ${var.current_account_id}"
echo "Plan summary: $summary_file"
echo "Apply script: $apply_script"
EOT
}
depends_on = [null_resource.scp_stage]
}
resource "null_resource" "custom_terraform_extension" {
for_each = local.custom_extension_map
triggers = {
current_account_id = var.current_account_id
extension_name = each.value.name
extension_type = each.value.type
extension_source = each.value.source
}
provisioner "local-exec" {
interpreter = ["/bin/sh", "-c"]
command = <<-EOT
set -eu
extension_source="${each.value.source}"
extension_dir="${each.value.source_path}"
run_dir="${local.custom_extension_runs[each.key].runtime_dir}"
summary_file="${local.custom_extension_runs[each.key].plan_summary_path}"
apply_script="${local.custom_extension_runs[each.key].apply_script_path}"
echo "Preparing custom terraform extension ${each.value.name} for account ${var.current_account_id}"
echo "Resolved directory: $extension_dir"
if [ "${each.value.type}" != "terraform" ]; then
echo "Unsupported custom extension type: ${each.value.type}" >&2
exit 1
fi
if [ ! -d "$extension_dir" ]; then
echo "Custom extension directory does not exist: $extension_dir" >&2
exit 1
fi
if [ ! -f "$extension_dir/main.tf" ]; then
echo "Custom extension directory missing main.tf: $extension_dir" >&2
exit 1
fi
rm -rf "$run_dir"
mkdir -p "$run_dir"
cp -R "$extension_dir"/. "$run_dir"/
rm -rf "$run_dir/.terraform"
rm -f "$run_dir/terraform.tfstate" "$run_dir/terraform.tfstate.backup" "$run_dir/tfplan"
terraform -chdir="$run_dir" init -backend=false
terraform -chdir="$run_dir" plan -out=tfplan
terraform -chdir="$run_dir" show -no-color tfplan > "$summary_file"
cat > "$apply_script" <<'EOF'
#!/bin/sh
set -eu
terraform -chdir="${local.custom_extension_runs[each.key].runtime_dir}" apply tfplan
EOF
chmod +x "$apply_script"
echo "Plan summary: $summary_file"
echo "Apply script: $apply_script"
EOT
}
depends_on = [null_resource.network_module_stage]
}
resource "null_resource" "custom_terraform_extensions_stage" {
triggers = {
current_account_id = var.current_account_id
extension_count = tostring(length(local.custom_extension_map))
extension_names = join(",", sort(keys(local.custom_extension_map)))
}
provisioner "local-exec" {
interpreter = ["/bin/sh", "-c"]
command = <<-EOT
set -eu
echo "Custom Terraform extensions plans prepared for account ${var.current_account_id}"
echo "Extensions: ${join(", ", sort(keys(local.custom_extension_map)))}"
EOT
}
depends_on = [null_resource.custom_terraform_extension]
}
output "baseline_names" {
description = "本次应用的 baseline 文件名列表"
value = var.baseline_names
}
output "identity_stage_status" {
description = "identity 段状态(当前第一交付切片已真实落地)"
value = "implemented"
}
output "config_stage_status" {
description = "config 段状态(当前仅输出待应用规则摘要,尚未真实下发)"
value = "dry_run_only"
}
output "scp_stage_status" {
description = "scp 段状态(当前仅输出待应用策略摘要,尚未真实下发)"
value = "dry_run_only"
}
output "network_stage_status" {
description = "network 模块段状态(当前生成独立 plan 与 apply 脚本,需用户确认后执行)"
value = local.network_enabled ? "plan_prepared" : "skipped"
}
output "custom_extensions_stage_status" {
description = "custom terraform extensions 段状态(当前生成独立 plan 与 apply 脚本,需用户确认后执行)"
value = length(local.custom_extension_map) > 0 ? "plan_prepared" : "not_requested"
}
output "baseline_execution_mode" {
description = "baseline 执行模式说明"
value = "plan_prepared_requires_user_confirmation"
}
output "runtime_root" {
description = "baseline 本次运行的本地运行态目录"
value = local.runtime_root
}
output "config_summary_path" {
description = "config 段摘要文件路径"
value = local.config_summary
}
output "scp_summary_path" {
description = "scp 段摘要文件路径"
value = local.scp_summary
}
output "network_plan_summary_path" {
description = "network 模块 plan 摘要文件路径"
value = local.network_plan_txt
}
output "network_apply_script_path" {
description = "network 模块 apply 脚本路径"
value = local.network_enabled ? local.network_apply_sh : null
}
output "custom_extension_plan_summary_paths" {
description = "custom terraform extensions 的 plan 摘要文件路径映射"
value = {
for name, run in local.custom_extension_runs :
name => run.plan_summary_path
}
}
output "custom_extension_apply_script_paths" {
description = "custom terraform extensions 的 apply 脚本路径映射"
value = {
for name, run in local.custom_extension_runs :
name => run.apply_script_path
}
}
output "identity_usernames" {
description = "identity 阶段解析并管理的用户名列表"
value = sort(keys(local.identity_users))
}
output "identity_group_names" {
description = "identity 阶段解析并管理的组名列表"
value = sort(keys(local.identity_groups))
}
output "identity_permission_set_names" {
description = "identity 阶段解析并管理的权限集列表"
value = sort(keys(local.permission_sets))
}
output "custom_extension_names" {
description = "custom terraform extensions 阶段解析出的扩展名称列表"
value = sort(keys(local.custom_extension_map))
}
output "identity_user_ids" {
description = "identity 阶段创建的用户 ID 映射"
value = {
for username, resource in volcenginecc_cloudidentity_user.baseline :
username => resource.user_id
}
}
output "identity_group_ids" {
description = "identity 阶段创建的组 ID 映射"
value = {
for group_name, resource in volcenginecc_cloudidentity_group.baseline :
group_name => resource.group_id
}
}
variable "region" {
description = "火山引擎主 Region"
type = string
default = "cn-beijing"
}
variable "workspace_root" {
description = "运行根目录绝对路径,对应 ./volcengine-landing-zone-workspace"
type = string
}
variable "current_account_id" {
description = "当前新创建账号 ID,baseline 将应用到该账号"
type = string
}
variable "baseline_names" {
description = "由 agent 读取并确认后的 baseline 名称列表;用于结果摘要与回显"
type = list(string)
default = []
}
variable "identity_payload_json" {
description = "由 agent 基于 baseline 模块与变量结果归一化后的 identity 段 JSON;当前消费 users、groups、assignments"
type = string
default = "{}"
}
variable "config_payload_json" {
description = "由 agent 基于 baseline 模块与变量结果归一化后的 config 段 JSON;当前消费 config_rules 与 network"
type = string
default = "{}"
}
variable "scp_payload_json" {
description = "由 agent 基于 baseline 模块与变量结果归一化后的 scp 段 JSON;当前只输出启用 SCP 策略摘要,不直接下发"
type = string
default = "{}"
}
variable "custom_extensions_json" {
description = "由 agent 基于 baseline 模块与变量结果归一化后的 custom extensions JSON;当前按扩展目录准备独立 plan 与 apply 脚本"
type = string
default = "[]"
}
terraform {
required_providers {
volcenginecc = {
source = "volcengine/volcenginecc"
version = ">= 0.0.41"
}
}
}
resource "volcenginecc_vpc_vpc" "workload" {
cidr_block = var.workload_vpc_cidr
vpc_name = "${var.current_account_id}-workload-vpc"
description = "Account Factory workload VPC attached to shared transit router"
}
resource "volcenginecc_vpc_subnet" "workload_az_a" {
vpc_id = volcenginecc_vpc_vpc.workload.id
zone_id = var.availability_zone_a
cidr_block = var.workload_subnet_cidr_az_a
subnet_name = "${var.current_account_id}-workload-subnet-a"
}
resource "volcenginecc_vpc_subnet" "workload_az_b" {
vpc_id = volcenginecc_vpc_vpc.workload.id
zone_id = var.availability_zone_b
cidr_block = var.workload_subnet_cidr_az_b
subnet_name = "${var.current_account_id}-workload-subnet-b"
depends_on = [volcenginecc_vpc_subnet.workload_az_a]
}
resource "null_resource" "workload_transitrouter_service_linked_role" {
count = var.attach_to_shared_network ? 1 : 0
provisioner "local-exec" {
interpreter = ["/bin/sh", "-c"]
command = <<-EOT
set -eu
create_role_output="$(mktemp)"
if ve iam CreateServiceLinkedRole --ServiceName transitrouter >"$create_role_output" 2>&1; then
rm -f "$create_role_output"
exit 0
fi
if grep -q "RoleAlreadyExists" "$create_role_output"; then
rm -f "$create_role_output"
exit 0
fi
cat "$create_role_output" >&2
rm -f "$create_role_output"
exit 1
EOT
}
}
resource "volcenginecc_transitrouter_vpc_attachment" "workload" {
count = var.attach_to_shared_network ? 1 : 0
transit_router_id = var.transit_router_id
vpc_id = volcenginecc_vpc_vpc.workload.id
transit_router_attachment_name = "${var.current_account_id}-workload-attach"
description = "Account Factory workload VPC attachment"
auto_publish_route_enabled = true
attach_points = [
{
subnet_id = volcenginecc_vpc_subnet.workload_az_a.id
zone_id = var.availability_zone_a
},
{
subnet_id = volcenginecc_vpc_subnet.workload_az_b.id
zone_id = var.availability_zone_b
}
]
depends_on = [null_resource.workload_transitrouter_service_linked_role]
}
output "workload_vpc_id" {
description = "业务账号 VPC ID"
value = volcenginecc_vpc_vpc.workload.id
}
output "workload_subnet_az_a_id" {
description = "业务账号可用区 A 子网 ID"
value = volcenginecc_vpc_subnet.workload_az_a.id
}
output "workload_subnet_az_b_id" {
description = "业务账号可用区 B 子网 ID"
value = volcenginecc_vpc_subnet.workload_az_b.id
}
output "workload_vpc_attachment_id" {
description = "业务账号 VPC 接入统一网络的 attachment ID"
value = try(volcenginecc_transitrouter_vpc_attachment.workload[0].id, null)
}
variable "network_account_id" {
description = "统一网络账号 ID"
type = string
}
variable "current_account_id" {
description = "当前新创建账号 ID,用于资源命名与输出关联"
type = string
}
variable "transit_router_id" {
description = "统一网络中转路由器 ID"
type = string
}
variable "workload_vpc_cidr" {
description = "业务账号 VPC CIDR"
type = string
}
variable "workload_subnet_cidr_az_a" {
description = "业务账号可用区 A 子网 CIDR"
type = string
}
variable "workload_subnet_cidr_az_b" {
description = "业务账号可用区 B 子网 CIDR"
type = string
}
variable "availability_zone_a" {
description = "业务账号可用区 A"
type = string
default = "cn-beijing-a"
}
variable "availability_zone_b" {
description = "业务账号可用区 B"
type = string
default = "cn-beijing-b"
}
variable "attach_to_shared_network" {
description = "是否接入统一网络底座"
type = bool
default = true
}
Account Factory Baseline Modules
本目录用于保存 Account Factory 的 baseline 预置模块。
设计原则
- 预置模块与用户自定义 Terraform 模块在 baseline
*.baseline.json文件中使用同一种modules引用模型 - 两者的区别只在于
source指向的位置不同 - 预置模块用于降低首次创建 baseline 的门槛
当前模块
network-cross-account-connectivity
terraform {
required_providers {
volcenginecc = {
source = "volcengine/volcenginecc"
version = ">= 0.0.41"
}
}
}
provider "volcenginecc" {
region = var.region
}
locals {
platform_ou_id = coalesce(var.existing_platform_ou_id, try(volcenginecc_organization_unit.platform[0].org_unit_id, null))
applications_ou_id = coalesce(var.existing_applications_ou_id, try(volcenginecc_organization_unit.applications[0].org_unit_id, null))
sandbox_ou_id = coalesce(var.existing_sandbox_ou_id, try(volcenginecc_organization_unit.sandbox[0].org_unit_id, null))
applications_dev_ou_id = coalesce(var.existing_applications_dev_ou_id, try(volcenginecc_organization_unit.applications_dev[0].org_unit_id, null))
applications_staging_ou_id = coalesce(var.existing_applications_staging_ou_id, try(volcenginecc_organization_unit.applications_staging[0].org_unit_id, null))
applications_prod_ou_id = coalesce(var.existing_applications_prod_ou_id, try(volcenginecc_organization_unit.applications_prod[0].org_unit_id, null))
}
# The Root OU ID must be resolved before apply.
# On a first-time setup, create the organization via
# `ve organization CreateOrganization --body '{}'` first, then use
# `ve organization ListOrganizationalUnits --body '{}'` to find the
# item whose Name is `Root` and Depth is `0`.
# If the organization already contains the standard OU layout,
# the caller is expected to discover those OU IDs before apply and
# pass them through the `existing_*_ou_id` inputs so Terraform reuses
# existing OUs instead of attempting to recreate them.
# --- Top-level OUs ---
resource "volcenginecc_organization_unit" "platform" {
count = var.existing_platform_ou_id == null ? 1 : 0
parent_id = var.root_ou_id
name = "Platform"
}
resource "volcenginecc_organization_unit" "applications" {
count = var.existing_applications_ou_id == null ? 1 : 0
parent_id = var.root_ou_id
name = "Applications"
depends_on = [volcenginecc_organization_unit.platform]
}
resource "volcenginecc_organization_unit" "sandbox" {
count = var.existing_sandbox_ou_id == null ? 1 : 0
parent_id = var.root_ou_id
name = "SandBox"
depends_on = [volcenginecc_organization_unit.applications]
}
resource "volcenginecc_organization_unit" "applications_dev" {
count = var.existing_applications_dev_ou_id == null ? 1 : 0
parent_id = local.applications_ou_id
name = "Dev"
depends_on = [volcenginecc_organization_unit.sandbox]
}
resource "volcenginecc_organization_unit" "applications_staging" {
count = var.existing_applications_staging_ou_id == null ? 1 : 0
parent_id = local.applications_ou_id
name = "Staging"
depends_on = [volcenginecc_organization_unit.applications_dev]
}
resource "volcenginecc_organization_unit" "applications_prod" {
count = var.existing_applications_prod_ou_id == null ? 1 : 0
parent_id = local.applications_ou_id
name = "Prod"
depends_on = [volcenginecc_organization_unit.applications_staging]
}
# --- Core Accounts (account_name max 20 chars) ---
resource "volcenginecc_organization_account" "log_archive" {
account_name = "${var.prefix}-Log"
show_name = "${var.prefix}-LogArchiveAccount"
org_unit_id = local.platform_ou_id
depends_on = [volcenginecc_organization_unit.applications_prod]
}
resource "volcenginecc_organization_account" "security" {
account_name = "${var.prefix}-Sec"
show_name = "${var.prefix}-SecurityAccount"
org_unit_id = local.platform_ou_id
depends_on = [volcenginecc_organization_account.log_archive]
}
resource "volcenginecc_organization_account" "shared_service" {
account_name = "${var.prefix}-Shared"
show_name = "${var.prefix}-SharedServiceAccount"
org_unit_id = local.platform_ou_id
depends_on = [volcenginecc_organization_account.security]
}
resource "volcenginecc_organization_account" "network" {
account_name = "${var.prefix}-Net"
show_name = "${var.prefix}-NetworkAccount"
org_unit_id = local.platform_ou_id
depends_on = [volcenginecc_organization_account.shared_service]
}
resource "volcenginecc_organization_account" "sandbox_test" {
account_name = "${var.prefix}-SandBox"
show_name = "${var.prefix}-SandBoxTestAccount"
org_unit_id = local.sandbox_ou_id
depends_on = [volcenginecc_organization_account.network]
}
output "ou_platform_id" {
description = "Platform OU ID"
value = local.platform_ou_id
}
output "ou_applications_id" {
description = "Applications OU ID"
value = local.applications_ou_id
}
output "ou_applications_dev_id" {
description = "Applications Dev OU ID"
value = local.applications_dev_ou_id
}
output "ou_applications_staging_id" {
description = "Applications Staging OU ID"
value = local.applications_staging_ou_id
}
output "ou_applications_prod_id" {
description = "Applications Prod OU ID"
value = local.applications_prod_ou_id
}
output "ou_sandbox_id" {
description = "SandBox OU ID"
value = local.sandbox_ou_id
}
output "account_id_log_archive" {
description = "LogArchive 账号 ID"
value = volcenginecc_organization_account.log_archive.account_id
}
output "account_id_security" {
description = "Security 账号 ID"
value = volcenginecc_organization_account.security.account_id
}
output "account_id_shared_service" {
description = "SharedService 账号 ID"
value = volcenginecc_organization_account.shared_service.account_id
}
output "account_id_network" {
description = "Network 账号 ID"
value = volcenginecc_organization_account.network.account_id
}
output "account_id_sandbox_test" {
description = "SandBoxTest 账号 ID"
value = volcenginecc_organization_account.sandbox_test.account_id
}
prefix = "example"
region = "cn-beijing"
root_ou_id = "ou-xxxxxxxx"
# This example matches the refactored Landing Zone org layout:
# - Top-level OUs: Platform, Applications, SandBox
# - Applications sub OUs: Dev, Staging, Prod
# - Platform accounts: LogArchive, Security, SharedService, Network
#
# If these standard OUs already exist, the recommended flow is to let the
# skill discover them first and auto-fill the corresponding
# existing_*_ou_id variables before apply.
variable "region" {
description = "火山引擎主 Region"
type = string
default = "cn-beijing"
}
variable "prefix" {
description = "企业名称前缀,用于账号命名"
type = string
validation {
condition = can(regex("^[A-Za-z0-9-]+$", var.prefix)) && length(var.prefix) >= 2 && length(var.prefix) <= 12
error_message = "prefix must be 2-12 characters long and contain only letters, numbers, or hyphens."
}
}
variable "root_ou_id" {
description = "Root OU ID (from `ve organization ListOrganizationalUnits --body '{}'`, where Name=`Root` and Depth=`0`)"
type = string
}
variable "existing_platform_ou_id" {
description = "可选,已存在的 Platform OU ID;提供后将直接复用,不再重复创建。标准流程下建议由 skill 在执行前自动扫描现有 OU 并注入。"
type = string
default = null
}
variable "existing_applications_ou_id" {
description = "可选,已存在的 Applications OU ID;提供后将直接复用,不再重复创建。标准流程下建议由 skill 在执行前自动扫描现有 OU 并注入。"
type = string
default = null
}
variable "existing_sandbox_ou_id" {
description = "可选,已存在的 SandBox OU ID;提供后将直接复用,不再重复创建。标准流程下建议由 skill 在执行前自动扫描现有 OU 并注入。"
type = string
default = null
}
variable "existing_applications_dev_ou_id" {
description = "可选,已存在的 Applications/Dev OU ID;提供后将直接复用,不再重复创建。标准流程下建议由 skill 在执行前自动扫描现有 OU 并注入。"
type = string
default = null
}
variable "existing_applications_staging_ou_id" {
description = "可选,已存在的 Applications/Staging OU ID;提供后将直接复用,不再重复创建。标准流程下建议由 skill 在执行前自动扫描现有 OU 并注入。"
type = string
default = null
}
variable "existing_applications_prod_ou_id" {
description = "可选,已存在的 Applications/Prod OU ID;提供后将直接复用,不再重复创建。标准流程下建议由 skill 在执行前自动扫描现有 OU 并注入。"
type = string
default = null
}
terraform {
required_providers {
volcenginecc = {
source = "volcengine/volcenginecc"
version = ">= 0.0.41"
}
}
}
provider "volcenginecc" {
region = var.region
}
locals {
# financial_relation_accounts 的 key 是语义标识(如 log_archive、security、shared_service、network、sandbox_test),
# 实际 AccountAlias = <prefix>-<key>,与 01-organization 阶段的账号命名规则保持一致,
# 避免与其他执行或已有账号 alias 冲突。
financial_relation_order = sort(keys(var.financial_relation_accounts))
financial_relation_pairs = join("\n", [
for semantic_key in local.financial_relation_order :
"${semantic_key}\t${var.financial_relation_accounts[semantic_key]}\t${var.prefix}-${semantic_key}"
])
}
# ---------------------------------------------------------------
# 阶段 2:财务关系
# 火山引擎 Terraform Provider 当前不提供财务托管/财务关联资源,
# 因此本阶段使用 null_resource + local-exec 调用 ve CLI 完成。
# ---------------------------------------------------------------
resource "null_resource" "financial_relation" {
provisioner "local-exec" {
interpreter = ["/bin/sh", "-c"]
command = <<-EOT
set -eu
relation_name="${var.financial_relation_type}"
case "$relation_name" in
Financial_Hosting)
relation_code="1"
;;
Financial_Association)
relation_code="4"
;;
*)
echo "Unsupported financial relation type: $relation_name" >&2
exit 1
;;
esac
auth_list_str='${var.financial_relation_auth_list_str}'
# 每行三列:语义标识 + 账号ID + 带前缀的 AccountAlias
# AccountAlias 由 <prefix>-<semantic_key> 自动拼接,与 01-organization 阶段账号命名规则保持一致
while IFS="$(printf '\t')" read -r semantic_key sub_account_id account_alias; do
[ -n "$sub_account_id" ] || continue
echo "Checking financial relation for alias=$account_alias sub_account_id=$sub_account_id relation=$relation_name($relation_code)"
list_body=$(printf '{"AccountIDSearchList":["%s"],"Relation":["%s"]}' "$sub_account_id" "$relation_code")
list_output=$(ve billing ListFinancialRelation --body "$list_body" 2>&1) || {
echo "Failed to list financial relation for sub account $sub_account_id" >&2
echo "$list_output" >&2
exit 1
}
if printf '%s' "$list_output" | grep -Eq "\"SubAccountI[dD]\"[[:space:]]*:[[:space:]]*\"?$sub_account_id\"?" &&
printf '%s' "$list_output" | grep -Eq "\"Relation\"[[:space:]]*:[[:space:]]*(\\[)?\"?$relation_code\"?(\\])?"; then
echo "Financial relation already exists, skip: alias=$account_alias sub_account_id=$sub_account_id relation=$relation_name($relation_code)"
continue
fi
if [ -n "$auth_list_str" ]; then
create_body=$(printf '{"SubAccountID":%s,"Relation":%s,"AccountAlias":"%s","AuthListStr":"%s"}' "$sub_account_id" "$relation_code" "$account_alias" "$auth_list_str")
else
create_body=$(printf '{"SubAccountID":%s,"Relation":%s,"AccountAlias":"%s"}' "$sub_account_id" "$relation_code" "$account_alias")
fi
create_output=""
if create_output=$(ve billing CreateFinancialRelation --body "$create_body" 2>&1); then
:
elif ! printf '%s' "$create_output" | grep -Eqi 'already exists|duplicate|重复|已存在|已存在其他业务关系'; then
echo "Failed to create financial relation for sub account $sub_account_id" >&2
echo "$create_output" >&2
exit 1
fi
verify_output=$(ve billing ListFinancialRelation --body "$list_body" 2>&1) || {
echo "Failed to verify financial relation for sub account $sub_account_id" >&2
echo "$verify_output" >&2
exit 1
}
if ! printf '%s' "$verify_output" | grep -Eq "\"SubAccountI[dD]\"[[:space:]]*:[[:space:]]*\"?$sub_account_id\"?" ||
! printf '%s' "$verify_output" | grep -Eq "\"Relation\"[[:space:]]*:[[:space:]]*(\\[)?\"?$relation_code\"?(\\])?"; then
echo "Financial relation verification failed for sub account $sub_account_id" >&2
echo "$verify_output" >&2
exit 1
fi
echo "Financial relation is ready: alias=$account_alias sub_account_id=$sub_account_id relation=$relation_name($relation_code)"
echo "$verify_output"
done <<'EOF'
${local.financial_relation_pairs}
EOF
EOT
}
triggers = {
account_pairs = local.financial_relation_pairs
relation_type = var.financial_relation_type
auth_list_str = var.financial_relation_auth_list_str
}
}
output "financial_relations" {
description = "已建立财务关系的账号列表;key 为语义标识,AccountAlias = <prefix>-<key>"
value = {
for k, v in var.financial_relation_accounts : k => {
account_id = v
account_alias = "${var.prefix}-${k}"
relation_type = var.financial_relation_type
}
}
}
variable "region" {
description = "火山引擎主 Region"
type = string
default = "cn-beijing"
}
variable "prefix" {
description = "企业名称前缀,与 01-organization 阶段保持一致;实际 AccountAlias 会按 prefix-<key> 自动拼接,避免与其他执行或已有账号 alias 冲突。"
type = string
validation {
condition = can(regex("^[A-Za-z0-9-]+$", var.prefix)) && length(var.prefix) >= 2 && length(var.prefix) <= 12
error_message = "prefix must be 2-12 characters long and contain only letters, numbers, or hyphens."
}
}
variable "financial_relation_type" {
description = "财务关系类型:Financial_Hosting(财务托管) 或 Financial_Association(财务关联)"
type = string
default = "Financial_Hosting"
}
variable "financial_relation_accounts" {
description = "需要建立财务关系的子账号 ID 映射,key 为账号别名,value 为账号 ID"
type = map(string)
}
variable "financial_relation_auth_list_str" {
description = "可选授权列表,多个权限点以逗号分隔;为空时表示不附带授权列表"
type = string
default = ""
}
terraform {
required_providers {
null = {
source = "hashicorp/null"
version = ">= 3.2.1"
}
external = {
source = "hashicorp/external"
version = ">= 2.3.1"
}
volcenginecc = {
source = "volcengine/volcenginecc"
version = ">= 0.0.41"
}
}
}
provider "volcenginecc" {
region = var.region
}
locals {
workspace_root = abspath("${path.module}/../../..")
identity_runtime_dir = "${local.workspace_root}/landing-zone-setup/identity/${var.admin_username}"
admin_password_result_path = "${local.identity_runtime_dir}/admin-password-reset.json"
normalized_admin_email = var.admin_email != null && trimspace(var.admin_email) != "" ? trimspace(var.admin_email) : null
target_account_ids = toset(distinct(concat(
[trimspace(var.management_account_id)],
[for account_id in var.core_account_ids : trimspace(account_id) if trimspace(account_id) != ""]
)))
}
# Cloud Identity writes in this stage are sensitive to control-plane task
# concurrency. Callers must run this module with `terraform plan/apply
# -parallelism=1`; otherwise assignment/provisioning may return
# `ConcurrentException` during apply.
# --- Permission Set: Administrator ---
resource "volcenginecc_cloudidentity_permission_set" "admin" {
name = "AdministratorAccess"
description = "Full administrator access for Landing Zone core accounts"
session_duration = var.session_duration
permission_policies = [
{
permission_policy_name = "AdministratorAccess"
permission_policy_type = "System"
permission_policy_document = ""
}
]
}
# --- Permission Set: ReadOnly ---
resource "volcenginecc_cloudidentity_permission_set" "readonly" {
name = "ReadOnlyAccess"
description = "Read-only access for auditing and review"
session_duration = var.session_duration
permission_policies = [
{
permission_policy_name = "ReadOnlyAccess"
permission_policy_type = "System"
permission_policy_document = ""
}
]
depends_on = [volcenginecc_cloudidentity_permission_set.admin]
}
resource "volcenginecc_cloudidentity_permission_set" "ops_admin" {
name = "OpsAdministrator"
description = "Operations administrator access for Landing Zone"
session_duration = var.session_duration
permission_policies = [
{
permission_policy_name = "OpsAdministrator"
permission_policy_type = "System"
permission_policy_document = ""
}
]
depends_on = [volcenginecc_cloudidentity_permission_set.readonly]
}
resource "volcenginecc_cloudidentity_permission_set" "financial_admin" {
name = "FinancialAdministrator"
description = "Financial administrator access for Landing Zone"
session_duration = var.session_duration
permission_policies = [
{
permission_policy_name = "FinancialAdministrator"
permission_policy_type = "System"
permission_policy_document = ""
}
]
depends_on = [volcenginecc_cloudidentity_permission_set.ops_admin]
}
resource "volcenginecc_cloudidentity_permission_set" "iam_admin" {
name = "IAMAdministrator"
description = "IAM administrator access for Landing Zone"
session_duration = var.session_duration
permission_policies = [
{
permission_policy_name = "IAMAdministrator"
permission_policy_type = "System"
permission_policy_document = ""
}
]
depends_on = [volcenginecc_cloudidentity_permission_set.financial_admin]
}
# --- Admin User ---
resource "volcenginecc_cloudidentity_user" "admin" {
user_name = var.admin_username
display_name = var.admin_display_name
email = local.normalized_admin_email
depends_on = [volcenginecc_cloudidentity_permission_set.iam_admin]
}
# --- Admin Permission Set Assignment (management + target accounts) ---
resource "volcenginecc_cloudidentity_permission_set_assignment" "admin_assign" {
for_each = local.target_account_ids
permission_set_id = volcenginecc_cloudidentity_permission_set.admin.permission_set_id
principal_type = "User"
principal_id = volcenginecc_cloudidentity_user.admin.user_id
target_id = each.value
}
# --- Provision the permission set to management + target accounts ---
resource "volcenginecc_cloudidentity_permission_set_provisioning" "admin_provision" {
for_each = local.target_account_ids
permission_set_id = volcenginecc_cloudidentity_permission_set.admin.permission_set_id
target_id = each.value
depends_on = [volcenginecc_cloudidentity_permission_set_assignment.admin_assign]
}
resource "null_resource" "admin_password_reset" {
triggers = {
target_account_ids = join(",", sort(tolist(local.target_account_ids)))
admin_user_id = volcenginecc_cloudidentity_user.admin.user_id
admin_username = var.admin_username
}
provisioner "local-exec" {
interpreter = ["/bin/sh", "-c"]
command = <<-EOT
set -eu
runtime_dir="${local.identity_runtime_dir}"
result_file="${local.admin_password_result_path}"
mkdir -p "$runtime_dir"
ve cloudidentity ResetPassword --body '{
"GenerateRandomPassword": true,
"PasswordResetRequired": true,
"UserId": "${volcenginecc_cloudidentity_user.admin.user_id}"
}' > "$result_file"
echo "Admin password reset result written to $result_file"
EOT
}
depends_on = [volcenginecc_cloudidentity_permission_set_provisioning.admin_provision]
}
# --- Resolve the Cloud Identity user-portal login entry (dynamic, not hard-coded) ---
# Queries the live Cloud Identity instance so the login URL we hand to the user is
# the real user portal (https://<subdomain>.volccloudidentity.com/userportal),
# instead of the generic console login page.
data "external" "portal_login" {
program = ["/bin/sh", "-c", <<-EOT
set -eu
# Prefer GetPortalLoginConfig.PortalURL (authoritative full domain).
config_json="$(ve cloudidentity GetPortalLoginConfig --body '{}' 2>/dev/null || true)"
portal_url="$(printf '%s' "$config_json" \
| sed -n 's/.*"PortalURL"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)"
subdomain="$(printf '%s' "$config_json" \
| sed -n 's/.*"Subdomain"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)"
# Fallback to GetServiceStatus instance fields when portal config is unavailable.
if [ -z "$portal_url" ]; then
status_json="$(ve cloudidentity GetServiceStatus --body '{}' 2>/dev/null || true)"
instance_name="$(printf '%s' "$status_json" \
| sed -n 's/.*"InstanceName"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)"
instance_id="$(printf '%s' "$status_json" \
| sed -n 's/.*"InstanceId"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)"
if [ -n "$instance_name" ]; then
subdomain="$instance_name"
portal_url="https://$instance_name.volccloudidentity.com"
elif [ -n "$instance_id" ]; then
subdomain="$instance_id"
portal_url="https://$instance_id.volccloudidentity.com"
fi
fi
# Build the user portal login entry; empty when nothing could be resolved.
if [ -n "$portal_url" ]; then
login_url="$portal_url/userportal"
else
login_url=""
fi
printf '{"portal_url":"%s","subdomain":"%s","login_url":"%s"}' \
"$portal_url" "$subdomain" "$login_url"
EOT
]
depends_on = [null_resource.admin_password_reset]
}
output "permission_set_admin_id" {
description = "Administrator 权限集 ID"
value = volcenginecc_cloudidentity_permission_set.admin.permission_set_id
}
output "permission_set_readonly_id" {
description = "ReadOnly 权限集 ID"
value = volcenginecc_cloudidentity_permission_set.readonly.permission_set_id
}
output "permission_set_ops_admin_id" {
description = "OpsAdministrator 权限集 ID"
value = volcenginecc_cloudidentity_permission_set.ops_admin.permission_set_id
}
output "permission_set_financial_admin_id" {
description = "FinancialAdministrator 权限集 ID"
value = volcenginecc_cloudidentity_permission_set.financial_admin.permission_set_id
}
output "permission_set_iam_admin_id" {
description = "IAMAdministrator 权限集 ID"
value = volcenginecc_cloudidentity_permission_set.iam_admin.permission_set_id
}
output "admin_user_id" {
description = "管理员用户 ID"
value = volcenginecc_cloudidentity_user.admin.user_id
}
output "admin_password_reset_status" {
description = "管理员初始密码重置状态"
value = "completed"
}
output "admin_password_reset_result_path" {
description = "管理员初始密码重置结果文件路径"
value = local.admin_password_result_path
}
output "permission_set_catalog" {
description = "面向用户展示的权限集说明与适用场景"
value = [
{
name = "AdministratorAccess"
description = "管理员权限,适合平台初始化、资源治理和全局配置维护。"
typical_scenarios = [
"首次搭建 Landing Zone",
"处理组织、网络、日志等基础设施变更",
"排查需要全局权限的问题",
]
assignment_status = "已自动分配到当前管理员用户,并已下发到管理账号和目标账号列表"
},
{
name = "ReadOnlyAccess"
description = "只读权限,适合审计、查看配置和日常巡检。"
typical_scenarios = [
"查看资源与配置状态",
"审计和合规检查",
"排障前的信息确认",
]
assignment_status = "当前仅创建,未自动分配"
},
{
name = "OpsAdministrator"
description = "运维管理权限,适合日常运维、监控和故障处理。"
typical_scenarios = [
"处理告警和运维变更",
"执行日常运维操作",
"定位和修复运行故障",
]
assignment_status = "当前仅创建,未自动分配"
},
{
name = "FinancialAdministrator"
description = "财务管理权限,适合账单、费用和财务关系管理。"
typical_scenarios = [
"查看账单与费用分析",
"管理财务托管或财务关联",
"核对成本分摊与预算",
]
assignment_status = "当前仅创建,未自动分配"
},
{
name = "IAMAdministrator"
description = "身份与访问管理权限,适合用户、权限和访问控制治理。"
typical_scenarios = [
"管理用户与权限边界",
"调整访问授权策略",
"治理身份与访问控制配置",
]
assignment_status = "当前仅创建,未自动分配"
},
]
}
output "admin_assignment_target_ids" {
description = "已自动分配 AdministratorAccess 的账号 ID 列表(包含管理账号和目标账号列表)"
value = sort(tolist(local.target_account_ids))
}
output "cloud_identity_portal_url" {
description = "Cloud Identity 用户门户域名(不含路径),来自实例实际配置"
value = data.external.portal_login.result.portal_url
}
output "cloud_identity_subdomain" {
description = "Cloud Identity 实例子域名 / 实例标识"
value = data.external.portal_login.result.subdomain
}
output "recommended_login_url" {
description = "建议提供给用户的登录入口 URL(Cloud Identity 用户门户)"
value = (
data.external.portal_login.result.login_url != ""
? data.external.portal_login.result.login_url
: "https://console.volcengine.com/auth/login"
)
}
variable "region" {
description = "火山引擎主 Region"
type = string
default = "cn-beijing"
}
variable "prefix" {
description = "企业名称前缀"
type = string
}
variable "admin_username" {
description = "管理员用户名"
type = string
}
variable "admin_display_name" {
description = "管理员显示名称"
type = string
default = "LZ Administrator"
}
variable "admin_email" {
description = "管理员邮箱,可选"
type = string
default = null
nullable = true
}
variable "session_duration" {
description = "Permission Set 会话有效期(秒)"
type = number
default = 3600
}
variable "core_account_ids" {
description = "额外目标账号 ID 列表;默认可复用核心账号,也可追加客户已有账号"
type = list(string)
}
variable "management_account_id" {
description = "当前管理账号 ID,会默认并入 AdministratorAccess 的授权目标"
type = string
validation {
condition = trimspace(var.management_account_id) != ""
error_message = "management_account_id 不能为空。"
}
}
[
"ACEP",
"AdBlocker",
"AdvDefence",
"BusinessSecurity",
"CDN",
"CreativeManagementPlatform",
"DDoS",
"ESCloud",
"FileNAS",
"ImageX",
"Kafka",
"RabbitMQ",
"Redis",
"RocketMQ",
"TLS",
"Volc_ECS",
"Volc_InfluxDB_Proxy",
"Volc_Observe",
"ab_test",
"account",
"account_inner",
"account_management",
"account_verify",
"accp",
"adopenapi",
"advantiddos_global",
"advc_data",
"agentfusion",
"agentkit",
"aidap",
"aidv",
"aiotvideo",
"aisearch",
"aisec",
"alb",
"ams",
"antidirt",
"anycasteip",
"api_explorer",
"apig",
"apmplus",
"apmplus_openapi",
"apmplus_server",
"ares",
"ark",
"ark_claw",
"arkclaw",
"arkclawx",
"artifacts",
"asm",
"authorization",
"auto_scaling",
"beian",
"bill",
"bill_byteoc",
"bill_volc_provider",
"bill_volcano_engine",
"billing",
"bio",
"bis",
"bmq",
"businessag",
"bytebot",
"bytehouse",
"bytehouse_ce",
"byteio",
"cbba",
"cbr",
"cdp",
"cdp_saas",
"cdt",
"cen",
"certificate_service",
"cfs",
"clawsentry",
"clb",
"clblogdelivery",
"cloud_detect",
"cloud_invoice",
"cloud_profile",
"cloud_trail",
"cloudconnector",
"cloudcontrol",
"cloudidentity",
"cloudmonitor",
"cloudmonitor_tsdb_inband",
"cms_fusion",
"comm_number_pool",
"config",
"configcenter",
"console",
"console_config",
"consolemessage",
"content",
"content_customization",
"controller",
"corplink",
"coze",
"cp",
"cpaas",
"cr",
"crb",
"csa",
"ctxsearch",
"cube",
"customer_label",
"cv",
"cv_console",
"data_agent",
"data_tob",
"data_tob_rta",
"dataleap",
"datasail",
"dbw",
"dcdn",
"dcp",
"deploy_agent",
"des",
"directconnect",
"dmc",
"dms",
"dns",
"domain_openapi",
"domain_service",
"dramart",
"dsc",
"dtechservice",
"dts",
"eco_mesh",
"eco_partner",
"ecs",
"edgemonitor",
"edx",
"efs",
"ehpc",
"eic",
"emr",
"emr_olap",
"emr_serverless",
"fasttrack",
"flink",
"flink_online",
"fw_center",
"ga",
"gmp",
"gna",
"graph",
"gtm",
"gwlb",
"hbase",
"health_checker",
"hqd",
"hsm",
"httpdns",
"i18n_console",
"i18n_openapi",
"iad",
"iam",
"ic_iam",
"iccloud_muse",
"id",
"iga",
"imagination",
"imp",
"industry_commerce",
"influxdb",
"infoquest",
"insight",
"insight_search_api",
"intellimkt",
"iot",
"ipaas",
"iva",
"kickart",
"kms",
"las",
"lens",
"live",
"liveinsight",
"livesaas",
"llmscan",
"llmshield",
"location",
"marketplace",
"marketplace_partner",
"mcdn",
"mcs",
"mem0",
"metakms",
"metrics",
"milvus",
"mkt_insight",
"ml_platform",
"mlp_ai4s",
"modelweb_api",
"mongodb",
"mp",
"mse",
"mysql_sharding",
"na",
"natgateway",
"nta",
"organization",
"organization_retail",
"organization_shadow",
"origin_defence",
"ove",
"para",
"partner",
"passport",
"pca",
"pcc",
"pcdn",
"plb",
"prisdb",
"private_zone",
"privatelink",
"prm_sdm_online",
"psi",
"public_dns",
"quota",
"rcs_5g",
"rds_mssql",
"rds_mysql",
"rds_postgresql",
"resource_share",
"resourcecenter",
"risk_console",
"rm",
"ros",
"rtc",
"sami",
"sdwan",
"sealsuite",
"sec_agent",
"sec_managed",
"seccenter",
"signin",
"skillhub",
"smc",
"sns",
"speech_saas_prod",
"sqs",
"storage_ebs",
"storage_web_controller",
"sts",
"tag",
"tcs",
"ticket",
"tis",
"tos",
"trademark",
"trae_cn",
"transitrouter",
"translate",
"ttgw",
"vbh",
"vbn",
"vci",
"vedbm",
"vedbm_serverless",
"vedem_product",
"veecc",
"veecp_openapi",
"veefapi",
"veefedge",
"veen_edge",
"veenedge",
"vefaas",
"vegame",
"vei_api",
"velinux",
"vepfs",
"verender",
"vikingdb",
"vke",
"vlog",
"vmp",
"vms",
"vod",
"vohico",
"volcSMS",
"volc_common_configure",
"volc_console_api",
"volc_content_api",
"volc_content_platform",
"volc_content_proxy",
"volc_contract_process",
"volc_datacenter_http",
"volc_merchandise_http",
"volc_risk_http",
"volc_secret_number",
"volc_torchlight_api",
"volc_voice_notify",
"vpc",
"vpn",
"waf",
"wtn"
]
locals {
audit_log_bucket_name = "${var.prefix}-organization-audit-logs"
organization_trail_name = "${var.prefix}-org-trail"
log_archive_assume_role_trn = "trn:iam::${var.log_archive_account_id}:role/OrganizationAccessControlRole"
trusted_service_principal = "cloud_trail"
tos_openapi_region = "cn-beijing"
default_trail_event_sources = jsondecode(file("${path.module}/default-trail-event-sources.json"))
effective_trail_event_sources = (
var.trail_event_sources != null ? var.trail_event_sources : local.default_trail_event_sources
)
trail_event_sources_cli_args = join(" ", [
for idx, source in local.effective_trail_event_sources : format("--EventSources.%d \"%s\"", idx + 1, source)
])
}
# ---------------------------------------------------------------
# 通过日志归档账号的 OrganizationAccessControlRole 配置组织级操作审计 (CloudTrail)
# 说明:
# - 当前先在企业组织管理员上下文中调用 RegisterDelegatedAdministrator,将日志归档账号注册为
# 云审计可信服务的代理账号;完成后再进入日志归档账号执行 CloudTrail 写操作。
# - 当前不再通过 provider 跨账号创建 TOS Bucket。
# - CreateTrail 前会先在日志归档账号上下文中通过 TOS OpenAPI 查询并补齐服务开通状态;
# 若账号当前处于欠费关停、欠费回收或已销户等状态,则停止本阶段并提示用户处理账号状态。
# - CreateTrail / StartLogging 必须在日志归档账号上下文中执行,不能直接落在当前管理员账号。
# - 由于 ve CLI 在本机已有 profile 时会优先使用 profile 鉴权,不能仅依赖 export STS 环境变量切换身份。
# - 当前改为:读取原默认 profile -> AssumeRole -> 写临时 log profile -> 在日志命令执行窗口内切到临时 log profile,并同时显式 --profile 调用 -> 恢复原 profile。
# - `EventSources.N` 只是接口文档里的数组占位写法,实际 CLI 传参必须展开成
# `--EventSources.1 ... --EventSources.2 ...` 这类从 1 开始的自然数序号。
# ---------------------------------------------------------------
resource "null_resource" "trusted_service_delegated_administrator" {
provisioner "local-exec" {
interpreter = ["/bin/sh", "-c"]
command = <<-EOT
set -eu
register_body=$(cat <<'JSON'
{"AccountId":"${var.log_archive_account_id}","ServicePrincipal":"${local.trusted_service_principal}"}
JSON
)
register_output=""
if register_output=$(ve organization RegisterDelegatedAdministrator --body "$register_body" 2>&1); then
:
elif printf '%s' "$register_output" | grep -Eqi 'already exists|duplicate|重复|已存在|ExistDelegateAdministrator|DelegateAdministrator'; then
:
else
echo "failed to register delegated administrator for trusted service ${local.trusted_service_principal}" >&2
printf '%s\n' "$register_output" >&2
exit 1
fi
printf '%s\n' "$register_output"
EOT
}
triggers = {
log_archive_account_id = var.log_archive_account_id
trusted_service_principal = local.trusted_service_principal
}
}
resource "null_resource" "organization_trail" {
provisioner "local-exec" {
interpreter = ["/bin/sh", "-c"]
command = <<-EOT
set -eu
config_file="$${HOME}/.volcengine/config.json"
original_profile=""
if [ -f "$config_file" ]; then
original_profile="$(tr -d '\n' <"$config_file" | sed -n 's/.*"current"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')"
fi
assume_role_output="$(ve sts AssumeRole \
--RoleTrn "${local.log_archive_assume_role_trn}" \
--RoleSessionName "lz-log-setup")"
temp_profile="lz-log-${var.log_archive_account_id}-create-$$"
cleanup() {
cleanup_status=$?
if [ -n "$${original_profile:-}" ]; then
ve configure profile --profile "$original_profile" >/dev/null 2>&1 || true
fi
if [ -n "$${temp_profile:-}" ]; then
ve configure delete --profile "$temp_profile" >/dev/null 2>&1 || true
fi
if [ -n "$${original_profile:-}" ]; then
ve configure profile --profile "$original_profile" >/dev/null 2>&1 || true
fi
exit "$cleanup_status"
}
trap cleanup EXIT INT TERM
printf '%s' "$assume_role_output" | python3 - "$temp_profile" "${var.region}" <<'PY'
import json
import subprocess
import sys
profile = sys.argv[1]
region = sys.argv[2]
payload = json.load(sys.stdin)
credentials = payload.get("Result", {}).get("Credentials", {})
if (
not credentials.get("AccessKeyId")
or not credentials.get("SecretAccessKey")
or not credentials.get("SessionToken")
):
print("failed to assume log archive account role: credentials not found in AssumeRole response", file=sys.stderr)
raise SystemExit(1)
subprocess.run(
[
"ve",
"configure",
"set",
"--profile",
profile,
"--region",
region,
"--access-key",
credentials["AccessKeyId"],
"--secret-key",
credentials["SecretAccessKey"],
"--session-token",
credentials["SessionToken"],
],
check=True,
stdout=subprocess.DEVNULL,
)
PY
ve configure profile --profile "$temp_profile" >/dev/null
caller_identity_output="$(ve sts GetCallerIdentity --profile "$temp_profile" 2>&1)" || {
printf '%s\n' "$caller_identity_output" >&2
exit 1
}
printf '%s' "$caller_identity_output" | grep -q "${var.log_archive_account_id}" || {
echo "temporary log profile identity probe did not match log archive account ${var.log_archive_account_id}" >&2
printf '%s\n' "$caller_identity_output" >&2
exit 1
}
tos_activation_output="$(printf '%s' "$assume_role_output" | python3 - "${path.module}/tos_activate.py" "${local.tos_openapi_region}" <<'PY'
import json
import subprocess
import sys
helper_path = sys.argv[1]
region = sys.argv[2]
payload = json.load(sys.stdin)
credentials = payload.get("Result", {}).get("Credentials", {})
if (
not credentials.get("AccessKeyId")
or not credentials.get("SecretAccessKey")
or not credentials.get("SessionToken")
):
print("failed to ensure TOS is activated in log archive account: credentials not found in AssumeRole response", file=sys.stderr)
raise SystemExit(1)
completed = subprocess.run(
[
sys.executable,
helper_path,
"--region",
region,
"--access-key",
credentials["AccessKeyId"],
"--secret-key",
credentials["SecretAccessKey"],
"--session-token",
credentials["SessionToken"],
],
check=True,
text=True,
capture_output=True,
)
sys.stdout.write(completed.stdout)
sys.stderr.write(completed.stderr)
PY
)" || {
echo "failed to ensure TOS is activated in log archive account ${var.log_archive_account_id}" >&2
printf '%s\n' "$tos_activation_output" >&2
exit 1
}
printf '%s\n' "$tos_activation_output"
ve cloudtrail20180101 CreateTrail \
--profile "$temp_profile" \
--TrailName "${local.organization_trail_name}" \
--TrailType 1 \
--EventRW "All" \
${local.trail_event_sources_cli_args} \
--TosBucketName "${local.audit_log_bucket_name}" \
--TosBucketRegion "${var.region}" \
--TosKeyPrefix "cloudtrail"
describe_trails_output="$(ve cloudtrail20180101 DescribeTrails \
--profile "$temp_profile" \
--TrailNames.1 "${local.organization_trail_name}" \
--IncludeOrganizationTrail 1 2>&1)" || {
printf '%s\n' "$describe_trails_output" >&2
exit 1
}
printf '%s' "$describe_trails_output" | grep -q "\"TrailName\"[[:space:]]*:[[:space:]]*\"${local.organization_trail_name}\"" || {
echo "DescribeTrails did not return expected trail ${local.organization_trail_name}" >&2
printf '%s\n' "$describe_trails_output" >&2
exit 1
}
printf '%s' "$describe_trails_output" | grep -q "\"TosBucketName\"[[:space:]]*:[[:space:]]*\"${local.audit_log_bucket_name}\"" || {
echo "DescribeTrails did not return expected bucket ${local.audit_log_bucket_name}" >&2
printf '%s\n' "$describe_trails_output" >&2
exit 1
}
EOT
}
triggers = {
log_archive_account_id = var.log_archive_account_id
bucket_name = local.audit_log_bucket_name
trail_name = local.organization_trail_name
tos_openapi_region = local.tos_openapi_region
trail_event_sources = jsonencode(local.effective_trail_event_sources)
}
depends_on = [null_resource.trusted_service_delegated_administrator]
}
resource "null_resource" "enable_trail_logging" {
provisioner "local-exec" {
interpreter = ["/bin/sh", "-c"]
command = <<-EOT
set -eu
config_file="$${HOME}/.volcengine/config.json"
original_profile=""
if [ -f "$config_file" ]; then
original_profile="$(tr -d '\n' <"$config_file" | sed -n 's/.*"current"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')"
fi
assume_role_output="$(ve sts AssumeRole \
--RoleTrn "${local.log_archive_assume_role_trn}" \
--RoleSessionName "lz-log-start-logging")"
temp_profile="lz-log-${var.log_archive_account_id}-start-$$"
cleanup() {
cleanup_status=$?
if [ -n "$${original_profile:-}" ]; then
ve configure profile --profile "$original_profile" >/dev/null 2>&1 || true
fi
if [ -n "$${temp_profile:-}" ]; then
ve configure delete --profile "$temp_profile" >/dev/null 2>&1 || true
fi
if [ -n "$${original_profile:-}" ]; then
ve configure profile --profile "$original_profile" >/dev/null 2>&1 || true
fi
exit "$cleanup_status"
}
trap cleanup EXIT INT TERM
printf '%s' "$assume_role_output" | python3 - "$temp_profile" "${var.region}" <<'PY'
import json
import subprocess
import sys
profile = sys.argv[1]
region = sys.argv[2]
payload = json.load(sys.stdin)
credentials = payload.get("Result", {}).get("Credentials", {})
if (
not credentials.get("AccessKeyId")
or not credentials.get("SecretAccessKey")
or not credentials.get("SessionToken")
):
print("failed to assume log archive account role: credentials not found in AssumeRole response", file=sys.stderr)
raise SystemExit(1)
subprocess.run(
[
"ve",
"configure",
"set",
"--profile",
profile,
"--region",
region,
"--access-key",
credentials["AccessKeyId"],
"--secret-key",
credentials["SecretAccessKey"],
"--session-token",
credentials["SessionToken"],
],
check=True,
stdout=subprocess.DEVNULL,
)
PY
ve configure profile --profile "$temp_profile" >/dev/null
caller_identity_output="$(ve sts GetCallerIdentity --profile "$temp_profile" 2>&1)" || {
printf '%s\n' "$caller_identity_output" >&2
exit 1
}
printf '%s' "$caller_identity_output" | grep -q "${var.log_archive_account_id}" || {
echo "temporary log profile identity probe did not match log archive account ${var.log_archive_account_id}" >&2
printf '%s\n' "$caller_identity_output" >&2
exit 1
}
ve cloudtrail20180101 StartLogging \
--profile "$temp_profile" \
--TrailName "${local.organization_trail_name}"
describe_trails_output="$(ve cloudtrail20180101 DescribeTrails \
--profile "$temp_profile" \
--TrailNames.1 "${local.organization_trail_name}" \
--IncludeOrganizationTrail 1 2>&1)" || {
printf '%s\n' "$describe_trails_output" >&2
exit 1
}
printf '%s' "$describe_trails_output" | grep -q "\"TrailName\"[[:space:]]*:[[:space:]]*\"${local.organization_trail_name}\"" || {
echo "DescribeTrails did not return expected trail ${local.organization_trail_name}" >&2
printf '%s\n' "$describe_trails_output" >&2
exit 1
}
printf '%s' "$describe_trails_output" | grep -q "\"LoggingStatus\"[[:space:]]*:[[:space:]]*\"Enable\"" || {
echo "DescribeTrails did not return expected logging status Enable" >&2
printf '%s\n' "$describe_trails_output" >&2
exit 1
}
EOT
}
triggers = {
log_archive_account_id = var.log_archive_account_id
trail_name = local.organization_trail_name
}
depends_on = [null_resource.organization_trail]
}
output "audit_log_bucket_name" {
description = "审计日志 TOS Bucket 名称"
value = "${var.prefix}-organization-audit-logs"
}
output "trail_name" {
description = "组织级操作审计跟踪名称"
value = "${var.prefix}-org-trail"
}
#!/usr/bin/env python3
import argparse
import datetime
import hashlib
import hmac
import json
import sys
import time
from urllib.error import HTTPError, URLError
from urllib.parse import quote
from urllib.request import Request, urlopen
SERVICE = "tos"
VERSION = "2018-01-01"
CONTENT_TYPE = "application/x-www-form-urlencoded"
SUPPORTED_REGION = "cn-beijing"
KNOWN_HOSTS = {
"cn-beijing": "tos.cn-beijing.volcengineapi.com",
}
RETRYABLE_FINAL_STATES = {"NonActivated"}
BLOCKING_FINAL_STATES = {"Stopping", "Closed", "Terminate"}
DEFAULT_MAX_POLLS = 8
DEFAULT_POLL_INTERVAL_SECONDS = 3
class TosActivationError(Exception):
def __init__(self, message, *, details=None, exit_code=1):
super().__init__(message)
self.details = details
self.exit_code = exit_code
def utc_now():
return datetime.datetime.now(datetime.timezone.utc)
def normalize_query(params):
items = []
for key in sorted(params.keys()):
value = params[key]
if isinstance(value, list):
for entry in value:
items.append(
f"{quote(str(key), safe='-_.~')}={quote(str(entry), safe='-_.~')}"
)
else:
items.append(
f"{quote(str(key), safe='-_.~')}={quote(str(value), safe='-_.~')}"
)
return "&".join(items)
def hash_sha256(content):
return hashlib.sha256(content.encode("utf-8")).hexdigest()
def hmac_sha256(key, content):
return hmac.new(key, content.encode("utf-8"), hashlib.sha256).digest()
def resolve_host(region):
if region not in KNOWN_HOSTS:
raise TosActivationError(
f"Unsupported TOS OpenAPI region: {region!r}",
details={
"supported_regions": sorted(KNOWN_HOSTS.keys()),
},
exit_code=4,
)
return KNOWN_HOSTS[region]
def build_signed_headers(region, credentials, query):
host = resolve_host(region)
method = "POST"
body = ""
request_time = utc_now()
x_date = request_time.strftime("%Y%m%dT%H%M%SZ")
short_x_date = x_date[:8]
body_hash = hash_sha256(body)
canonical_header_items = [
("host", host),
("x-date", x_date),
]
if credentials.get("session_token"):
canonical_header_items.append(("x-security-token", credentials["session_token"]))
signed_headers = ";".join(name for name, _ in canonical_header_items)
canonical_headers = "\n".join(f"{name}:{value}" for name, value in canonical_header_items)
canonical_request = "\n".join(
[
method.upper(),
"/",
normalize_query(query),
canonical_headers,
"",
signed_headers,
body_hash,
]
)
hashed_canonical_request = hash_sha256(canonical_request)
credential_scope = "/".join([short_x_date, region, SERVICE, "request"])
string_to_sign = "\n".join(
["HMAC-SHA256", x_date, credential_scope, hashed_canonical_request]
)
k_date = hmac_sha256(credentials["secret_key"].encode("utf-8"), short_x_date)
k_region = hmac_sha256(k_date, region)
k_service = hmac_sha256(k_region, SERVICE)
k_signing = hmac_sha256(k_service, "request")
signature = hmac_sha256(k_signing, string_to_sign).hex()
headers = {
"Host": host,
"X-Date": x_date,
"Authorization": (
"HMAC-SHA256 Credential={}/{}, SignedHeaders={}, Signature={}".format(
credentials["access_key"],
credential_scope,
signed_headers,
signature,
)
),
}
if credentials.get("session_token"):
headers["X-Security-Token"] = credentials["session_token"]
return headers
def load_json_response(response):
payload = response.read().decode("utf-8")
try:
return json.loads(payload)
except json.JSONDecodeError as exc:
raise TosActivationError(
"TOS OpenAPI returned a non-JSON response",
details={"payload": payload},
exit_code=1,
) from exc
def invoke_openapi(action, args):
host = resolve_host(args.region)
query = {
"Action": action,
"Version": VERSION,
"ServiceName": SERVICE,
}
body = ""
credentials = {
"access_key": args.access_key,
"secret_key": args.secret_key,
"session_token": args.session_token,
}
headers = build_signed_headers(args.region, credentials, query)
url = f"https://{host}/?{normalize_query(query)}"
request = Request(url=url, headers=headers, method="POST", data=body.encode("utf-8"))
try:
with urlopen(request, timeout=args.timeout_seconds) as response:
payload = load_json_response(response)
response_error = payload.get("ResponseMetadata", {}).get("Error")
if response_error:
raise TosActivationError(
f"TOS OpenAPI {action} returned an API error",
details=payload,
exit_code=1,
)
return payload
except HTTPError as exc:
details = None
try:
details = load_json_response(exc)
except TosActivationError:
payload = exc.read().decode("utf-8", errors="replace")
details = {"payload": payload}
raise TosActivationError(
f"TOS OpenAPI {action} failed with HTTP {exc.code}",
details=details,
exit_code=1,
) from exc
except URLError as exc:
raise TosActivationError(
f"TOS OpenAPI {action} request failed: {exc}",
exit_code=1,
) from exc
def extract_request_id(response):
return response.get("ResponseMetadata", {}).get("RequestId")
def extract_status(response):
return response.get("Result", {}).get("Status")
def poll_account_status(args):
last_response = None
for attempt in range(1, args.max_polls + 1):
last_response = invoke_openapi("GetAccountStatus", args)
status = extract_status(last_response)
if status == "Activated":
return last_response, attempt
if status in BLOCKING_FINAL_STATES:
raise TosActivationError(
f"TOS account status became {status} while waiting for activation",
details={
"final_status": status,
"verify_request_id": extract_request_id(last_response),
"region": args.region,
"host": resolve_host(args.region),
"attempt": attempt,
},
exit_code=5,
)
if attempt < args.max_polls:
time.sleep(args.poll_interval_seconds)
return last_response, args.max_polls
def ensure_tos_activated(args):
initial_response = invoke_openapi("GetAccountStatus", args)
initial_status = extract_status(initial_response)
if initial_status == "Activated":
return {
"changed": False,
"initial_status": initial_status,
"final_status": initial_status,
"status_request_id": extract_request_id(initial_response),
"region": args.region,
"host": resolve_host(args.region),
}
if initial_status in BLOCKING_FINAL_STATES:
raise TosActivationError(
f"TOS account status is {initial_status} and cannot be auto-activated",
details={
"initial_status": initial_status,
"status_request_id": extract_request_id(initial_response),
"region": args.region,
"host": resolve_host(args.region),
},
exit_code=5,
)
if initial_status not in RETRYABLE_FINAL_STATES:
raise TosActivationError(
f"Unexpected TOS account status: {initial_status!r}",
details={
"initial_status": initial_status,
"status_request_id": extract_request_id(initial_response),
"region": args.region,
"host": resolve_host(args.region),
},
exit_code=1,
)
activate_response = invoke_openapi("ActiveTosSvc", args)
verify_response, verify_attempts = poll_account_status(args)
final_status = extract_status(verify_response)
if final_status != "Activated":
raise TosActivationError(
"TOS activation verification failed",
details={
"initial_status": initial_status,
"final_status": final_status,
"activate_request_id": extract_request_id(activate_response),
"verify_request_id": extract_request_id(verify_response),
"region": args.region,
"host": resolve_host(args.region),
"verify_attempts": verify_attempts,
},
exit_code=5,
)
return {
"changed": True,
"initial_status": initial_status,
"final_status": final_status,
"activate_request_id": extract_request_id(activate_response),
"verify_request_id": extract_request_id(verify_response),
"region": args.region,
"host": resolve_host(args.region),
"verify_attempts": verify_attempts,
}
def parse_args():
parser = argparse.ArgumentParser(
description="Ensure the current account has TOS activated via Volcengine OpenAPI."
)
parser.add_argument("--region", required=True, help="Volcengine region for the TOS control plane.")
parser.add_argument("--access-key", required=True, help="Access key used for signing.")
parser.add_argument("--secret-key", required=True, help="Secret key used for signing.")
parser.add_argument("--session-token", help="Optional STS session token.")
parser.add_argument(
"--timeout-seconds",
type=int,
default=30,
help="HTTP timeout in seconds.",
)
parser.add_argument(
"--max-polls",
type=int,
default=DEFAULT_MAX_POLLS,
help="Maximum number of verification polls after activation.",
)
parser.add_argument(
"--poll-interval-seconds",
type=int,
default=DEFAULT_POLL_INTERVAL_SECONDS,
help="Seconds to wait between verification polls.",
)
return parser.parse_args()
def main():
args = parse_args()
try:
result = ensure_tos_activated(args)
except TosActivationError as exc:
payload = {
"error": str(exc),
"details": exc.details,
}
print(json.dumps(payload, ensure_ascii=True), file=sys.stderr)
return exc.exit_code
print(json.dumps(result, ensure_ascii=True))
return 0
if __name__ == "__main__":
sys.exit(main())
variable "region" {
description = "火山引擎主 Region"
type = string
default = "cn-beijing"
}
variable "prefix" {
description = "企业名称前缀"
type = string
}
variable "log_archive_account_id" {
description = "日志归档账号 ID(来自阶段 1 输出)"
type = string
}
variable "trail_event_sources" {
description = "操作审计跟踪的事件源列表。未显式提供时,默认使用 default-trail-event-sources.json 中维护的全量事件源清单。"
type = list(string)
default = null
validation {
condition = var.trail_event_sources == null || length(var.trail_event_sources) > 0
error_message = "如果显式提供 trail_event_sources,则其不能为空。"
}
}
terraform {
required_providers {
volcenginecc = {
source = "volcengine/volcenginecc"
version = ">= 0.0.41"
}
}
}
locals {
network_account_assume_role_trn = "trn:iam::${var.network_account_id}:role/OrganizationAccessControlRole"
}
# --- Provider: 主账号 (默认) ---
provider "volcenginecc" {
region = var.region
}
# --- Provider: 网络账号 (通过 assume_role 跨账号) ---
provider "volcenginecc" {
alias = "network_account"
region = var.region
endpoints = {
sts = "sts.volcengineapi.com"
}
assume_role = {
assume_role_trn = local.network_account_assume_role_trn
assume_role_session_name = "lz-network-setup"
assume_role_duration_seconds = 3600
}
}
# ---------------------------------------------------------------
# Part 1: 中转路由器 (Transit Router) — 在网络账号中创建
# ---------------------------------------------------------------
resource "volcenginecc_transitrouter_transit_router" "this" {
provider = volcenginecc.network_account
transit_router_name = "${var.prefix}-tr-${var.region}"
description = "Landing Zone Transit Router in network account for ${var.region}"
project_name = "default"
tags = [
{
key = "ManagedBy"
value = "LandingZone"
}
]
}
# ---------------------------------------------------------------
# Part 2: 网络底座 VPC — 在网络账号中创建
# ---------------------------------------------------------------
resource "volcenginecc_vpc_vpc" "network" {
provider = volcenginecc.network_account
cidr_block = var.network_vpc_cidr
vpc_name = "${var.prefix}-network-vpc"
description = "Network baseline VPC for Landing Zone"
}
resource "volcenginecc_vpc_subnet" "network_az_a" {
provider = volcenginecc.network_account
vpc_id = volcenginecc_vpc_vpc.network.id
zone_id = "${var.region}-a"
cidr_block = var.network_subnet_cidr_az_a
subnet_name = "${var.prefix}-network-subnet-a"
}
resource "volcenginecc_vpc_subnet" "network_az_b" {
provider = volcenginecc.network_account
vpc_id = volcenginecc_vpc_vpc.network.id
zone_id = "${var.region}-b"
cidr_block = var.network_subnet_cidr_az_b
subnet_name = "${var.prefix}-network-subnet-b"
depends_on = [volcenginecc_vpc_subnet.network_az_a]
}
# ---------------------------------------------------------------
# Part 2.5: 确保网络账号已具备 Transit Router 服务关联角色
# 说明:
# - TR 在创建 VPC attachment 时需要网络账号内的 ServiceRoleForTransitRouter。
# - 当前通过 ve CLI 在 assume_role 到网络账号后进行幂等创建;若角色已存在则直接继续。
# ---------------------------------------------------------------
resource "null_resource" "network_account_transitrouter_service_linked_role" {
triggers = {
network_account_id = var.network_account_id
}
provisioner "local-exec" {
interpreter = ["/bin/sh", "-c"]
command = <<-EOT
set -eu
assume_role_output="$(ve sts AssumeRole \
--RoleTrn "${local.network_account_assume_role_trn}" \
--RoleSessionName "lz-network-slr")"
temp_profile="lz-network-slr-$$"
cleanup() {
cleanup_status=$?
if [ -n "$${temp_profile:-}" ]; then
ve configure delete --profile "$temp_profile" >/dev/null 2>&1 || true
fi
exit "$cleanup_status"
}
trap cleanup EXIT INT TERM
printf '%s' "$assume_role_output" | python3 - "$temp_profile" "${var.region}" <<'PY'
import json
import subprocess
import sys
profile = sys.argv[1]
region = sys.argv[2]
payload = json.load(sys.stdin)
credentials = payload.get("Result", {}).get("Credentials", {})
if (
not credentials.get("AccessKeyId")
or not credentials.get("SecretAccessKey")
or not credentials.get("SessionToken")
):
print("failed to assume network account role: credentials not found in AssumeRole response", file=sys.stderr)
raise SystemExit(1)
subprocess.run(
[
"ve",
"configure",
"set",
"--profile",
profile,
"--region",
region,
"--access-key",
credentials["AccessKeyId"],
"--secret-key",
credentials["SecretAccessKey"],
"--session-token",
credentials["SessionToken"],
],
check=True,
stdout=subprocess.DEVNULL,
)
PY
create_role_output="$(mktemp)"
if ve iam CreateServiceLinkedRole --profile "$temp_profile" --ServiceName transitrouter >"$create_role_output" 2>&1; then
rm -f "$create_role_output"
exit 0
fi
if grep -q "RoleAlreadyExists" "$create_role_output"; then
rm -f "$create_role_output"
exit 0
fi
cat "$create_role_output" >&2
rm -f "$create_role_output"
exit 1
EOT
}
}
# ---------------------------------------------------------------
# Part 3: 将网络底座 VPC 连接到中转路由器
# ---------------------------------------------------------------
resource "volcenginecc_transitrouter_vpc_attachment" "network" {
provider = volcenginecc.network_account
transit_router_id = volcenginecc_transitrouter_transit_router.this.id
vpc_id = volcenginecc_vpc_vpc.network.id
transit_router_attachment_name = "${var.prefix}-network-attach"
description = "Network baseline VPC attachment"
auto_publish_route_enabled = true
attach_points = [
{
subnet_id = volcenginecc_vpc_subnet.network_az_a.id
zone_id = "${var.region}-a"
},
{
subnet_id = volcenginecc_vpc_subnet.network_az_b.id
zone_id = "${var.region}-b"
}
]
tags = [
{
key = "ManagedBy"
value = "LandingZone"
}
]
depends_on = [null_resource.network_account_transitrouter_service_linked_role]
}
output "transit_router_id" {
description = "中转路由器 (TR) ID"
value = volcenginecc_transitrouter_transit_router.this.id
}
output "network_vpc_id" {
description = "网络底座 VPC ID"
value = volcenginecc_vpc_vpc.network.id
}
output "network_subnet_az_a_id" {
description = "网络底座子网 ID (可用区 A)"
value = volcenginecc_vpc_subnet.network_az_a.id
}
output "network_subnet_az_b_id" {
description = "网络底座子网 ID (可用区 B)"
value = volcenginecc_vpc_subnet.network_az_b.id
}
output "network_vpc_attachment_id" {
description = "网络底座 VPC 与 TR 的连接 ID"
value = volcenginecc_transitrouter_vpc_attachment.network.id
}
variable "region" {
description = "火山引擎主 Region"
type = string
default = "cn-beijing"
}
variable "prefix" {
description = "企业名称前缀"
type = string
}
variable "network_account_id" {
description = "网络账号 ID(来自阶段 1 输出)"
type = string
}
variable "network_vpc_cidr" {
description = "网络底座 VPC CIDR"
type = string
default = "10.0.0.0/16"
}
variable "network_subnet_cidr_az_a" {
description = "网络底座子网 CIDR (可用区 A)"
type = string
default = "10.0.1.0/24"
}
variable "network_subnet_cidr_az_b" {
description = "网络底座子网 CIDR (可用区 B)"
type = string
default = "10.0.2.0/24"
}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{REPORT_TITLE}}</title>
<style>
:root {
color-scheme: light dark;
--bg: #ffffff;
--panel: #f7f9fc;
--text: #1f2328;
--muted: #5b6573;
--border: #d8dee8;
--primary: #1664ff;
--success: #0f9d58;
--warning: #b26a00;
--danger: #d93025;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0d1117;
--panel: #161b22;
--text: #e6edf3;
--muted: #9da7b3;
--border: #30363d;
--primary: #6ca0ff;
--success: #4ac26b;
--warning: #ffb347;
--danger: #ff7b72;
}
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
}
.container {
max-width: 1180px;
margin: 0 auto;
padding: 28px 24px 56px;
}
.hero,
.section {
border: 1px solid var(--border);
border-radius: 18px;
background: var(--panel);
}
.hero {
padding: 28px;
background: linear-gradient(135deg, rgba(22, 100, 255, 0.12), rgba(22, 100, 255, 0.04));
}
.section {
margin-top: 22px;
padding: 22px;
}
h1, h2, h3 {
margin: 0 0 12px;
line-height: 1.3;
}
h1 { font-size: 2rem; }
h2 { font-size: 1.3rem; }
p { margin: 8px 0; }
ul {
margin: 8px 0 0 20px;
padding: 0;
}
li + li { margin-top: 8px; }
.meta {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
margin-top: 18px;
}
.meta-item {
padding: 14px 16px;
border: 1px solid var(--border);
border-radius: 14px;
background: var(--bg);
}
.meta-label {
display: block;
font-size: 0.88rem;
color: var(--muted);
margin-bottom: 6px;
}
.meta-value {
font-size: 1rem;
font-weight: 600;
word-break: break-word;
}
.status-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
margin-top: 16px;
}
.badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 7px 12px;
border-radius: 999px;
border: 1px solid var(--border);
font-size: 0.92rem;
background: var(--bg);
}
.badge-success { color: var(--success); border-color: rgba(15, 157, 88, 0.3); background: rgba(15, 157, 88, 0.05); }
.badge-warning { color: var(--warning); border-color: rgba(178, 106, 0, 0.3); background: rgba(178, 106, 0, 0.05); }
.badge-danger { color: var(--danger); border-color: rgba(217, 48, 37, 0.3); background: rgba(217, 48, 37, 0.05); }
.table-wrap {
overflow-x: auto;
margin-top: 12px;
border-radius: 12px;
border: 1px solid var(--border);
background: var(--bg);
}
table {
width: 100%;
border-collapse: collapse;
margin: 0;
}
th, td {
padding: 12px 14px;
border-bottom: 1px solid var(--border);
text-align: left;
vertical-align: top;
}
tr:last-child th,
tr:last-child td {
border-bottom: none;
}
th {
background: rgba(22, 100, 255, 0.05);
font-weight: 600;
white-space: nowrap;
}
.muted { color: var(--muted); }
.multiline {
white-space: pre-wrap;
word-break: break-word;
}
.path {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.94rem;
word-break: break-all;
}
</style>
</head>
<body>
<!--
使用说明:
1. 本文件是 Landing Zone Setup 汇总报告的固定格式模板,生成报告时请复制本模板并替换所有 {{PLACEHOLDER}}。
2. 报告应重点展示各个搭建项的结果,保持页面简洁易读。
3. 如果某类记录有多条,请复制带 data-row 标记的 <tr> 或 <li> 块,不要擅自删除整个章节。
4. 报告只写已核实的执行结果;不写推测、不写后台排障过程、不暴露敏感凭证。
-->
<div class="container">
<section class="hero">
<h1>{{REPORT_TITLE}}</h1>
<p class="muted">{{REPORT_SUBTITLE}}</p>
<div class="meta">
<div class="meta-item">
<span class="meta-label">执行时间</span>
<span class="meta-value">{{EXECUTION_TIME_RANGE}}</span>
</div>
<div class="meta-item">
<span class="meta-label">工作区目录</span>
<span class="meta-value path" style="font-size: 0.85em; font-weight: normal;">{{WORKSPACE_PATH}}</span>
</div>
</div>
</section>
<section class="section">
<h2>1. 阶段执行明细</h2>
<p class="muted" style="font-size: 0.95rem; margin-top: 0;">重点展示本次搭建各核心阶段的执行结果及资源落地情况。</p>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>阶段</th>
<th>状态</th>
<th>实际执行内容</th>
<th>关键产出</th>
<th>备注</th>
</tr>
</thead>
<tbody>
<tr data-row="stage">
<td style="white-space: nowrap; font-weight: 500;">{{STAGE_NAME}}</td>
<td style="white-space: nowrap;">{{STAGE_STATUS}}</td>
<td class="multiline">{{STAGE_ACTIONS}}</td>
<td class="multiline">{{STAGE_OUTPUTS}}</td>
<td class="multiline muted" style="font-size: 0.95em;">{{STAGE_NOTES}}</td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="section">
<h2>2. 关键交付文件</h2>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>文件路径</th>
<th>用途</th>
<th>状态</th>
</tr>
</thead>
<tbody>
<tr data-row="artifact">
<td class="path">{{ARTIFACT_PATH}}</td>
<td class="multiline">{{ARTIFACT_PURPOSE}}</td>
<td style="white-space: nowrap;">{{ARTIFACT_STATUS}}</td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="section">
<h2>3. 待处理与后续建议</h2>
<h3>待人工处理项</h3>
<ul>
<li data-row="manual-item" class="multiline">{{MANUAL_ITEM_1_OR_NONE}}</li>
</ul>
<h3 style="margin-top: 16px;">建议下一步</h3>
<ul>
<li data-row="next-step-item" class="multiline">{{NEXT_STEP_ITEM_1}}</li>
</ul>
</section>
</div>
</body>
</html>{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://volcengine.example/landing-zone/account-factory/baseline.schema.json",
"title": "Account Factory Baseline Definition",
"description": "Schema for account factory baseline definitions. Descriptions in this schema act as the inline guidance that the agent should follow when generating baseline files.",
"type": "object",
"additionalProperties": false,
"required": [
"name",
"modules"
],
"properties": {
"name": {
"type": "string",
"minLength": 1,
"description": "Unique baseline name. This is the logical identity used in summaries and merge results."
},
"description": {
"type": "string",
"description": "Human-readable purpose of the baseline."
},
"variables": {
"type": "object",
"description": "Variable declarations referenced by module inputs. Keys are variable names.",
"default": {},
"propertyNames": {
"pattern": "^[A-Za-z_][A-Za-z0-9_]*$"
},
"additionalProperties": {
"type": "object",
"additionalProperties": false,
"properties": {
"description": {
"type": "string",
"description": "Explain what the variable controls."
},
"required": {
"type": "boolean",
"default": false,
"description": "Whether the variable must be collected before any enabled module can run."
},
"type": {
"type": "string",
"enum": [
"string",
"number",
"integer",
"boolean",
"array",
"object"
],
"description": "Optional variable type hint for the agent or future validators."
},
"default": {
"description": "Optional default value used when the variable is omitted."
}
}
}
},
"modules": {
"type": "array",
"minItems": 1,
"description": "Ordered module declarations. When multiple baselines are merged, later selections override earlier declarations with the same module name.",
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"name",
"kind",
"source",
"enabled"
],
"properties": {
"name": {
"type": "string",
"minLength": 1,
"description": "Unique module name within the merged baseline set."
},
"description": {
"type": "string",
"description": "Optional summary shown to users before apply."
},
"kind": {
"type": "string",
"enum": [
"preset",
"custom"
],
"description": "Preset modules come from workspace blueprints; custom modules come from workspace account-factory/custom-terraform."
},
"source": {
"type": "string",
"minLength": 1,
"description": "Directory path relative to the workspace root. Preset modules should normally live under blueprints/account-factory/baseline-modules/. Custom modules should normally live under account-factory/custom-terraform/."
},
"enabled": {
"type": "boolean",
"description": "Only enabled modules participate in variable collection and execution planning."
},
"inputs": {
"type": "object",
"description": "Module input values. Variable references should use the exact string form {{ variable_name }}.",
"default": {},
"additionalProperties": true
}
}
}
}
}
}
Local File Display (DISPLAY)
This skill often needs to put a local file in front of the user, such as a solution confirmation HTML file, login information, an initial password file, or an HTML summary report, instead of retelling the file in chat. SKILL.md references this from G1, G4, STEP 0, and STEP 3.
Core rule: display means letting the user open the file itself, not retelling or summarizing its contents in chat.
In the solution-confirmation scenario especially, do not read the HTML body first and then generate a solution explanation. The correct order is: deliver the file path first, add one short guidance line and a confirmation question, then stop and wait.
Step 1. Copy into the Writable Workspace
- Copy the file into
${WORKSPACE_ROOT}/. For the solution confirmation document, copy it as${WORKSPACE_ROOT}/landing-zone-solution-plan.html. The workspace copy is the artifact you deliver. - This keeps the read-only assets under
${SKILL_ROOT}untouched. See G3 inSKILL.md.
Step 2. Put the File in Front of the User (Open First, Then Degrade)
Use the first option that works in the current runtime, in this priority order:
1. Open it (default, do this first). If the runtime can reach the user's machine (Claude Code, Trae, and similar local clients), you must actually open the file, for example macOS open <abs.html>, Linux xdg-open <abs.html>, Windows start "" <abs.html>. Opening is the default action, not an optional convenience. After it opens, add one short guidance line. 2. Degrade to path + Markdown guidance. Only if opening is unavailable or fails, give the absolute path of the file under ${WORKSPACE_ROOT} plus one short guidance line such as "Please open this file in a browser to review it". You may add a section-title-only index (table of contents) so the user can decide whether to open it. A title-only index is not a restatement of the body. Do not rewrite each section into summary paragraphs. 3. Last-resort fallback: retell in chat. Only when neither opening nor delivering an openable path is meaningful (for example a pure cloud runtime with no access to the user's machine and no preview), you may restate the plan in chat. When you do, explicitly mark it as a degraded fallback because file access was not possible. This is the lowest tier and must never be used as a shortcut when option 1 or 2 is available.
Never start any listening process (HTTP server, tunnel, python -m http.server, nc -l) to display a file. Outside the last-resort fallback, do not read the HTML body first and turn it into a chat summary; the file stays the source of truth.
Step 3. Solution-Confirmation Response Contract
When the file is landing-zone-solution-plan.html, the first user-facing turn after delivery is a strict checkpoint response, not a free-form explanation.
Allowed: one line pointing the user to the file, plus one confirmation question.
Forbidden: any summary of the solution body; any explanation of phase order, account structure, finance, identity, log, or network design; any "here is the plan" rewrite.
If you mention concrete solution content from the HTML body before the user confirms, you have violated this protocol even if the file path was delivered.
Step 4. Stop and Wait
After the file path has been delivered, stop and wait for the user's explicit feedback (confirm, adjust, or continue). This pause is required by G1 (solution confirmation) and G4 (file review). G2 does not override it.