Files
orbit/cmd/orbit-admin/main.go

405 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"reflect"
"strings"
"time"
"git.yuntuo.hk/k2safe/orbit/internal/buildinfo"
"git.yuntuo.hk/k2safe/orbit/internal/config"
"git.yuntuo.hk/k2safe/orbit/internal/storage/mysql"
core "git.yuntuo.hk/k2safe/orbit/model"
)
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run() error {
if len(os.Args) < 2 {
return errors.New("usage: orbit-admin release manifest|bootstrap|console-user create|verify|observe snapshot|retention inventory|credential status|rotate|revoke|apply")
}
switch os.Args[1] {
case "release":
return runRelease(os.Args[2:])
case "bootstrap":
return runBootstrap(os.Args[2:])
case "console-user":
return runConsoleUser(os.Args[2:])
case "verify":
return runVerify(os.Args[2:])
case "observe":
return runObserve(os.Args[2:])
case "retention":
return runRetention(os.Args[2:])
case "credential":
return runCredential(os.Args[2:])
}
return errors.New("usage: orbit-admin release manifest|bootstrap|console-user create|verify|observe snapshot|retention inventory|credential status|rotate|revoke|apply")
}
// releaseManifest 是发布物自身的静态身份清单,不读取数据库、不包含 DSN、凭据或业务数据。
// 部署前可以保存它,与镜像摘要、git 提交和目标数据库的 verify 输出一起作为发布证据。
type releaseManifest struct {
ManifestVersion int `json:"manifest_version"` // ManifestVersion 清单格式版本,变更时递增。
Build buildinfo.Info `json:"build"` // Build 当前二进制及协议身份。
Schema releaseSchemaManifest `json:"schema"` // Schema 当前内置迁移链摘要。
}
// releaseSchemaManifest 描述当前二进制可执行的前向迁移链;SQL 正文不写入清单。
type releaseSchemaManifest struct {
CurrentVersion uint64 `json:"current_version"` // CurrentVersion 最新迁移版本。
MigrationCount int `json:"migration_count"` // MigrationCount 迁移数量。
Migrations []releaseMigrationManifest `json:"migrations"` // Migrations 按版本排序的名称和校验和。
}
// releaseMigrationManifest 是单条迁移的不可变身份,不暴露迁移 SQL。
type releaseMigrationManifest struct {
Version uint64 `json:"version"` // Version 迁移版本。
Name string `json:"name"` // Name 迁移文件名。
Checksum string `json:"checksum"` // Checksum 迁移内容 SHA-256。
}
func runRelease(args []string) error {
if len(args) != 1 || args[0] != "manifest" {
return errors.New("usage: orbit-admin release manifest")
}
manifest, err := currentReleaseManifest()
if err != nil {
return err
}
return json.NewEncoder(os.Stdout).Encode(manifest)
}
func currentReleaseManifest() (releaseManifest, error) {
migrations, err := mysql.Migrations()
if err != nil || len(migrations) == 0 {
return releaseManifest{}, errors.New("release migration manifest unavailable")
}
items := make([]releaseMigrationManifest, 0, len(migrations))
for _, migration := range migrations {
items = append(items, releaseMigrationManifest{
Version: migration.Version,
Name: migration.Name,
Checksum: migration.Checksum,
})
}
return releaseManifest{
ManifestVersion: 1,
Build: buildinfo.Current(),
Schema: releaseSchemaManifest{
CurrentVersion: migrations[len(migrations)-1].Version,
MigrationCount: len(items),
Migrations: items,
},
}, nil
}
// validateReleaseManifest 确认外部保存的发布清单与当前管理二进制完全一致。
// 它只比较静态身份,不连接数据库,也不改变任何状态。
func validateReleaseManifest(path string) error {
file, err := os.Open(path)
if err != nil {
return errors.New("release manifest could not be opened")
}
defer file.Close()
decoder := json.NewDecoder(file)
decoder.DisallowUnknownFields()
var got releaseManifest
if err := decoder.Decode(&got); err != nil {
return errors.New("release manifest is invalid")
}
var trailing any
if err := decoder.Decode(&trailing); err != io.EOF {
return errors.New("release manifest contains trailing data")
}
want, err := currentReleaseManifest()
if err != nil {
return err
}
if !reflect.DeepEqual(got, want) {
return errors.New("release manifest does not match this binary")
}
return nil
}
func runVerify(args []string) error {
flags := flag.NewFlagSet("verify", flag.ContinueOnError)
var manifestPath string
flags.StringVar(&manifestPath, "manifest", "", "saved release manifest to compare with this binary")
if err := flags.Parse(args); err != nil {
return err
}
if flags.NArg() != 0 {
return errors.New("verify does not accept positional arguments")
}
if manifestPath != "" {
if err := validateReleaseManifest(manifestPath); err != nil {
return err
}
}
cfg, err := config.Load()
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
db, err := mysql.Open(ctx, cfg.MySQLDSN, 2, 1)
if err != nil {
return errors.New("database unavailable")
}
defer db.Close()
verification, err := mysql.VerifyOperationalSchema(ctx, db)
encoded, encodeErr := json.Marshal(verification)
if encodeErr != nil {
return encodeErr
}
fmt.Println(string(encoded))
if err != nil {
return err
}
return nil
}
// runObserve exposes the same bounded, read-only snapshot used by the private
// metrics sampler. It is deliberately an operator command rather than a
// tenant-facing API: values are per database node and may be truncated.
func runObserve(args []string) error {
if len(args) != 1 || args[0] != "snapshot" {
return errors.New("usage: orbit-admin observe snapshot")
}
cfg, err := config.Load()
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
db, err := mysql.Open(ctx, cfg.MySQLDSN, 2, 1)
if err != nil {
return errors.New("database unavailable")
}
defer db.Close()
snapshot, err := mysql.ReadOperationalSnapshot(ctx, db)
if err != nil {
return errors.New("operational snapshot unavailable")
}
output := struct {
Snapshot mysql.OperationalSnapshot `json:"snapshot"`
SampleLimit int `json:"sample_limit"`
EventWindowSeconds int `json:"event_window_seconds"`
Scope string `json:"scope"`
}{
Snapshot: snapshot,
SampleLimit: 10000,
EventWindowSeconds: 300,
Scope: "single database node; bounded sample, not a global exact count",
}
return json.NewEncoder(os.Stdout).Encode(output)
}
func runRetention(args []string) error {
if len(args) == 0 || args[0] != "inventory" {
return errors.New("usage: orbit-admin retention inventory --before RFC3339")
}
flags := flag.NewFlagSet("retention inventory", flag.ContinueOnError)
var before string
flags.StringVar(&before, "before", "", "UTC cutoff; only records strictly before it are counted")
if err := flags.Parse(args[1:]); err != nil {
return err
}
if flags.NArg() != 0 || before == "" {
return errors.New("--before RFC3339 is required")
}
cutoff, err := time.Parse(time.RFC3339Nano, before)
if err != nil {
return errors.New("--before must be RFC3339")
}
cfg, err := config.Load()
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
db, err := mysql.Open(ctx, cfg.MySQLDSN, 2, 1)
if err != nil {
return errors.New("database unavailable")
}
defer db.Close()
inventory, err := mysql.ReadRetentionInventory(ctx, db, cutoff)
encoded, encodeErr := json.Marshal(inventory)
if encodeErr != nil {
return encodeErr
}
fmt.Println(string(encoded))
return err
}
func runBootstrap(args []string) error {
flags := flag.NewFlagSet("bootstrap", flag.ContinueOnError)
var in mysql.BootstrapInput
var output string
flags.StringVar(&in.Application, "application", "", "new application name")
flags.Func("application-id", "existing application id; mutually exclusive with --application", func(v string) error {
in.ExistingApplicationID = core.ID(v)
return in.ExistingApplicationID.Validate()
})
flags.BoolVar(&in.ResourceAccess, "resource-access", false, "grant application-wide resource read and manage actions")
flags.StringVar(&in.Namespace, "namespace", "", "new namespace name")
flags.StringVar(&in.Environment, "environment", "", "environment name")
flags.StringVar(&in.Queue, "queue", "", "initial queue name")
flags.StringVar(&output, "output", "", "new private credentials JSON file")
flags.BoolVar(&in.WorkerAccess, "worker-access", false, "also grant Worker connect and consume actions for development")
flags.BoolVar(&in.ScheduleAccess, "schedule-access", false, "also grant schedule read and manage actions for development")
flags.BoolVar(&in.EventAccess, "event-access", false, "also grant result subscription read, manage and consume actions")
flags.BoolVar(&in.RecoveryAccess, "recovery-access", false, "explicitly grant task review, manual retry and operation audit read actions")
flags.BoolVar(&in.WorkflowAccess, "workflow-access", false, "explicitly grant workflow definition read and publish actions")
flags.BoolVar(&in.WorkflowRunAccess, "workflow-run-access", false, "explicitly grant workflow start and read actions")
flags.BoolVar(&in.WorkflowControlAccess, "workflow-control-access", false, "explicitly grant workflow control and read actions")
flags.BoolVar(&in.WorkflowSignalAccess, "workflow-signal-access", false, "explicitly grant workflow signal and read actions")
flags.BoolVar(&in.WorkflowRecoveryAccess, "workflow-recovery-access", false, "explicitly grant workflow recovery and read actions")
flags.BoolVar(&in.WorkflowAuditAccess, "workflow-audit-access", false, "explicitly grant workflow operation audit read action")
if err := flags.Parse(args); err != nil {
return err
}
if flags.NArg() != 0 || output == "" {
return errors.New("--output and named resource arguments are required")
}
cfg, err := config.Load()
if err != nil {
return err
}
file, err := os.OpenFile(output, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
if err != nil {
return errors.New("cannot create new private output file (existing files are never overwritten)")
}
defer file.Close()
delivered := false
defer func() {
if !delivered {
_ = os.Remove(output)
}
}()
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
db, err := mysql.Open(ctx, cfg.MySQLDSN, 2, 1)
if err != nil {
return err
}
defer db.Close()
err = mysql.Bootstrap(ctx, db, in, func(result mysql.BootstrapResult) error {
if err := json.NewEncoder(file).Encode(result); err != nil {
return err
}
if err := file.Sync(); err != nil {
return err
}
directory, err := os.Open(filepath.Dir(output))
if err != nil {
return err
}
err = directory.Sync()
closeErr := directory.Close()
if err != nil {
return err
}
if closeErr != nil {
return closeErr
}
delivered = true
return nil
})
if err != nil {
if delivered {
return errors.New("bootstrap commit could not be confirmed; private output was retained; verify its credential before retrying")
}
return errors.New("bootstrap failed before credential delivery; check schema, names and database access")
}
fmt.Println("Bootstrap committed. Credentials saved in the requested private file; valid for 30 days.")
return nil
}
// runConsoleUser 创建一个控制台登录账号。密码默认从标准输入读取,避免出现在
// shell 历史和进程列表;账号只绑定已有业务凭据,不会生成或输出业务 token。
func runConsoleUser(args []string) error {
if len(args) == 0 || args[0] != "create" {
return errors.New("usage: orbit-admin console-user create --credential-file path --username name [--display-name name] [--password-stdin]")
}
flags := flag.NewFlagSet("console-user create", flag.ContinueOnError)
var namespace, credential, credentialFile, username, displayName, password string
var passwordStdin bool
flags.StringVar(&namespace, "namespace-id", "", "namespace id; can be read from --credential-file")
flags.StringVar(&credential, "credential-id", "", "credential id; can be read from --credential-file")
flags.StringVar(&credentialFile, "credential-file", "", "private bootstrap credential JSON file")
flags.StringVar(&username, "username", "", "console login username")
flags.StringVar(&displayName, "display-name", "", "console display name")
flags.StringVar(&password, "password", "", "password; prefer --password-stdin")
flags.BoolVar(&passwordStdin, "password-stdin", false, "read password from stdin")
if err := flags.Parse(args[1:]); err != nil {
return err
}
if flags.NArg() != 0 || username == "" {
return errors.New("--username and principal information are required")
}
if credentialFile != "" {
raw, err := os.ReadFile(credentialFile)
if err != nil {
return errors.New("credential file could not be read")
}
var file struct {
NamespaceID core.ID `json:"namespace_id"`
CredentialID core.ID `json:"credential_id"`
}
if err := json.Unmarshal(raw, &file); err != nil {
return errors.New("credential file is invalid")
}
if namespace == "" {
namespace = string(file.NamespaceID)
}
if credential == "" {
credential = string(file.CredentialID)
}
}
if passwordStdin {
raw, err := io.ReadAll(io.LimitReader(os.Stdin, 2049))
if err != nil {
return errors.New("password could not be read")
}
password = strings.TrimRight(string(raw), "\r\n")
}
ns, cred := core.ID(namespace), core.ID(credential)
if ns.Validate() != nil || cred.Validate() != nil || password == "" {
return errors.New("valid namespace, credential and password are required")
}
cfg, err := config.Load()
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
db, err := mysql.Open(ctx, cfg.MySQLDSN, 2, 1)
if err != nil {
return errors.New("database unavailable")
}
defer db.Close()
user, err := mysql.NewConsoleAuthStore(db).CreateConsoleUser(ctx, mysql.ConsoleUserInput{NamespaceID: ns, CredentialID: cred, Username: username, DisplayName: displayName, Password: password})
if err != nil {
return err
}
encoded, err := json.Marshal(user)
if err != nil {
return err
}
fmt.Println(string(encoded))
return nil
}