package main import ( "context" "encoding/hex" "encoding/json" "errors" "flag" "fmt" "os" "os/signal" "path/filepath" "syscall" "time" "git.yuntuo.hk/k2safe/orbit/internal/config" "git.yuntuo.hk/k2safe/orbit/internal/fault" "git.yuntuo.hk/k2safe/orbit/internal/retention" "git.yuntuo.hk/k2safe/orbit/internal/storage/mysql" core "git.yuntuo.hk/k2safe/orbit/model" ) func runRetentionArchive(args []string) error { flags := flag.NewFlagSet("retention archive", flag.ContinueOnError) var spec retention.JobSpec var directory, namespace string var options retention.JobOptions var timeout time.Duration flags.StringVar(&directory, "directory", os.Getenv("ORBIT_ARCHIVE_DIRECTORY"), "private per-job output directory; same directory resumes saved batches") flags.StringVar(&namespace, "namespace", "", "namespace ID to archive") flags.StringVar(&spec.Kind, "kind", "", "tasks|workflows|events|audit|deliveries|inbox|idempotency") flags.Uint64Var(&spec.PolicyRevision, "revision", 0, "confirmed database retention policy revision") flags.IntVar(&spec.BatchSize, "batch-size", 20, "roots per batch, 1-100; immutable for this job") flags.IntVar(&options.MaxBatches, "max-batches", 10, "maximum new batches per invocation, 1-1000") flags.Int64Var(&options.MaxBytes, "max-bytes", 256<<20, "total saved manifest and batch bytes, 1 MiB-100 GiB") flags.DurationVar(&timeout, "timeout", 5*time.Minute, "invocation deadline, 1s-1h") if err := flags.Parse(args); err != nil { return err } spec.NamespaceID = core.ID(namespace) if flags.NArg() != 0 || directory == "" || !filepath.IsAbs(directory) || timeout < time.Second || timeout > time.Hour || options.MaxBatches < 1 || options.MaxBatches > 1000 || options.MaxBytes < 1<<20 || options.MaxBytes > 100<<30 { return errors.New("invalid archive arguments; use --help for limits and an absolute --directory") } if err := spec.Validate(); err != nil { return err } cfg, err := config.Load() if err != nil { return err } signalCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() ctx, cancel := context.WithTimeout(signalCtx, timeout) defer cancel() db, err := mysql.Open(ctx, cfg.MySQLDSN, 2, 1) if err != nil { return errors.New("database unavailable") } defer db.Close() if err := mysql.CheckSchema(ctx, db); err != nil { return errors.New("database schema is not ready") } store := mysql.NewRetentionPolicyStore(db, cfg.RetentionDefaults) status, runErr := retention.RunArchive(ctx, directory, spec, options, func(ctx context.Context, job core.ID, input retention.ArchiveInput) (retention.ArchiveSnapshot, error) { snapshot, err := store.ExportRetentionForJob(ctx, spec.NamespaceID, job, input) if err != nil { // 数据库原始错误可能含 SQL 参数,只输出固定错误码,业务正文不进入控制台日志。 if ctx.Err() != nil { return retention.ArchiveSnapshot{}, ctx.Err() } return retention.ArchiveSnapshot{}, fmt.Errorf("archive database export failed (%s); keep original directory and verify policy before retrying", fault.Code(err)) } return snapshot, nil }) if err := json.NewEncoder(os.Stdout).Encode(status); err != nil { return err } return runErr } func runRetentionVerifyArchive(args []string) error { flags := flag.NewFlagSet("retention verify-archive", flag.ContinueOnError) var directory, expectedSHA string flags.StringVar(&directory, "directory", os.Getenv("ORBIT_ARCHIVE_DIRECTORY"), "saved archive job directory; no database connection required") flags.StringVar(&expectedSHA, "expected-sha256", "", "optional independently saved last_sha256 to pin the entire chain") if err := flags.Parse(args); err != nil { return err } if flags.NArg() != 0 || directory == "" { return errors.New("--directory is required") } if expectedSHA != "" { raw, err := hex.DecodeString(expectedSHA) if err != nil || len(raw) != 32 || hex.EncodeToString(raw) != expectedSHA { return errors.New("--expected-sha256 must be 64 lowercase hexadecimal characters") } } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() status, err := retention.VerifyArchive(ctx, directory) if encodeErr := json.NewEncoder(os.Stdout).Encode(status); encodeErr != nil { return encodeErr } if err == nil && expectedSHA != "" && status.LastSHA256 != expectedSHA { return errors.New("archive differs from independently saved digest") } return err }