Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 34 additions & 2 deletions cmd/cloudctl/README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

[中文](README.md) | [English](README.en.md)

`cloudctl` is the restricted operational management tool for Ora Cloud. It runs with database operator credentials outside the public API path to manage database migrations and bootstrap initial tenants and administrators.
`cloudctl` is the restricted operational management tool for Ora Cloud. It runs with database operator credentials outside the public API path to manage database migrations, bootstrap initial tenants and administrators, and explicitly dispose of residual resources.

## Commands and responsibilities

Expand All @@ -26,9 +26,41 @@ cloudctl -config <path> -command bootstrap -name '<tenant-name>' -source '<idp-s
- Returns a JSON payload containing `tenantId` and `userId`.

See [External Git line](../../docs/repositories/external-git.en.md) for Git credential submission and reference management.

### `residual`

Place global `-config` before `residual` and action flags after the action. Every action requires `--tenant-id`. Queries and business commands do not need DDL privileges; access to the controlled host and database credentials governs access to this entry point. Core uses PostgreSQL `session_user` as the authenticated actor. Optional `--operator-label` is self-reported and cannot select or override that actor. The CLI accepts no database password flag and reuses existing configuration loading.

```sh
cloudctl -config <path> residual list --tenant-id <tenant> --project-id <project> --limit 100
cloudctl -config <path> residual get --tenant-id <tenant> --workspace-id <workspace>
cloudctl -config <path> residual get --tenant-id <tenant> --intent-id <intent>
```

- `list` intersects optional `--project-id`, `--run-id`, `--workspace-id`, `--operation-id`, and `--reclaim-id` filters, with `--limit` (default 100) and `--after`. It returns `items/nextCursor`; operation filtering can match either the original failed D8 delete or the currently bound deletion operation.
- `get` accepts one root/child `--intent-id`, or an exact `--run-id`, `--workspace-id`, or `--reclaim-id`. Other resource IDs may narrow a resource selector; an intent selector cannot be mixed with resource selectors. Queries return audit history, target evidence, and `openTargets` for external confirmation.

Termination and data deletion are separate commands, each requiring a reason, idempotency key, and affirmative confirmation. `terminate` requires at least a Run or Workspace; all additional IDs must identify the same original resource. D8 also requires the original failed delete `--operation-id`. Project/tenant selectors cannot authorize batch writes.

```sh
cloudctl -config <path> residual terminate --tenant-id <tenant> --run-id <run> --workspace-id <workspace> --reason 'operator abandoned execution' --key <terminate-key> --impact-confirmed
cloudctl -config <path> residual delete-data --tenant-id <tenant> --intent-id <root-intent> --reason 'delete retained data' --key <new-delete-key> --data-delete-confirmed
```

`terminate` registers termination authorization and retains data by default; acceptance does not mean sandboxes have terminated. The root intent succeeds with `terminated_data_retained` only after every target has genuine termination and late-ensure fencing confirmation. A later `delete-data` accepts only that completed root intent and separately registers a child intent and one fixed deletion operation; `released` requires actual deletion confirmation. It rejects bare Workspace deletion and does not substitute external confirmation for real termination evidence.

An unfinished intent whose disposal cannot be confirmed by the system may explicitly transfer its remaining responsibility. Read `openTargets` from `get` first and supply one `--external-fact` per unfinished target. Keys must be the exact `sandbox:<id>` or `data:<workspace-id>` values; values reference a ticket or another external disposal fact. Duplicate targets, empty references or explanations, and missing responsibility confirmation are rejected; core also checks for omitted targets.

```sh
cloudctl -config <path> residual confirm-external --tenant-id <tenant> --intent-id <unfinished-intent> --reason 'external disposal' --key <external-key> --evidence-note 'host decommissioned; data responsibility accepted' --external-fact 'sandbox:<id>=ticket-123' --external-fact 'data:<workspace-id>=ticket-456' --responsibility-transferred
```

External confirmation accepts only an existing unfinished intent and records a distinct `externally_confirmed` disposition. It manufactures no `terminated`, `lateEnsureFenced`, or `removed` physical evidence and does not authorize reuse of the same volume. Write keys are tenant-wide across actions: identical input returns current progress for the same responsibility; changing the action, target, reason, or confirmation under the same key returns `idempotency_conflict`.

## Boundaries and invariants

- **Deployment boundary**: `cloudctl` is not exposed via HTTP and is not invoked by the server daemon. It requires direct network access to PostgreSQL with schema migration permissions.
- **Deployment boundary**: `cloudctl` is not exposed via HTTP and is not invoked by the server daemon. It requires direct PostgreSQL access; only migration commands need schema migration privileges. Residual management adds no public tenant admin/member permissions, and public HTTP routes continue to reject hidden Run Workspaces.
- **Structured output**: All command results are output as structured JSON to `stdout`, and errors to `stderr`, enabling integration with CI/CD deployment pipelines.
- **Errors and credentials**: Residual commands preserve stable core Fault codes. Unknown infrastructure errors produce only `internal_error`; configuration or connection failures use safe generic codes without connection strings or passwords. Invalid arguments produce `invalid_cli_arguments`; `--impact-confirmed=false` does not confirm an action.

