405 lines
14 KiB
Go
405 lines
14 KiB
Go
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
|
||
}
|