586 lines
18 KiB
Go
586 lines
18 KiB
Go
// Copyright 2026 tamakyi. All rights reserved.
|
||
// Use of this source code is governed by a MIT-style
|
||
// license that can be found in the LICENSE file.
|
||
|
||
package cmd
|
||
|
||
import (
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"runtime"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/aws/aws-sdk-go-v2/aws"
|
||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||
"github.com/pkg/errors"
|
||
"github.com/sirupsen/logrus"
|
||
"github.com/urfave/cli/v2"
|
||
|
||
"github.com/tamakyi/TamaBox/internal/conf"
|
||
"github.com/tamakyi/TamaBox/internal/storage"
|
||
)
|
||
|
||
const (
|
||
imageObjectAttemptTimeout = 60 * time.Second
|
||
imageObjectMaxAttempts = 3
|
||
imageObjectRetryBackoff = 100 * time.Millisecond
|
||
)
|
||
|
||
// Backup 全量备份数据库与对象存储(Cloudflare R2)图片到本地目录。
|
||
//
|
||
// 为什么必须同时备份图片:站点的所有图片(头像 / 背景 / 二维码 logo / 提问图 / 回答图)
|
||
// 都以根日期 key、picture/ 或 tamabox/ 等形式存在 R2 上,数据库里只存 key 或 CDN URL。
|
||
// 只备份数据库的话,恢复后这些引用指向的图片若已不在 R2,页面就全是裂图。
|
||
//
|
||
// 输出目录结构:
|
||
//
|
||
// <output>/
|
||
// db-<20060102-150405>.sql # pg_dump 全量导出(schema + data,可用 psql 恢复)
|
||
// images/<key> # R2 对象,保持原 key 路径
|
||
// images-manifest.json # key → 实际大小 / SHA-256 / GET 元数据
|
||
//
|
||
// 用法:TamaBox backup --output /var/backups/tamabox
|
||
//
|
||
// TamaBox backup --output . --db-only # 只备份数据库
|
||
// TamaBox backup --output . --images-only # 只备份图片
|
||
var Backup = &cli.Command{
|
||
Name: "backup",
|
||
Usage: "Back up the database and all R2 objects to a local directory",
|
||
Action: runBackup,
|
||
Flags: []cli.Flag{
|
||
&cli.StringFlag{
|
||
Name: "output",
|
||
Value: ".",
|
||
Usage: "Output directory for the backup files",
|
||
},
|
||
&cli.BoolFlag{
|
||
Name: "db-only",
|
||
Usage: "Only back up the database (skip R2 images)",
|
||
},
|
||
&cli.BoolFlag{
|
||
Name: "images-only",
|
||
Usage: "Only back up R2 images (skip the database)",
|
||
},
|
||
},
|
||
}
|
||
|
||
func runBackup(ctx *cli.Context) error {
|
||
if ctx.Bool("db-only") && ctx.Bool("images-only") {
|
||
return errors.New("--db-only and --images-only cannot be used together")
|
||
}
|
||
if err := conf.Init(); err != nil {
|
||
return errors.Wrap(err, "load configuration")
|
||
}
|
||
|
||
outDir := ctx.String("output")
|
||
if outDir == "" {
|
||
return errors.New("--output must not be empty")
|
||
}
|
||
if err := os.MkdirAll(outDir, 0o700); err != nil {
|
||
return errors.Wrapf(err, "create output directory %q", outDir)
|
||
}
|
||
|
||
dbOnly := ctx.Bool("db-only")
|
||
imagesOnly := ctx.Bool("images-only")
|
||
|
||
if !imagesOnly {
|
||
if err := backupDatabase(ctx.Context, outDir); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
if !dbOnly {
|
||
if err := backupImages(ctx.Context, outDir); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
logrus.WithField("dir", outDir).Info("备份完成")
|
||
return nil
|
||
}
|
||
|
||
// backupDatabase 用 pg_dump 导出 PostgreSQL 全量数据。
|
||
//
|
||
// 为什么用外部 pg_dump 而不是在进程内逐表 SELECT:pg_dump 输出的 .sql 是标准恢复格式,
|
||
// 直接用 psql 恢复,且能正确处理序列、外键、软删除行、大对象等进程内手写导出极易漏掉的
|
||
// 细节。项目实际使用 PostgreSQL(见 code-review-2026-08-28.md P1-4)。
|
||
func backupDatabase(ctx context.Context, outDir string) error {
|
||
if conf.Database.Type != "" && conf.Database.Type != "postgres" {
|
||
return errors.Errorf("备份当前仅支持 PostgreSQL,配置为 %q", conf.Database.Type)
|
||
}
|
||
|
||
pgDump, err := exec.LookPath("pg_dump")
|
||
if err != nil {
|
||
return errors.Wrap(err, "未找到 pg_dump(PostgreSQL 客户端工具),请安装 postgresql-client")
|
||
}
|
||
|
||
ts := time.Now().Format("20060102-150405")
|
||
outFile := filepath.Join(outDir, fmt.Sprintf("db-%s.sql", ts))
|
||
|
||
// 密码经环境变量传递,避免出现在进程参数里(ps 可见)。
|
||
cmd := exec.CommandContext(ctx, pgDump,
|
||
"--host", conf.Database.Host,
|
||
"--port", fmt.Sprintf("%d", conf.Database.Port),
|
||
"--username", conf.Database.User,
|
||
"--dbname", conf.Database.Name,
|
||
"--no-owner", "--no-privileges",
|
||
"--format", "plain",
|
||
)
|
||
cmd.Env = append(os.Environ(), "PGPASSWORD="+conf.Database.Password)
|
||
|
||
var dumpErr strings.Builder
|
||
if err := writeFileAtomically(outFile, func(w io.Writer) error {
|
||
cmd.Stdout = w
|
||
cmd.Stderr = &dumpErr
|
||
if err := cmd.Run(); err != nil {
|
||
return errors.Wrapf(err, "pg_dump failed: %s", strings.TrimSpace(dumpErr.String()))
|
||
}
|
||
return nil
|
||
}); err != nil {
|
||
return err
|
||
}
|
||
|
||
logrus.WithField("file", outFile).Info("数据库已备份")
|
||
return nil
|
||
}
|
||
|
||
// writeFileAtomically 保留数据库、manifest 与对象下载共用的原子写修复:
|
||
// 只有完整写入、Sync 并关闭成功后才替换目标,失败时清理临时 .part 文件。
|
||
func writeFileAtomically(outFile string, write func(io.Writer) error) error {
|
||
f, err := os.CreateTemp(filepath.Dir(outFile), "."+filepath.Base(outFile)+"-*.part")
|
||
if err != nil {
|
||
return errors.Wrapf(err, "create temporary file for %q", outFile)
|
||
}
|
||
tmpFile := f.Name()
|
||
defer os.Remove(tmpFile)
|
||
defer f.Close()
|
||
|
||
if err := write(f); err != nil {
|
||
return err
|
||
}
|
||
if err := f.Sync(); err != nil {
|
||
return errors.Wrapf(err, "sync temporary file for %q", outFile)
|
||
}
|
||
if err := f.Close(); err != nil {
|
||
return errors.Wrapf(err, "close temporary file for %q", outFile)
|
||
}
|
||
if err := os.Rename(tmpFile, outFile); err != nil {
|
||
return errors.Wrapf(err, "finalize %q", outFile)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// imageManifestEntry 记录实际下载内容的大小和 SHA-256,以及 GET 返回的原始元数据。
|
||
// ETag 只是服务商元数据,不能当作内容 MD5 或完整性校验的替代品。
|
||
type imageManifestEntry struct {
|
||
Key string `json:"key"`
|
||
Size int64 `json:"size"`
|
||
SHA256 string `json:"sha256"`
|
||
ETag string `json:"etag,omitempty"`
|
||
ContentType string `json:"content_type,omitempty"`
|
||
CacheControl string `json:"cache_control,omitempty"`
|
||
}
|
||
|
||
type backupS3API interface {
|
||
ListObjectsV2(context.Context, *s3.ListObjectsV2Input, ...func(*s3.Options)) (*s3.ListObjectsV2Output, error)
|
||
GetObject(context.Context, *s3.GetObjectInput, ...func(*s3.Options)) (*s3.GetObjectOutput, error)
|
||
}
|
||
|
||
type backupDownloadPlan struct {
|
||
key string
|
||
destination string
|
||
}
|
||
|
||
type downloadedImageObject struct {
|
||
size int64
|
||
sha256 string
|
||
etag string
|
||
contentType string
|
||
cacheControl string
|
||
}
|
||
|
||
// backupImages 默认使用空前缀列举配置 bucket 的全部对象,而不是假定图片都在 tamabox/ 下。
|
||
func backupImages(ctx context.Context, outDir string) error {
|
||
client, err := backupS3Client(ctx)
|
||
if err != nil {
|
||
return errors.Wrap(err, "get R2 client for backup")
|
||
}
|
||
return backupImagesWithClient(ctx, client, conf.Upload.ImageBucket, outDir, "")
|
||
}
|
||
|
||
// backupImagesWithClient 允许测试和隔离演练显式指定 prefix。
|
||
// 所有分页 List、key 校验和本地路径预检都在第一次 GetObject 之前完成。
|
||
func backupImagesWithClient(ctx context.Context, client backupS3API, bucket, outDir, prefix string) error {
|
||
keys, err := listAllBackupKeys(ctx, client, bucket, prefix)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
imageDir := filepath.Join(outDir, "images")
|
||
plans, err := planBackupDownloads(imageDir, keys)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
manifest := make([]imageManifestEntry, 0, len(plans))
|
||
var totalBytes int64
|
||
for _, plan := range plans {
|
||
if err := ensureBackupDestinationDirectory(imageDir, plan.destination); err != nil {
|
||
return err
|
||
}
|
||
downloaded, err := downloadObject(ctx, client, bucket, plan.key, plan.destination)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
manifest = append(manifest, imageManifestEntry{
|
||
Key: plan.key,
|
||
Size: downloaded.size,
|
||
SHA256: downloaded.sha256,
|
||
ETag: downloaded.etag,
|
||
ContentType: downloaded.contentType,
|
||
CacheControl: downloaded.cacheControl,
|
||
})
|
||
totalBytes += downloaded.size
|
||
}
|
||
|
||
manifestFile := filepath.Join(outDir, "images-manifest.json")
|
||
if err := writeFileAtomically(manifestFile, func(w io.Writer) error {
|
||
enc := json.NewEncoder(w)
|
||
enc.SetIndent("", " ")
|
||
if err := enc.Encode(manifest); err != nil {
|
||
return errors.Wrap(err, "write manifest")
|
||
}
|
||
return nil
|
||
}); err != nil {
|
||
return err
|
||
}
|
||
|
||
logrus.WithFields(logrus.Fields{
|
||
"objects": len(manifest),
|
||
"bytes": totalBytes,
|
||
"dir": imageDir,
|
||
"prefix": prefix,
|
||
}).Info("图片已备份")
|
||
return nil
|
||
}
|
||
|
||
func listAllBackupKeys(ctx context.Context, client backupS3API, bucket, prefix string) ([]string, error) {
|
||
keys := make([]string, 0)
|
||
var continuationToken *string
|
||
seenTokens := make(map[string]struct{})
|
||
|
||
for {
|
||
page, err := client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{
|
||
Bucket: aws.String(bucket),
|
||
Prefix: aws.String(prefix),
|
||
ContinuationToken: continuationToken,
|
||
})
|
||
if err != nil {
|
||
return nil, errors.Wrapf(err, "list R2 objects prefix %q", prefix)
|
||
}
|
||
if page == nil {
|
||
return nil, errors.New("list R2 objects returned a nil page")
|
||
}
|
||
for _, object := range page.Contents {
|
||
// nil key 与空 key 一样是无效输入;统一转成空串,由完整 List 后的预检拒绝。
|
||
keys = append(keys, aws.ToString(object.Key))
|
||
}
|
||
|
||
if !aws.ToBool(page.IsTruncated) {
|
||
return keys, nil
|
||
}
|
||
next := aws.ToString(page.NextContinuationToken)
|
||
if next == "" {
|
||
return nil, errors.New("truncated R2 list response has no continuation token")
|
||
}
|
||
if _, exists := seenTokens[next]; exists {
|
||
return nil, errors.Errorf("R2 list continuation token %q repeated", next)
|
||
}
|
||
seenTokens[next] = struct{}{}
|
||
continuationToken = aws.String(next)
|
||
}
|
||
}
|
||
|
||
func planBackupDownloads(imageDir string, keys []string) ([]backupDownloadPlan, error) {
|
||
plans := make([]backupDownloadPlan, 0, len(keys))
|
||
files := make(map[string]string, len(keys))
|
||
directories := make(map[string]string)
|
||
|
||
root, err := filepath.Abs(imageDir)
|
||
if err != nil {
|
||
return nil, errors.Wrap(err, "resolve images directory")
|
||
}
|
||
directories[canonicalLocalPath(root)] = ""
|
||
|
||
for _, key := range keys {
|
||
destination, err := localImagePath(root, key)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
canonicalDestination := canonicalLocalPath(destination)
|
||
if previous, exists := files[canonicalDestination]; exists {
|
||
return nil, errors.Errorf("objects %q and %q map to the same local file", previous, key)
|
||
}
|
||
if previous, exists := directories[canonicalDestination]; exists {
|
||
return nil, errors.Errorf("file/directory collision between objects %q and %q", previous, key)
|
||
}
|
||
|
||
for parent := filepath.Dir(destination); pathWithinDirectory(root, parent); parent = filepath.Dir(parent) {
|
||
canonicalParent := canonicalLocalPath(parent)
|
||
if previous, exists := files[canonicalParent]; exists {
|
||
return nil, errors.Errorf("file/directory collision between objects %q and %q", previous, key)
|
||
}
|
||
if _, exists := directories[canonicalParent]; !exists {
|
||
directories[canonicalParent] = key
|
||
}
|
||
if parent == root {
|
||
break
|
||
}
|
||
}
|
||
files[canonicalDestination] = key
|
||
plans = append(plans, backupDownloadPlan{key: key, destination: destination})
|
||
}
|
||
|
||
if err := prepareBackupImageRoot(root); err != nil {
|
||
return nil, err
|
||
}
|
||
for _, plan := range plans {
|
||
if err := preflightBackupLocalPath(root, plan.destination); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
return plans, nil
|
||
}
|
||
|
||
func validateObjectKey(key string) error {
|
||
if key == "" {
|
||
return errors.New("R2 object key must not be empty")
|
||
}
|
||
if strings.ContainsRune(key, 0) {
|
||
return errors.Errorf("R2 object key %q contains NUL", key)
|
||
}
|
||
if strings.Contains(key, `\`) {
|
||
return errors.Errorf("R2 object key %q contains a backslash", key)
|
||
}
|
||
if strings.HasSuffix(key, "/") {
|
||
return errors.Errorf("R2 object key %q is a directory key", key)
|
||
}
|
||
if strings.HasPrefix(key, "/") || filepath.IsAbs(filepath.FromSlash(key)) || filepath.VolumeName(filepath.FromSlash(key)) != "" ||
|
||
(len(key) >= 2 && ((key[0] >= 'A' && key[0] <= 'Z') || (key[0] >= 'a' && key[0] <= 'z')) && key[1] == ':') {
|
||
return errors.Errorf("R2 object key %q is an absolute or volume-qualified path", key)
|
||
}
|
||
for _, segment := range strings.Split(key, "/") {
|
||
if segment == "" || segment == "." || segment == ".." {
|
||
return errors.Errorf("R2 object key %q contains an empty or dot path segment", key)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func localImagePath(imageDir, key string) (string, error) {
|
||
if err := validateObjectKey(key); err != nil {
|
||
return "", err
|
||
}
|
||
root, err := filepath.Abs(imageDir)
|
||
if err != nil {
|
||
return "", errors.Wrap(err, "resolve images directory")
|
||
}
|
||
destination := filepath.Join(root, filepath.FromSlash(key))
|
||
if !pathWithinDirectory(root, destination) || destination == root {
|
||
return "", errors.Errorf("R2 object key %q escapes the images directory", key)
|
||
}
|
||
return destination, nil
|
||
}
|
||
|
||
func pathWithinDirectory(root, candidate string) bool {
|
||
rel, err := filepath.Rel(root, candidate)
|
||
if err != nil || filepath.IsAbs(rel) {
|
||
return false
|
||
}
|
||
return rel == "." || rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
|
||
}
|
||
|
||
func canonicalLocalPath(path string) string {
|
||
cleaned := filepath.Clean(path)
|
||
if runtime.GOOS == "windows" {
|
||
return strings.ToLower(cleaned)
|
||
}
|
||
return cleaned
|
||
}
|
||
|
||
func prepareBackupImageRoot(imageDir string) error {
|
||
info, err := os.Lstat(imageDir)
|
||
if os.IsNotExist(err) {
|
||
if err := os.MkdirAll(imageDir, 0o700); err != nil {
|
||
return errors.Wrap(err, "create images directory")
|
||
}
|
||
info, err = os.Lstat(imageDir)
|
||
}
|
||
if err != nil {
|
||
return errors.Wrap(err, "inspect images directory")
|
||
}
|
||
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
||
return errors.Errorf("images path %q must be a real directory, not a symlink or file", imageDir)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func preflightBackupLocalPath(imageDir, destination string) error {
|
||
info, err := os.Lstat(destination)
|
||
if err == nil && (info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular()) {
|
||
return errors.Errorf("backup destination %q is not a regular file", destination)
|
||
}
|
||
if err != nil && !os.IsNotExist(err) {
|
||
return errors.Wrapf(err, "inspect backup destination %q", destination)
|
||
}
|
||
|
||
for parent := filepath.Dir(destination); parent != imageDir; parent = filepath.Dir(parent) {
|
||
if !pathWithinDirectory(imageDir, parent) {
|
||
return errors.Errorf("backup destination %q escapes images directory", destination)
|
||
}
|
||
info, err := os.Lstat(parent)
|
||
if os.IsNotExist(err) {
|
||
continue
|
||
}
|
||
if err != nil {
|
||
return errors.Wrapf(err, "inspect backup parent %q", parent)
|
||
}
|
||
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
||
return errors.Errorf("backup parent %q is not a real directory", parent)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func ensureBackupDestinationDirectory(imageDir, destination string) error {
|
||
root, err := filepath.Abs(imageDir)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
parent := filepath.Dir(destination)
|
||
rel, err := filepath.Rel(root, parent)
|
||
if err != nil || !pathWithinDirectory(root, parent) {
|
||
return errors.Errorf("invalid backup destination %q", destination)
|
||
}
|
||
if rel == "." {
|
||
return nil
|
||
}
|
||
|
||
current := root
|
||
for _, segment := range strings.Split(rel, string(filepath.Separator)) {
|
||
current = filepath.Join(current, segment)
|
||
info, err := os.Lstat(current)
|
||
if os.IsNotExist(err) {
|
||
if err := os.Mkdir(current, 0o700); err != nil && !os.IsExist(err) {
|
||
return errors.Wrapf(err, "create backup directory %q", current)
|
||
}
|
||
info, err = os.Lstat(current)
|
||
}
|
||
if err != nil {
|
||
return errors.Wrapf(err, "inspect backup directory %q", current)
|
||
}
|
||
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
||
return errors.Errorf("backup parent %q is not a real directory", current)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// backupS3Client 与上传、恢复共用 storage.NewS3Client 的 endpoint 语义:
|
||
// endpoint 可不带路径或只带 /<bucket>,请求始终以 Bucket 参数和原始完整 key 寻址。
|
||
// List 返回什么 key 就下载什么 key,不额外添加或剥离 bucket 前缀。
|
||
func backupS3Client(ctx context.Context) (*s3.Client, error) {
|
||
return storage.NewS3Client(
|
||
ctx,
|
||
conf.Upload.ImageEndpoint,
|
||
conf.Upload.ImageBucket,
|
||
conf.Upload.ImageAccessID,
|
||
conf.Upload.ImageAccessSecret,
|
||
)
|
||
}
|
||
|
||
// downloadObject 原子下载单个对象,并返回实际落盘内容的 SHA-256 和 GET 元数据。
|
||
// 每次尝试都有独立 60 秒 context;流中断会从头重试整个对象,最多 3 次。
|
||
func downloadObject(ctx context.Context, client backupS3API, bucket, key, destination string) (downloadedImageObject, error) {
|
||
var lastErr error
|
||
for attempt := 0; attempt < imageObjectMaxAttempts; attempt++ {
|
||
if err := ctx.Err(); err != nil {
|
||
return downloadedImageObject{}, err
|
||
}
|
||
attemptCtx, cancel := context.WithTimeout(ctx, imageObjectAttemptTimeout)
|
||
downloaded, err := downloadObjectOnce(attemptCtx, client, bucket, key, destination)
|
||
cancel()
|
||
if err == nil {
|
||
return downloaded, nil
|
||
}
|
||
lastErr = err
|
||
if ctx.Err() != nil {
|
||
return downloadedImageObject{}, ctx.Err()
|
||
}
|
||
if attempt == imageObjectMaxAttempts-1 {
|
||
break
|
||
}
|
||
if err := waitForImageObjectRetry(ctx, imageObjectRetryBackoff<<attempt); err != nil {
|
||
return downloadedImageObject{}, err
|
||
}
|
||
}
|
||
return downloadedImageObject{}, errors.Wrapf(lastErr, "download %q after %d attempts", key, imageObjectMaxAttempts)
|
||
}
|
||
|
||
func downloadObjectOnce(ctx context.Context, client backupS3API, bucket, key, destination string) (downloadedImageObject, error) {
|
||
out, err := client.GetObject(ctx, &s3.GetObjectInput{
|
||
Bucket: aws.String(bucket),
|
||
Key: aws.String(key),
|
||
})
|
||
if err != nil {
|
||
return downloadedImageObject{}, errors.Wrapf(err, "get R2 object %q", key)
|
||
}
|
||
if out == nil || out.Body == nil {
|
||
return downloadedImageObject{}, errors.Errorf("get R2 object %q returned an empty response body", key)
|
||
}
|
||
|
||
hash := sha256.New()
|
||
var size int64
|
||
writeErr := writeFileAtomically(destination, func(w io.Writer) error {
|
||
var err error
|
||
size, err = io.Copy(io.MultiWriter(w, hash), out.Body)
|
||
if err != nil {
|
||
return errors.Wrapf(err, "download %q", key)
|
||
}
|
||
if out.ContentLength != nil && size != aws.ToInt64(out.ContentLength) {
|
||
return errors.Wrapf(io.ErrUnexpectedEOF, "download %q: GET content length is %d, received %d", key, aws.ToInt64(out.ContentLength), size)
|
||
}
|
||
return nil
|
||
})
|
||
_ = out.Body.Close()
|
||
if writeErr != nil {
|
||
return downloadedImageObject{}, writeErr
|
||
}
|
||
return downloadedImageObject{
|
||
size: size,
|
||
sha256: hex.EncodeToString(hash.Sum(nil)),
|
||
etag: aws.ToString(out.ETag),
|
||
contentType: aws.ToString(out.ContentType),
|
||
cacheControl: aws.ToString(out.CacheControl),
|
||
}, nil
|
||
}
|
||
|
||
func waitForImageObjectRetry(ctx context.Context, delay time.Duration) error {
|
||
timer := time.NewTimer(delay)
|
||
defer timer.Stop()
|
||
select {
|
||
case <-ctx.Done():
|
||
return ctx.Err()
|
||
case <-timer.C:
|
||
return nil
|
||
}
|
||
}
|