See [cmd overview](../README.en.md), [Database migrations](../../internal/core/migrations/README.en.md), and [Internal credentials](../../docs/identity/authentication.en.md).
36 changes: 34 additions & 2 deletions cmd/cloudctl/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

[中文](README.md) | [English](README.en.md)

`cloudctl` 是 Ora Cloud 受限的运维管理工具。它在公共 API 调用路径之外运行,使用数据库操作员凭据管理数据库迁移以及初始租户和管理员的引导配置。
`cloudctl` 是 Ora Cloud 受限的运维管理工具。它在公共 API 调用路径之外运行,使用数据库操作员凭据管理数据库迁移、初始租户和管理员的引导配置,以及残留资源的显式处置。

## 命令与职责

Expand All @@ -26,9 +26,41 @@ cloudctl -config <path> -command bootstrap -name '<tenant-name>' -source '<idp-s
- 返回包含 `tenantId` 和 `userId` 的 JSON 数据载荷。

Git 凭据的提交与引用管理见 [外部 Git 线](../../docs/repositories/external-git.md)。

### `residual`

全局 `-config` 必须放在 `residual` 前,动作参数放在动作后。所有动作都必须提供 `--tenant-id`。查询及业务命令不需要 DDL 权限;受控主机和数据库凭据决定谁能使用这个入口。core 取 PostgreSQL `session_user` 作为认证主体;可选 `--operator-label` 只是人工自述,不能选择或覆盖认证主体。CLI 不接收数据库密码参数,沿用现有配置加载方式。

```sh
cloudctl -config <path> residual list --tenant-id <tenant> --project-id <project> --limit 100
cloudctl -config <path> residual get --tenant-id <tenant> --workspace-id <workspace>
cloudctl -config <path> residual get --tenant-id <tenant> --intent-id <intent>
```

- `list` 支持 `--project-id`、`--run-id`、`--workspace-id`、`--operation-id`、`--reclaim-id` 相交过滤,以及 `--limit`(默认 100)和 `--after`。返回 `items/nextCursor`;operation 过滤可命中 D8 原 failed delete 或当前绑定的删除操作。
- `get` 接受一个根/从属 `--intent-id`,或精确的 `--run-id`、`--workspace-id`、`--reclaim-id`。原资源选择器可用其他资源 ID 进一步限制范围;意图选择器不能混用资源选择器。查询返回审计、目标证据和 `openTargets`,供外部确认引用。

终止和删数据是两次独立命令,各自需要 reason、幂等 key 和真实确认。`terminate` 至少指定 Run 或 Workspace;提供的其他 ID 必须属于同一个原资源。D8 还必须提供原 failed delete 的 `--operation-id`。Project/tenant 不能作为批量写入范围。

```sh
cloudctl -config <path> residual terminate --tenant-id <tenant> --run-id <run> --workspace-id <workspace> --reason 'operator abandoned execution' --key <terminate-key> --impact-confirmed
cloudctl -config <path> residual delete-data --tenant-id <tenant> --intent-id <root-intent> --reason 'delete retained data' --key <new-delete-key> --data-delete-confirmed
```

`terminate` 登记终止授权,默认保留数据;命令受理不代表沙盒已经终止。只有每个目标都有真实终止及迟到 ensure 已被封闭的双确认,根意图才可成功并报告 `terminated_data_retained`。后续 `delete-data` 只接受这个已完成的根意图,单独登记从属意图和唯一删除操作;实际删除确认后才能报告 `released`。它不接受裸 Workspace 删除,也不以外部确认代替真实终止证据。

无法由系统确认的未完成意图可通过下列命令显式转移剩余责任。先从 `get` 读取 `openTargets`,为每个未完成目标各提供一次 `--external-fact`;键必须是原样的 `sandbox:<id>` 或 `data:<workspace-id>`,值是工单或其他外部处置事实引用。重复目标、空引用、空说明及未确认责任转移会被拒绝;core 继续核对是否遗漏目标。

```sh
cloudctl -config <path> residual confirm-external --tenant-id <tenant> --intent-id <unfinished-intent> --reason 'external disposal' --key <external-key> --evidence-note 'host decommissioned; data responsibility accepted' --external-fact 'sandbox:<id>=ticket-123' --external-fact 'data:<workspace-id>=ticket-456' --responsibility-transferred
```

