From 47890ca9134b6fe7b4325d6d5448032a2a6e4a54 Mon Sep 17 00:00:00 2001 From: Lemon-miaow Date: Thu, 24 Sep 2026 19:25:16 +0800 Subject: [PATCH] =?UTF-8?q?feat(offsite):=20=E4=B8=96=E7=95=8C=E5=BD=92?= =?UTF-8?q?=E6=A1=A3=E4=B8=8E=E6=95=B0=E6=8D=AE=E5=BA=93=E5=A4=87=E4=BB=BD?= =?UTF-8?q?=E5=8A=A0=E5=AF=86=E5=90=8C=E6=AD=A5=E5=88=B0=E5=BC=82=E5=9C=B0?= =?UTF-8?q?=20S3=EF=BC=8Creaper=20=E7=A1=AE=E8=AE=A4=E5=BC=82=E5=9C=B0?= =?UTF-8?q?=E5=89=AF=E6=9C=AC=E5=90=8E=E6=89=8D=E5=88=A0=E9=99=A4=E4=B8=96?= =?UTF-8?q?=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AUDIT-2026-09-22.md | 1 + cmd/felis/offsite.go | 556 ++++++++++++++++++ cmd/felis/offsite_test.go | 96 +++ cmd/felis/reaper.go | 5 +- cmd/felis/run.go | 2 + cmd/felis/watchdog.go | 5 + deploy/bootstrap.sh | 230 +++++++- deploy/bootstrap_test.sh | 171 +++++- docs/troubleshooting.md | 122 +++- internal/config/config.go | 88 +++ internal/config/config_test.go | 47 ++ internal/dbbackup/dbbackup.go | 6 +- internal/dbbackup/dbbackup_test.go | 4 +- internal/offsite/bucket.go | 160 +++++ internal/offsite/crypt.go | 195 ++++++ internal/offsite/offsite_test.go | 446 ++++++++++++++ internal/offsite/pgcatalog.go | 73 +++ internal/offsite/status.go | 64 ++ internal/offsite/sync.go | 464 +++++++++++++++ internal/platform/workloads.go | 37 ++ internal/platform/workloads_test.go | 29 + internal/reaper/pgstore.go | 16 +- internal/reaper/reaper.go | 38 +- internal/reaper/reaper_test.go | 45 +- .../migrations/0024_world_backups_offsite.sql | 11 + internal/watchdog/probes.go | 41 +- internal/watchdog/probes_test.go | 27 +- 27 files changed, 2928 insertions(+), 51 deletions(-) create mode 100644 cmd/felis/offsite.go create mode 100644 cmd/felis/offsite_test.go create mode 100644 internal/offsite/bucket.go create mode 100644 internal/offsite/crypt.go create mode 100644 internal/offsite/offsite_test.go create mode 100644 internal/offsite/pgcatalog.go create mode 100644 internal/offsite/status.go create mode 100644 internal/offsite/sync.go create mode 100644 internal/store/migrations/0024_world_backups_offsite.sql diff --git a/AUDIT-2026-09-22.md b/AUDIT-2026-09-22.md index 64207d2..3e9ef8d 100644 --- a/AUDIT-2026-09-22.md +++ b/AUDIT-2026-09-22.md @@ -963,6 +963,7 @@ IPv6-only 接入(`ssh -6 -i ~/.ssh/id_ed25519 root@fdb2:2c26:f4e4:0:21c:42ff:f 30. ~~已装机系统服的新增 CR 字段(tracker #1)~~ ✅ **已修并真机闭环**(第四十六批 #78:`sudo felis converge`——零值才填、非零不覆写;剥字段→填回→幂等三连真机过)。 31. ~~troubleshooting `[INERT]` 图例~~ ✅ **已修**(第四十六批 #79,文档级)。 32. ~~稳定版发布与 release 安装/升级通道~~ ✅ **已实证**(第四十七批:tag `v0.1.0`(`b9c97ff`)+ release run `35950722509` 双资产、`/releases/latest` 解析到 v0.1.0;无 checkout 全量安装 2m17s / 零 fail / 零 warn、registry mirror 四镜像、reaper CronJob 对齐 `felis:v0.1.0`;`felis update` 双向报告〔rc1→v0.1.0 notify / v0.1.0 up to date〕)。 +33. ~~世界归档与数据库备份只在本机~~ ✅ **已修并真机闭环**(2026-09-24:`felis offsite` + `felis-offsite.timer` 每小时把世界归档与 DB bundle 以 AES-256-GCM 分段加密推到 S3 兼容桶,`world_backups.offsite_at` 记账〔迁移 0024〕;配了 `[offsite]` 后 reaper 只在归档的异地副本确认后才删 PVC;watchdog 12 小时无成功同步即告警;安装器 `FELIS_OFFSITE_*` 生成密钥并在收尾横幅要求离机保存。真机(MinIO):首次同步 8 世界 1.2 GiB + 12 bundle、两条"记录在册但盘上已无"的历史归档如实列出;`fetch-db latest` sha256 与本机一致、错误密钥拒绝且不留半成品;`fetch-worlds` 取回被挪走的归档 sha256 一致;reaper 演练〔20 天空闲世界〕第一轮 `awaiting_offsite=1` 且 PVC 保留 → 同步后第二轮 `reaped=1`、PVC 删除、所有权释放;watchdog 把 status 改成 35 小时前 → `[warning] offsite`。演练装置已清理)。 ## 剩余待演练队列(截至第四十三批) diff --git a/cmd/felis/offsite.go b/cmd/felis/offsite.go new file mode 100644 index 0000000..9dc14b0 --- /dev/null +++ b/cmd/felis/offsite.go @@ -0,0 +1,556 @@ +package main + +import ( + "bufio" + "context" + "errors" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "felis.lolicon.best/internal/config" + "felis.lolicon.best/internal/dbbackup" + "felis.lolicon.best/internal/offsite" + "felis.lolicon.best/internal/platform" + "felis.lolicon.best/internal/store" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const offsiteUsage = `usage: + felis offsite sync [-config path] [-archive-dir dir] [-db-dir dir] [-status-file path] + felis offsite status [-config path] [-status-file path] + felis offsite list [-config path] + felis offsite fetch-db [-config path | -endpoint url -bucket name [-region r] [-prefix p]] + [-dir dir] latest| + felis offsite fetch-worlds [-config path] [-archive-dir dir] + felis offsite keygen + +Every verb but keygen reads the bucket credentials and the encryption key from +the variables [offsite] names (default FELIS_OFFSITE_ACCESS_KEY, +FELIS_OFFSITE_SECRET_KEY, FELIS_OFFSITE_KEY), taking any that are unset from +-env-file (default /etc/felis/offsite.env). +` + +// defaultOffsiteEnvFile is where bootstrap keeps the [offsite] secrets; the +// felis-offsite.service unit loads it as its EnvironmentFile. +const defaultOffsiteEnvFile = "/etc/felis/offsite.env" + +// cmdOffsite implements `felis offsite`: the off-site copy of the world +// archives and the database bundles (internal/offsite). felis-offsite.timer +// runs `sync` hourly on the host; the fetch verbs are the way back after the +// node is lost (docs/troubleshooting.md §16). +func cmdOffsite(args []string, stdout, stderr io.Writer) int { + if len(args) == 0 { + fmt.Fprint(stderr, offsiteUsage) + return 2 + } + verb, rest := args[0], args[1:] + fs := flag.NewFlagSet("offsite "+verb, flag.ContinueOnError) + fs.SetOutput(stderr) + fs.Usage = func() { fmt.Fprint(stderr, offsiteUsage) } + switch verb { + case "sync": + return offsiteSync(fs, rest, stdout, stderr) + case "status": + return offsiteStatus(fs, rest, stdout, stderr) + case "list": + return offsiteList(fs, rest, stdout, stderr) + case "fetch-db": + return offsiteFetchDB(fs, rest, stdout, stderr) + case "fetch-worlds": + return offsiteFetchWorlds(fs, rest, stdout, stderr) + case "keygen": + k, err := offsite.NewKey() + if err != nil { + fmt.Fprintf(stderr, "felis offsite keygen: %v\n", err) + return 1 + } + fmt.Fprintln(stdout, k) + return 0 + case "-h", "--help", "help": + fmt.Fprint(stdout, offsiteUsage) + return 0 + } + fmt.Fprintf(stderr, "felis offsite: unknown verb %q\n%s", verb, offsiteUsage) + return 2 +} + +// offsiteEnv is the resolved [offsite] binding: the bucket and the key. +type offsiteEnv struct { + cfg config.OffsiteConfig + bucket *offsite.S3 + key []byte +} + +// loadOffsiteEnvFile sets each KEY=VALUE of path that is not already in the +// environment, so a root shell reaches the bucket the same way the unit does. +// A missing file is not an error. +func loadOffsiteEnvFile(path string) error { + if path == "" { + return nil + } + f, err := os.Open(path) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + defer f.Close() + sc := bufio.NewScanner(f) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + k, v, ok := strings.Cut(line, "=") + if !ok { + continue + } + k = strings.TrimSpace(strings.TrimPrefix(k, "export ")) + v = strings.TrimSpace(v) + if len(v) >= 2 && (v[0] == '"' || v[0] == '\'') && v[len(v)-1] == v[0] { + v = v[1 : len(v)-1] + } + if os.Getenv(k) == "" { + os.Setenv(k, v) + } + } + return sc.Err() +} + +// resolveOffsite builds the bucket client and parses the key for c. +func resolveOffsite(c config.OffsiteConfig) (*offsiteEnv, error) { + if !c.Enabled() { + return nil, errors.New("no [offsite] bucket is configured (docs/troubleshooting.md §16, \"Keep a copy somewhere else\")") + } + need := func(ref, what string) (string, error) { + v := os.Getenv(ref) + if v == "" { + return "", fmt.Errorf("%s: environment variable %s is empty (set it, or put it in %s)", what, ref, defaultOffsiteEnvFile) + } + return v, nil + } + ak, err := need(c.AccessKeyRef, "access key") + if err != nil { + return nil, err + } + sk, err := need(c.SecretKeyRef, "secret key") + if err != nil { + return nil, err + } + rawKey, err := need(c.KeyRef, "encryption key") + if err != nil { + return nil, err + } + key, err := offsite.ParseKey(rawKey) + if err != nil { + return nil, err + } + b, err := offsite.NewS3(offsite.S3Config{ + Endpoint: c.Endpoint, Region: c.Region, Bucket: c.Bucket, Prefix: c.Prefix, + AccessKey: ak, SecretKey: sk, + }) + if err != nil { + return nil, err + } + return &offsiteEnv{cfg: c, bucket: b, key: key}, nil +} + +// loadOffsite loads felis.toml and the env file and resolves [offsite]. +func loadOffsite(cfgPath, envFile string) (*config.Config, *offsiteEnv, error) { + if err := loadOffsiteEnvFile(envFile); err != nil { + return nil, nil, fmt.Errorf("read %s: %w", envFile, err) + } + cfg, err := config.Load(cfgPath) + if err != nil { + return nil, nil, err + } + env, err := resolveOffsite(cfg.Offsite) + if err != nil { + return cfg, nil, err + } + return cfg, env, nil +} + +func offsiteSync(fs *flag.FlagSet, args []string, stdout, stderr io.Writer) int { + cfgPath := fs.String("config", "/etc/felis/felis.toml", "path to felis.toml (the host copy, which reaches PostgreSQL on 127.0.0.1)") + envFile := fs.String("env-file", defaultOffsiteEnvFile, "file with the [offsite] secrets, for variables not already set") + archiveDir := fs.String("archive-dir", "", "host directory of the world archive volume (default: resolved from the backup PVC through the cluster)") + backupPVC := fs.String("backup-pvc", "felis-backups", `the world archive PVC, in the [k8s] namespace ("" when backups are off)`) + dbDir := fs.String("db-dir", dbbackup.DefaultDir, `database bundle directory ("" copies no bundles)`) + statusFile := fs.String("status-file", offsite.DefaultStatusFile, "where the result of this run is recorded for the watchdog and `status`") + if err := fs.Parse(args); err != nil { + return 2 + } + cfg, env, err := loadOffsite(*cfgPath, *envFile) + if err != nil { + fmt.Fprintf(stderr, "felis offsite sync: %v\n", err) + return 1 + } + st := offsite.Status{ + LastAttempt: time.Now().UTC(), Endpoint: env.cfg.Endpoint, Bucket: env.cfg.Bucket, + Prefix: env.cfg.Prefix, KeyID: offsite.KeyID(env.key), + } + if prev, _ := offsite.ReadStatus(*statusFile); prev != nil { + st.LastSuccess = prev.LastSuccess + } + res, err := runOffsiteSync(cfg, env, *archiveDir, *backupPVC, *dbDir, stderr) + st.Result = res + if err != nil { + st.LastError = err.Error() + } else { + st.LastSuccess = st.LastAttempt + } + if werr := offsite.WriteStatus(*statusFile, st); werr != nil { + fmt.Fprintf(stderr, "felis offsite sync: record status: %v\n", werr) + } + fmt.Fprintf(stdout, "felis offsite sync: worlds copied=%d pending=%d missing=%d expired=%d; bundles copied=%d pruned=%d; bucket holds %d worlds (%s) and %d bundles\n", + res.WorldsUploaded, res.WorldsPending, len(res.WorldsMissing), res.WorldsExpired, + res.DBUploaded, res.DBPruned, res.RemoteWorlds, offsite.HumanBytes(res.RemoteBytes), res.RemoteDB) + for _, m := range res.WorldsMissing { + fmt.Fprintf(stderr, "felis offsite sync: recorded archive not on the volume, nothing to copy: %s\n", m) + } + if err != nil { + fmt.Fprintf(stderr, "felis offsite sync: %v\n", err) + return 1 + } + return 0 +} + +func runOffsiteSync(cfg *config.Config, env *offsiteEnv, archiveDir, backupPVC, dbDir string, log io.Writer) (offsite.Result, error) { + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Minute) + defer cancel() + checkCtx, checkCancel := context.WithTimeout(ctx, 30*time.Second) + err := env.bucket.Check(checkCtx) + checkCancel() + if err != nil { + return offsite.Result{}, err + } + if archiveDir == "" && backupPVC != "" { + dir, err := resolveArchiveDir(ctx, cfg.K8s.Namespace, backupPVC, false, log) + if err != nil { + return offsite.Result{}, err + } + archiveDir = dir + } + drv, err := store.Open(ctx, cfg.Database.URL) + if err != nil { + return offsite.Result{}, fmt.Errorf("open database: %w", err) + } + defer drv.Close() + s := &offsite.Syncer{ + Bucket: env.bucket, Catalog: offsite.PGCatalog{DB: drv.DB()}, Key: env.key, + ArchiveDir: archiveDir, DBDir: dbDir, DBKeep: env.cfg.DBKeep, Log: log, + } + return s.Run(ctx) +} + +// resolveArchiveDir finds the host directory behind the world archive PVC: a +// local-path volume is a directory on this node. A PVC still waiting for its +// first consumer holds nothing yet: without bind that is "" (no archives), +// with bind it is bound first, for fetch-worlds to write into. +func resolveArchiveDir(ctx context.Context, ns, pvcName string, bind bool, log io.Writer) (string, error) { + if ns == "" { + ns = platform.DefaultMinecraftNamespace + } + cl, err := buildSystemServerClient() + if err != nil { + return "", fmt.Errorf("reach the cluster to find the archive volume (or pass -archive-dir): %w", err) + } + var pvc corev1.PersistentVolumeClaim + if err := cl.Get(ctx, types.NamespacedName{Namespace: ns, Name: pvcName}, &pvc); err != nil { + return "", fmt.Errorf("archive volume %s/%s: %w", ns, pvcName, err) + } + if pvc.Spec.VolumeName == "" { + if !bind { + fmt.Fprintf(log, "felis offsite: archive volume %s/%s is not bound yet; no world has been archived\n", ns, pvcName) + return "", nil + } + if err := bindVolume(ctx, cl, ns, pvcName, log); err != nil { + return "", err + } + if err := cl.Get(ctx, types.NamespacedName{Namespace: ns, Name: pvcName}, &pvc); err != nil { + return "", err + } + } + var pv corev1.PersistentVolume + if err := cl.Get(ctx, types.NamespacedName{Name: pvc.Spec.VolumeName}, &pv); err != nil { + return "", fmt.Errorf("archive volume %s: %w", pvc.Spec.VolumeName, err) + } + var dir string + switch { + case pv.Spec.Local != nil: + dir = pv.Spec.Local.Path + case pv.Spec.HostPath != nil: + dir = pv.Spec.HostPath.Path + default: + return "", fmt.Errorf("archive volume %s is not a directory on a node (local or hostPath); pass -archive-dir with where it is mounted on this host", pv.Name) + } + if fi, err := os.Stat(dir); err != nil || !fi.IsDir() { + return "", fmt.Errorf("archive volume %s is %s on its node, which is not a directory here; run this on the node that holds it, or pass -archive-dir", pv.Name, dir) + } + return dir, nil +} + +// bindVolume runs a pod that mounts the PVC and exits, which is what makes a +// WaitForFirstConsumer volume (k3s local-path) get provisioned. The pod uses +// the control plane's own image, which every install already has. +func bindVolume(ctx context.Context, cl client.Client, ns, pvcName string, log io.Writer) error { + var api appsv1.Deployment + if err := cl.Get(ctx, types.NamespacedName{Namespace: platform.DefaultControlNamespace, Name: "felis-api"}, &api); err != nil { + return fmt.Errorf("find the felis image to bind the archive volume with: %w", err) + } + if len(api.Spec.Template.Spec.Containers) == 0 { + return errors.New("felis-api has no container to take the image from") + } + image := api.Spec.Template.Spec.Containers[0].Image + pod := platform.VolumeBinderPod(ns, pvcName, image) + if err := cl.Create(ctx, pod); err != nil { + return fmt.Errorf("start a pod to bind the archive volume: %w", err) + } + fmt.Fprintf(log, "felis offsite: binding the archive volume %s/%s (pod %s)\n", ns, pvcName, pod.Name) + defer func() { + _ = cl.Delete(context.Background(), pod, client.PropagationPolicy(metav1.DeletePropagationBackground)) + }() + deadline := time.Now().Add(3 * time.Minute) + for time.Now().Before(deadline) { + var pvc corev1.PersistentVolumeClaim + if err := cl.Get(ctx, types.NamespacedName{Namespace: ns, Name: pvcName}, &pvc); err == nil && pvc.Spec.VolumeName != "" && pvc.Status.Phase == corev1.ClaimBound { + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(2 * time.Second): + } + } + return fmt.Errorf("the archive volume %s/%s did not bind within 3 minutes; see kubectl -n %s describe pod %s", ns, pvcName, ns, pod.Name) +} + +func offsiteStatus(fs *flag.FlagSet, args []string, stdout, stderr io.Writer) int { + cfgPath := fs.String("config", "/etc/felis/felis.toml", "path to felis.toml") + statusFile := fs.String("status-file", offsite.DefaultStatusFile, "the record `sync` writes") + if err := fs.Parse(args); err != nil { + return 2 + } + cfg, err := config.Load(*cfgPath) + if err != nil { + fmt.Fprintf(stderr, "felis offsite status: %v\n", err) + return 1 + } + if !cfg.Offsite.Enabled() { + fmt.Fprintln(stdout, "off-site copy: not configured. World archives and database bundles exist on this machine only.") + fmt.Fprintln(stdout, "See docs/troubleshooting.md §16, \"Keep a copy somewhere else\".") + return 1 + } + o := cfg.Offsite + fmt.Fprintf(stdout, "bucket: %s at %s", o.Bucket, o.Endpoint) + if o.Prefix != "" { + fmt.Fprintf(stdout, ", prefix %s", o.Prefix) + } + fmt.Fprintln(stdout) + st, err := offsite.ReadStatus(*statusFile) + if err != nil { + fmt.Fprintf(stderr, "felis offsite status: %v\n", err) + return 1 + } + if st == nil { + fmt.Fprintln(stdout, "last sync: never (sudo systemctl start felis-offsite.service)") + return 1 + } + now := time.Now() + fmt.Fprintf(stdout, "key id: %s\n", st.KeyID) + fmt.Fprintf(stdout, "last attempt: %s (%s ago)\n", st.LastAttempt.Local().Format(time.DateTime), dbbackup.Age(now.Sub(st.LastAttempt))) + if st.LastSuccess.IsZero() { + fmt.Fprintln(stdout, "last success: never") + } else { + fmt.Fprintf(stdout, "last success: %s (%s ago)\n", st.LastSuccess.Local().Format(time.DateTime), dbbackup.Age(now.Sub(st.LastSuccess))) + } + if st.LastError != "" { + fmt.Fprintf(stdout, "last error: %s\n", st.LastError) + } + r := st.Result + fmt.Fprintf(stdout, "bucket holds: %d world archives (%s), %d database bundles, newest %s\n", + r.RemoteWorlds, offsite.HumanBytes(r.RemoteBytes), r.RemoteDB, orNone(r.NewestDB)) + fmt.Fprintf(stdout, "waiting: %d world archives not yet copied\n", r.WorldsPending) + for _, m := range r.WorldsMissing { + fmt.Fprintf(stdout, "missing: %s is recorded but not on the volume\n", m) + } + if st.LastSuccess.IsZero() || now.Sub(st.LastSuccess) > offsite.StaleAfter { + fmt.Fprintf(stdout, "\nThe last successful sync is older than %s: journalctl -u felis-offsite -n 50\n", dbbackup.Age(offsite.StaleAfter)) + return 1 + } + return 0 +} + +func orNone(s string) string { + if s == "" { + return "none" + } + return s +} + +func offsiteList(fs *flag.FlagSet, args []string, stdout, stderr io.Writer) int { + cfgPath := fs.String("config", "/etc/felis/felis.toml", "path to felis.toml") + envFile := fs.String("env-file", defaultOffsiteEnvFile, "file with the [offsite] secrets, for variables not already set") + if err := fs.Parse(args); err != nil { + return 2 + } + _, env, err := loadOffsite(*cfgPath, *envFile) + if err != nil { + fmt.Fprintf(stderr, "felis offsite list: %v\n", err) + return 1 + } + return printOffsiteList(env, stdout, stderr) +} + +func printOffsiteList(env *offsiteEnv, stdout, stderr io.Writer) int { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + bundles, err := offsite.ListDB(ctx, env.bucket) + if err != nil { + fmt.Fprintf(stderr, "felis offsite list: %v\n", err) + return 1 + } + worlds, err := env.bucket.List(ctx, "worlds/") + if err != nil { + fmt.Fprintf(stderr, "felis offsite list: %v\n", err) + return 1 + } + fmt.Fprintf(stdout, "database bundles (%d, newest first):\n", len(bundles)) + for _, b := range bundles { + fmt.Fprintf(stdout, " %s %s\n", b.Key, offsite.HumanBytes(b.Size)) + } + var total int64 + for _, w := range worlds { + total += w.Size + } + fmt.Fprintf(stdout, "world archives: %d (%s)\n", len(worlds), offsite.HumanBytes(total)) + return 0 +} + +func offsiteFetchDB(fs *flag.FlagSet, args []string, stdout, stderr io.Writer) int { + cfgPath := fs.String("config", "/etc/felis/felis.toml", "path to felis.toml; on a host with no install yet, give -endpoint and -bucket instead") + envFile := fs.String("env-file", defaultOffsiteEnvFile, "file with the [offsite] secrets, for variables not already set") + endpoint := fs.String("endpoint", "", "bucket endpoint, when there is no felis.toml") + bucket := fs.String("bucket", "", "bucket name, when there is no felis.toml") + region := fs.String("region", "", "bucket region, when there is no felis.toml") + prefix := fs.String("prefix", "", "key prefix, when there is no felis.toml") + dir := fs.String("dir", dbbackup.DefaultDir, "directory to write the bundle to") + arg, ok := parseWithArg(fs, args) + if !ok { + return 2 + } + if arg == "" { + fmt.Fprint(stderr, offsiteUsage) + return 2 + } + if err := loadOffsiteEnvFile(*envFile); err != nil { + fmt.Fprintf(stderr, "felis offsite fetch-db: read %s: %v\n", *envFile, err) + return 1 + } + var oc config.OffsiteConfig + if *bucket != "" { + oc = config.OffsiteConfig{ + Endpoint: *endpoint, Bucket: *bucket, Region: *region, Prefix: *prefix, + AccessKeyRef: config.DefaultOffsiteAccessKeyEnv, SecretKeyRef: config.DefaultOffsiteSecretKeyEnv, + KeyRef: config.DefaultOffsiteKeyEnv, + } + } else { + cfg, err := config.Load(*cfgPath) + if err != nil { + fmt.Fprintf(stderr, "felis offsite fetch-db: %v (on a host with no install yet, pass -endpoint and -bucket)\n", err) + return 1 + } + oc = cfg.Offsite + } + env, err := resolveOffsite(oc) + if err != nil { + fmt.Fprintf(stderr, "felis offsite fetch-db: %v\n", err) + return 1 + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) + defer cancel() + name := arg + if name == "latest" { + bundles, err := offsite.ListDB(ctx, env.bucket) + if err != nil { + fmt.Fprintf(stderr, "felis offsite fetch-db: %v\n", err) + return 1 + } + if len(bundles) == 0 { + fmt.Fprintln(stderr, "felis offsite fetch-db: the bucket holds no database bundle") + return 1 + } + name = bundles[0].Key + } + if _, _, ok := dbbackup.ParseBundleName(name); !ok { + fmt.Fprintf(stderr, "felis offsite fetch-db: %q is not a bundle name (felis-db--