外部确认只接受已存在且未完成的意图,保存独立的 `externally_confirmed` 处置,不制造 `terminated`、`lateEnsureFenced` 或 `removed` 物理证据,不授权同卷复用。幂等 key 在租户内跨写动作唯一:相同输入返回同一责任的当前进度;同键改动作、目标、reason 或确认返回 `idempotency_conflict`。

## 边界与不变量

- **部署边界**:`cloudctl` 不通过 HTTP 暴露,也不由 server 守护进程调用。它必须能够直接连接 PostgreSQL,并拥有 Schema 迁移权限。
- **部署边界**:`cloudctl` 不通过 HTTP 暴露,也不由 server 守护进程调用。它必须能够直接连接 PostgreSQL;只有迁移命令需要 Schema 迁移权限。残留管理不新增 tenant admin/member 的公共权限,隐藏 Run Workspace 的公开 HTTP 入口继续拒绝访问。
- **结构化输出**:所有命令执行结果均以结构化 JSON 格式输出到 `stdout`,错误信息输出到 `stderr`,便于与 CI/CD 自动化部署流水线集成。
- **错误与凭据**:残留命令保留 core 的稳定 Fault code;未知基础设施错误仅输出 `internal_error`,配置或连接失败输出安全的通用 code,不输出连接串或密码。参数错误为 `invalid_cli_arguments`;`--impact-confirmed=false` 等同于未确认。

参见 [cmd 入口总览](../README.md)、[数据库迁移目录](../../internal/core/migrations/README.md) 与 [内部凭据与服务信任](../../docs/identity/authentication.md)。
75 changes: 57 additions & 18 deletions cmd/cloudctl/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"

"github.com/wanglongan587/cloud/internal/config"
Expand All @@ -21,39 +23,76 @@ func main() {
}

func run() error {
configFile := flag.String("config", "", "configuration file")
command := flag.String("command", "", "migrate | bootstrap")
name := flag.String("name", "", "tenant name")
source := flag.String("source", "", "stable identity namespace")
subject := flag.String("subject", "", "identity subject")
display := flag.String("display-name", "", "display name")
flag.Parse()
cfg, e := config.Load(*configFile)
return runArgs(context.Background(), os.Args[1:], os.Stdout)
}

type commandOptions struct {
configFile, command, name, source, subject, display string
residual core.ResidualManagementRequest
}

func parseCommand(args []string) (commandOptions, error) {
var opts commandOptions
flags := flag.NewFlagSet("cloudctl", flag.ContinueOnError)
flags.SetOutput(io.Discard)
flags.StringVar(&opts.configFile, "config", "", "configuration file")
flags.StringVar(&opts.command, "command", "", "migrate | bootstrap")
flags.StringVar(&opts.name, "name", "", "tenant name")
flags.StringVar(&opts.source, "source", "", "stable identity namespace")
flags.StringVar(&opts.subject, "subject", "", "identity subject")
flags.StringVar(&opts.display, "display-name", "", "display name")
if err := flags.Parse(args); err != nil {
return opts, invalidArguments()
}
if rest := flags.Args(); len(rest) != 0 {
if rest[0] != "residual" || opts.command != "" || opts.name != "" || opts.source != "" || opts.subject != "" || opts.display != "" {
return opts, invalidArguments()
}
request, err := parseResidual(rest[1:])
if err != nil {
return opts, err
}
opts.command, opts.residual = "residual", request
}
if opts.command != "migrate" && opts.command != "bootstrap" && (opts.command != "residual" || opts.residual.Action == "") {
return opts, invalidArguments()
}
return opts, nil
}

func runArgs(ctx context.Context, args []string, output io.Writer) error {
opts, e := parseCommand(args)
if e != nil {
return e
}
db, e := repository.InitDB(context.Background(), cfg.Database)
cfg, e := config.Load(opts.configFile)
if e != nil {
return e
return errors.New("configuration_error")
}
db, e := repository.InitDB(ctx, cfg.Database)
if e != nil {
return errors.New("database_unavailable")
}
s, e := core.NewStore(db)
if e != nil {
return e
if pool, poolErr := db.DB(); poolErr == nil {
_ = pool.Close()
}
return errors.New("database_unavailable")
}
defer s.Pool.Close()
ctx := context.Background()
var out core.Object
switch *command {
switch opts.command {
case "migrate":
e = s.Migrate(ctx)
out = core.Object{"migrated": e == nil}
case "bootstrap":
out, e = s.Bootstrap(ctx, *name, *source, *subject, *display)
default:
return fmt.Errorf("unknown command; choose migrate, bootstrap")
out, e = s.Bootstrap(ctx, opts.name, opts.source, opts.subject, opts.display)
case "residual":
return dispatchResidual(ctx, &opts.residual, output, s.ManageResidual)
}
if e != nil {
return e
return core.ErrorCode(e)
}
return json.NewEncoder(os.Stdout).Encode(out)
return json.NewEncoder(output).Encode(out)
}
Loading
Loading