feat(offsite): 异地副本加入 registry 用户镜像,按摘要加密去重,索引按版本保留 14 天,新增 fetch-images 回推恢复
This commit is contained in:
13 files changed
+1625
-50
No files matched your search
@@ -14,7 +14,6 @@ import (
|
|||||||
|
|
||||||
"felis.lolicon.best/internal/build"
|
"felis.lolicon.best/internal/build"
|
||||||
"felis.lolicon.best/internal/imagepush"
|
"felis.lolicon.best/internal/imagepush"
|
||||||
"felis.lolicon.best/internal/registrygate"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// defaultBuildToolsStatus is where mirror-build-tools records its last run; the
|
// defaultBuildToolsStatus is where mirror-build-tools records its last run; the
|
||||||
@@ -49,16 +48,9 @@ func cmdMirrorBuildTools(args []string, stdout, stderr io.Writer) int {
|
|||||||
fmt.Fprintf(stderr, "felis mirror-build-tools: %v\n", err)
|
fmt.Fprintf(stderr, "felis mirror-build-tools: %v\n", err)
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
if err := loadEnvFile(*secrets); err != nil {
|
user, pass, err := registryWriteCredential(*secrets)
|
||||||
fmt.Fprintf(stderr, "felis mirror-build-tools: read %s: %v\n", *secrets, err)
|
if err != nil {
|
||||||
return 1
|
fmt.Fprintf(stderr, "felis mirror-build-tools: %v\n", err)
|
||||||
}
|
|
||||||
user, pass := os.Getenv("FELIS_REGISTRY_USERNAME"), os.Getenv("FELIS_REGISTRY_PASSWORD")
|
|
||||||
if pass == "" {
|
|
||||||
user, pass = registrygate.PrincipalPlatform, os.Getenv("REGISTRY_PLATFORM_TOKEN")
|
|
||||||
}
|
|
||||||
if pass == "" {
|
|
||||||
fmt.Fprintln(stderr, "felis mirror-build-tools: no registry credential: set FELIS_REGISTRY_PASSWORD or run as root on the node (REGISTRY_PLATFORM_TOKEN in /etc/felis/secrets.env)")
|
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+43
-7
@@ -24,12 +24,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const offsiteUsage = `usage:
|
const offsiteUsage = `usage:
|
||||||
felis offsite sync [-config path] [-archive-dir dir] [-db-dir dir] [-status-file path]
|
felis offsite sync [-config path] [-archive-dir dir] [-db-dir dir] [-registry host:port|off]
|
||||||
|
[-status-file path]
|
||||||
felis offsite status [-config path] [-status-file path]
|
felis offsite status [-config path] [-status-file path]
|
||||||
felis offsite list [-config path]
|
felis offsite list [-config path]
|
||||||
felis offsite fetch-db [-config path | -endpoint url -bucket name [-region r] [-prefix p]]
|
felis offsite fetch-db [-config path | -endpoint url -bucket name [-region r] [-prefix p]]
|
||||||
[-dir dir] latest|<bundle>
|
[-dir dir] latest|<bundle>
|
||||||
felis offsite fetch-worlds [-config path] [-archive-dir dir]
|
felis offsite fetch-worlds [-config path] [-archive-dir dir]
|
||||||
|
felis offsite fetch-images [-config path] [-registry host:port] [-at version]
|
||||||
felis offsite keygen
|
felis offsite keygen
|
||||||
|
|
||||||
Every verb but keygen reads the bucket credentials and the encryption key from
|
Every verb but keygen reads the bucket credentials and the encryption key from
|
||||||
@@ -43,7 +45,8 @@ FELIS_OFFSITE_SECRET_KEY, FELIS_OFFSITE_KEY), taking any that are unset from
|
|||||||
const defaultOffsiteEnvFile = "/etc/felis/offsite.env"
|
const defaultOffsiteEnvFile = "/etc/felis/offsite.env"
|
||||||
|
|
||||||
// cmdOffsite implements `felis offsite`: the off-site copy of the world
|
// cmdOffsite implements `felis offsite`: the off-site copy of the world
|
||||||
// archives and the database bundles (internal/offsite). felis-offsite.timer
|
// archives, the database bundles and the registry's user images
|
||||||
|
// (internal/offsite). felis-offsite.timer
|
||||||
// runs `sync` hourly on the host; the fetch verbs are the way back after the
|
// runs `sync` hourly on the host; the fetch verbs are the way back after the
|
||||||
// node is lost (docs/troubleshooting.md §16).
|
// node is lost (docs/troubleshooting.md §16).
|
||||||
func cmdOffsite(args []string, stdout, stderr io.Writer) int {
|
func cmdOffsite(args []string, stdout, stderr io.Writer) int {
|
||||||
@@ -66,6 +69,8 @@ func cmdOffsite(args []string, stdout, stderr io.Writer) int {
|
|||||||
return offsiteFetchDB(fs, rest, stdout, stderr)
|
return offsiteFetchDB(fs, rest, stdout, stderr)
|
||||||
case "fetch-worlds":
|
case "fetch-worlds":
|
||||||
return offsiteFetchWorlds(fs, rest, stdout, stderr)
|
return offsiteFetchWorlds(fs, rest, stdout, stderr)
|
||||||
|
case "fetch-images":
|
||||||
|
return offsiteFetchImages(fs, rest, stdout, stderr)
|
||||||
case "keygen":
|
case "keygen":
|
||||||
k, err := offsite.NewKey()
|
k, err := offsite.NewKey()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -186,6 +191,7 @@ func offsiteSync(fs *flag.FlagSet, args []string, stdout, stderr io.Writer) int
|
|||||||
archiveDir := fs.String("archive-dir", "", "host directory of the world archive volume (default: resolved from the backup PVC through the cluster)")
|
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)`)
|
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)`)
|
dbDir := fs.String("db-dir", dbbackup.DefaultDir, `database bundle directory ("" copies no bundles)`)
|
||||||
|
registry := fs.String("registry", "", `host[:port] of the registry whose user images are copied (default: the in-cluster registry's loopback hostPort; "off" copies none)`)
|
||||||
statusFile := fs.String("status-file", offsite.DefaultStatusFile, "where the result of this run is recorded for the watchdog and `status`")
|
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 {
|
if err := fs.Parse(args); err != nil {
|
||||||
return 2
|
return 2
|
||||||
@@ -202,7 +208,7 @@ func offsiteSync(fs *flag.FlagSet, args []string, stdout, stderr io.Writer) int
|
|||||||
if prev, _ := offsite.ReadStatus(*statusFile); prev != nil {
|
if prev, _ := offsite.ReadStatus(*statusFile); prev != nil {
|
||||||
st.LastSuccess = prev.LastSuccess
|
st.LastSuccess = prev.LastSuccess
|
||||||
}
|
}
|
||||||
res, err := runOffsiteSync(cfg, env, *archiveDir, *backupPVC, *dbDir, stderr)
|
res, err := runOffsiteSync(cfg, env, *archiveDir, *backupPVC, *dbDir, offsiteRegistryEndpoint(*registry, cfg.Registry), stderr)
|
||||||
st.Result = res
|
st.Result = res
|
||||||
if err != nil {
|
if err != nil {
|
||||||
st.LastError = err.Error()
|
st.LastError = err.Error()
|
||||||
@@ -212,12 +218,16 @@ func offsiteSync(fs *flag.FlagSet, args []string, stdout, stderr io.Writer) int
|
|||||||
if werr := offsite.WriteStatus(*statusFile, st); werr != nil {
|
if werr := offsite.WriteStatus(*statusFile, st); werr != nil {
|
||||||
fmt.Fprintf(stderr, "felis offsite sync: record status: %v\n", werr)
|
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",
|
fmt.Fprintf(stdout, "felis offsite sync: worlds copied=%d pending=%d missing=%d expired=%d; bundles copied=%d pruned=%d; images copied=%d blobs=%d pruned=%d; bucket holds %d worlds (%s), %d bundles, %d images in %d repositories (%s)\n",
|
||||||
res.WorldsUploaded, res.WorldsPending, len(res.WorldsMissing), res.WorldsExpired,
|
res.WorldsUploaded, res.WorldsPending, len(res.WorldsMissing), res.WorldsExpired,
|
||||||
res.DBUploaded, res.DBPruned, res.RemoteWorlds, offsite.HumanBytes(res.RemoteBytes), res.RemoteDB)
|
res.DBUploaded, res.DBPruned, res.ImagesUploaded, res.ImageBlobsUploaded, res.ImageObjectsPruned,
|
||||||
|
res.RemoteWorlds, offsite.HumanBytes(res.RemoteBytes), res.RemoteDB, res.Images, res.ImageRepos, offsite.HumanBytes(res.RemoteImageBytes))
|
||||||
for _, m := range res.WorldsMissing {
|
for _, m := range res.WorldsMissing {
|
||||||
fmt.Fprintf(stderr, "felis offsite sync: recorded archive not on the volume, nothing to copy: %s\n", m)
|
fmt.Fprintf(stderr, "felis offsite sync: recorded archive not on the volume, nothing to copy: %s\n", m)
|
||||||
}
|
}
|
||||||
|
for _, m := range res.ImagesIncomplete {
|
||||||
|
fmt.Fprintf(stderr, "felis offsite sync: registry image not whole: %s\n", m)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, "felis offsite sync: %v\n", err)
|
fmt.Fprintf(stderr, "felis offsite sync: %v\n", err)
|
||||||
return 1
|
return 1
|
||||||
@@ -225,7 +235,7 @@ func offsiteSync(fs *flag.FlagSet, args []string, stdout, stderr io.Writer) int
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func runOffsiteSync(cfg *config.Config, env *offsiteEnv, archiveDir, backupPVC, dbDir string, log io.Writer) (offsite.Result, error) {
|
func runOffsiteSync(cfg *config.Config, env *offsiteEnv, archiveDir, backupPVC, dbDir, registry string, log io.Writer) (offsite.Result, error) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Minute)
|
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
checkCtx, checkCancel := context.WithTimeout(ctx, 30*time.Second)
|
checkCtx, checkCancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
@@ -250,6 +260,9 @@ func runOffsiteSync(cfg *config.Config, env *offsiteEnv, archiveDir, backupPVC,
|
|||||||
Bucket: env.bucket, Catalog: offsite.PGCatalog{DB: drv.DB()}, Key: env.key,
|
Bucket: env.bucket, Catalog: offsite.PGCatalog{DB: drv.DB()}, Key: env.key,
|
||||||
ArchiveDir: archiveDir, DBDir: dbDir, DBKeep: env.cfg.DBKeep, Log: log,
|
ArchiveDir: archiveDir, DBDir: dbDir, DBKeep: env.cfg.DBKeep, Log: log,
|
||||||
}
|
}
|
||||||
|
if registry != "" {
|
||||||
|
s.Images = newRegistryImages(registry)
|
||||||
|
}
|
||||||
return s.Run(ctx)
|
return s.Run(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,7 +360,7 @@ func offsiteStatus(fs *flag.FlagSet, args []string, stdout, stderr io.Writer) in
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
if !cfg.Offsite.Enabled() {
|
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, "off-site copy: not configured. World archives, database bundles and user images exist on this machine only.")
|
||||||
fmt.Fprintln(stdout, "See docs/troubleshooting.md §16, \"Keep a copy somewhere else\".")
|
fmt.Fprintln(stdout, "See docs/troubleshooting.md §16, \"Keep a copy somewhere else\".")
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
@@ -380,10 +393,19 @@ func offsiteStatus(fs *flag.FlagSet, args []string, stdout, stderr io.Writer) in
|
|||||||
r := st.Result
|
r := st.Result
|
||||||
fmt.Fprintf(stdout, "bucket holds: %d world archives (%s), %d database bundles, newest %s\n",
|
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))
|
r.RemoteWorlds, offsite.HumanBytes(r.RemoteBytes), r.RemoteDB, orNone(r.NewestDB))
|
||||||
|
if r.ImageIndex != "" {
|
||||||
|
fmt.Fprintf(stdout, "images: %d in %d repositories (%s), registry index %s\n",
|
||||||
|
r.Images, r.ImageRepos, offsite.HumanBytes(r.RemoteImageBytes), r.ImageIndex)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintln(stdout, "images: not copied (no in-cluster registry, or no sync has reached it yet)")
|
||||||
|
}
|
||||||
fmt.Fprintf(stdout, "waiting: %d world archives not yet copied\n", r.WorldsPending)
|
fmt.Fprintf(stdout, "waiting: %d world archives not yet copied\n", r.WorldsPending)
|
||||||
for _, m := range r.WorldsMissing {
|
for _, m := range r.WorldsMissing {
|
||||||
fmt.Fprintf(stdout, "missing: %s is recorded but not on the volume\n", m)
|
fmt.Fprintf(stdout, "missing: %s is recorded but not on the volume\n", m)
|
||||||
}
|
}
|
||||||
|
for _, m := range r.ImagesIncomplete {
|
||||||
|
fmt.Fprintf(stdout, "not whole: %s\n", m)
|
||||||
|
}
|
||||||
if st.LastSuccess.IsZero() || now.Sub(st.LastSuccess) > offsite.StaleAfter {
|
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))
|
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 1
|
||||||
@@ -434,6 +456,20 @@ func printOffsiteList(env *offsiteEnv, stdout, stderr io.Writer) int {
|
|||||||
total += w.Size
|
total += w.Size
|
||||||
}
|
}
|
||||||
fmt.Fprintf(stdout, "world archives: %d (%s)\n", len(worlds), offsite.HumanBytes(total))
|
fmt.Fprintf(stdout, "world archives: %d (%s)\n", len(worlds), offsite.HumanBytes(total))
|
||||||
|
versions, err := offsite.ImageIndexes(ctx, env.bucket)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(stderr, "felis offsite list: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
fmt.Fprintf(stdout, "registry index versions (%d, newest first; restore one with fetch-images -at):\n", len(versions))
|
||||||
|
for i := len(versions) - 1; i >= 0; i-- {
|
||||||
|
x, err := offsite.LoadImageIndex(ctx, env.bucket, env.key, versions[i])
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(stdout, " %s unreadable: %v\n", versions[i], err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Fprintf(stdout, " %s %d images in %d repositories\n", versions[i], x.Images(), len(x.Repositories))
|
||||||
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"felis.lolicon.best/internal/config"
|
||||||
|
"felis.lolicon.best/internal/imagepush"
|
||||||
|
"felis.lolicon.best/internal/offsite"
|
||||||
|
"felis.lolicon.best/internal/registrygate"
|
||||||
|
"felis.lolicon.best/internal/registryprune"
|
||||||
|
)
|
||||||
|
|
||||||
|
// registryImages is the platform registry as the off-site copy sees it: read
|
||||||
|
// anonymously through the gate (the catalog, its manifest index, manifests and
|
||||||
|
// blobs) and, for a restore, written as the platform principal. Both go through
|
||||||
|
// the node's loopback hostPort, the way the installer pushes.
|
||||||
|
type registryImages struct {
|
||||||
|
host string
|
||||||
|
index *registryprune.Client
|
||||||
|
source *imagepush.Source
|
||||||
|
pusher *imagepush.Pusher
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRegistryImages(endpoint string) *registryImages {
|
||||||
|
return ®istryImages{
|
||||||
|
host: endpoint,
|
||||||
|
index: ®istryprune.Client{Endpoint: "http://" + endpoint},
|
||||||
|
source: &imagepush.Source{Scheme: "http"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *registryImages) Repositories(ctx context.Context) ([]string, error) {
|
||||||
|
return r.index.Repositories(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *registryImages) Revisions(ctx context.Context, repo string) ([]string, map[string]string, error) {
|
||||||
|
idx, err := r.index.Index(ctx, repo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
digests := make([]string, 0, len(idx.Revisions))
|
||||||
|
for _, rev := range idx.Revisions {
|
||||||
|
digests = append(digests, rev.Digest)
|
||||||
|
}
|
||||||
|
return digests, idx.Tags, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *registryImages) Manifest(ctx context.Context, repo, digest string) ([]byte, string, error) {
|
||||||
|
body, mt, err := r.source.Manifest(ctx, r.host, repo, digest)
|
||||||
|
return body, mt, registryGone(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *registryImages) Blob(ctx context.Context, repo, digest string) (io.ReadCloser, error) {
|
||||||
|
rc, err := r.source.Blob(ctx, r.host, repo, digest)
|
||||||
|
return rc, registryGone(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *registryImages) PutBlob(ctx context.Context, repo, digest string, size int64, open func() (io.ReadCloser, error)) error {
|
||||||
|
return r.pusher.UploadBlob(ctx, r.host, repo, digest, size, open)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *registryImages) PutManifest(ctx context.Context, repo, reference, mediaType string, body []byte) error {
|
||||||
|
_, err := r.pusher.PutManifest(ctx, r.host, repo, reference, mediaType, body)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// registryGone marks a 404 as a manifest or blob the registry no longer holds.
|
||||||
|
func registryGone(err error) error {
|
||||||
|
var se *imagepush.StatusError
|
||||||
|
if errors.As(err, &se) && se.Code == http.StatusNotFound {
|
||||||
|
return fmt.Errorf("%w: %v", offsite.ErrImageGone, err)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// offsiteRegistryEndpoint is where the host reaches the registry whose images
|
||||||
|
// the off-site copy covers: flag when given ("off" for none), otherwise the
|
||||||
|
// loopback hostPort of the in-cluster registry [registry] url names. A
|
||||||
|
// registry outside the cluster is not this install's to copy.
|
||||||
|
func offsiteRegistryEndpoint(flag string, reg config.RegistryConfig) string {
|
||||||
|
switch flag {
|
||||||
|
case "off":
|
||||||
|
return ""
|
||||||
|
case "":
|
||||||
|
default:
|
||||||
|
return flag
|
||||||
|
}
|
||||||
|
host, _, _ := strings.Cut(reg.URL, "/")
|
||||||
|
name, _, _ := strings.Cut(host, ":")
|
||||||
|
if strings.HasSuffix(name, ".svc") || strings.HasSuffix(name, ".svc.cluster.local") {
|
||||||
|
return loopbackEndpoint(host)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// registryWriteCredential is the credential a host-side push uses:
|
||||||
|
// FELIS_REGISTRY_USERNAME/PASSWORD when set, otherwise the platform principal
|
||||||
|
// with REGISTRY_PLATFORM_TOKEN from the installer's secrets file.
|
||||||
|
func registryWriteCredential(secrets string) (string, string, error) {
|
||||||
|
if err := loadEnvFile(secrets); err != nil {
|
||||||
|
return "", "", fmt.Errorf("read %s: %w", secrets, err)
|
||||||
|
}
|
||||||
|
user, pass := os.Getenv("FELIS_REGISTRY_USERNAME"), os.Getenv("FELIS_REGISTRY_PASSWORD")
|
||||||
|
if pass == "" {
|
||||||
|
user, pass = registrygate.PrincipalPlatform, os.Getenv("REGISTRY_PLATFORM_TOKEN")
|
||||||
|
}
|
||||||
|
if pass == "" {
|
||||||
|
return "", "", errors.New("no registry credential: set FELIS_REGISTRY_PASSWORD or run as root on the node (REGISTRY_PLATFORM_TOKEN in /etc/felis/secrets.env)")
|
||||||
|
}
|
||||||
|
return user, pass, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func offsiteFetchImages(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)")
|
||||||
|
envFile := fs.String("env-file", defaultOffsiteEnvFile, "file with the [offsite] secrets, for variables not already set")
|
||||||
|
registry := fs.String("registry", "", "host[:port] of the registry to push into (default: the loopback hostPort of the in-cluster registry)")
|
||||||
|
secrets := fs.String("secrets-env", "/etc/felis/secrets.env", "installer secrets file holding REGISTRY_PLATFORM_TOKEN, read when FELIS_REGISTRY_PASSWORD is unset")
|
||||||
|
at := fs.String("at", "", "registry index version to restore (default: the newest; `felis offsite list` shows them)")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
cfg, env, err := loadOffsite(*cfgPath, *envFile)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(stderr, "felis offsite fetch-images: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
endpoint := offsiteRegistryEndpoint(*registry, cfg.Registry)
|
||||||
|
if endpoint == "" {
|
||||||
|
fmt.Fprintf(stderr, "felis offsite fetch-images: [registry] url %q is not the in-cluster registry; pass -registry host:port\n", cfg.Registry.URL)
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
user, pass, err := registryWriteCredential(*secrets)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(stderr, "felis offsite fetch-images: %v\n", err)
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Hour)
|
||||||
|
defer cancel()
|
||||||
|
stamp, idx, err := offsite.ChooseImageIndex(ctx, env.bucket, env.key, *at)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(stderr, "felis offsite fetch-images: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
fmt.Fprintf(stdout, "felis offsite fetch-images: restoring registry index %s (%d repositories, %d images) into %s\n",
|
||||||
|
stamp, len(idx.Repositories), idx.Images(), endpoint)
|
||||||
|
target := newRegistryImages(endpoint)
|
||||||
|
target.pusher = &imagepush.Pusher{Scheme: "http", Username: user, Password: pass}
|
||||||
|
res, err := offsite.FetchImages(ctx, env.bucket, env.key, idx, target, stderr)
|
||||||
|
fmt.Fprintf(stdout, "felis offsite fetch-images: %d of %d repositories restored, %d images, %d tags, %d blobs pushed (%s)\n",
|
||||||
|
res.Repositories, len(idx.Repositories), res.Manifests, res.Tags, res.BlobsPushed, offsite.HumanBytes(res.BytesPushed))
|
||||||
|
for _, f := range res.Failures {
|
||||||
|
fmt.Fprintf(stderr, "felis offsite fetch-images: %s\n", f)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(stderr, "felis offsite fetch-images: %v (a second run pushes only what is still missing)\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -2,12 +2,14 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"felis.lolicon.best/internal/config"
|
"felis.lolicon.best/internal/config"
|
||||||
|
"felis.lolicon.best/internal/imagepush"
|
||||||
"felis.lolicon.best/internal/offsite"
|
"felis.lolicon.best/internal/offsite"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -94,3 +96,27 @@ func TestOffsiteFetchDBRejectsOddNames(t *testing.T) {
|
|||||||
t.Fatalf("exit %d: %s", code, errb.String())
|
t.Fatalf("exit %d: %s", code, errb.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOffsiteRegistryEndpoint(t *testing.T) {
|
||||||
|
for _, tc := range []struct{ flag, url, want string }{
|
||||||
|
{"", "registry.felis.svc:5000", "127.0.0.1:5000"},
|
||||||
|
{"", "registry.felis.svc.cluster.local:5001", "127.0.0.1:5001"},
|
||||||
|
{"", "ghcr.io/acme", ""},
|
||||||
|
{"", "", ""},
|
||||||
|
{"off", "registry.felis.svc:5000", ""},
|
||||||
|
{"10.0.0.5:5000", "ghcr.io/acme", "10.0.0.5:5000"},
|
||||||
|
} {
|
||||||
|
if got := offsiteRegistryEndpoint(tc.flag, config.RegistryConfig{URL: tc.url}); got != tc.want {
|
||||||
|
t.Errorf("offsiteRegistryEndpoint(%q, %q) = %q, want %q", tc.flag, tc.url, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistryGoneMarksNotFound(t *testing.T) {
|
||||||
|
if err := registryGone(&imagepush.StatusError{Op: "get blob", Code: 404}); !errors.Is(err, offsite.ErrImageGone) {
|
||||||
|
t.Fatalf("404 = %v, want ErrImageGone", err)
|
||||||
|
}
|
||||||
|
if err := registryGone(&imagepush.StatusError{Op: "get blob", Code: 503}); errors.Is(err, offsite.ErrImageGone) {
|
||||||
|
t.Fatalf("503 = %v, want it kept an ordinary failure", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -13,7 +13,7 @@ Usage:
|
|||||||
Commands:
|
Commands:
|
||||||
migrate up Apply embedded database migrations under an advisory lock (snapshots the database first)
|
migrate up Apply embedded database migrations under an advisory lock (snapshots the database first)
|
||||||
db Back up, verify, list and restore the control-plane database (backup|restore|verify|list|check)
|
db Back up, verify, list and restore the control-plane database (backup|restore|verify|list|check)
|
||||||
offsite Copy world archives and database bundles to an off-site bucket, and fetch them back (sync|status|list|fetch-db|fetch-worlds|keygen)
|
offsite Copy world archives, database bundles and user images to an off-site bucket, and fetch them back (sync|status|list|fetch-db|fetch-worlds|fetch-images|keygen)
|
||||||
operator Run the MinecraftServer controller-manager
|
operator Run the MinecraftServer controller-manager
|
||||||
api Run the felis-api HTTP server
|
api Run the felis-api HTTP server
|
||||||
nano Run the Felis-nano hasJoined multiplexer (multi-Yggdrasil, no control plane)
|
nano Run the Felis-nano hasJoined multiplexer (multi-Yggdrasil, no control plane)
|
||||||
|
|||||||
+1
-1
@@ -3166,7 +3166,7 @@ install_offsite_timer() {
|
|||||||
fi
|
fi
|
||||||
cat > "$OFFSITE_SERVICE" <<EOF
|
cat > "$OFFSITE_SERVICE" <<EOF
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Felis off-site copy (world archives and database bundles, encrypted, to the [offsite] bucket)
|
Description=Felis off-site copy (world archives, database bundles and user registry images, encrypted, to the [offsite] bucket)
|
||||||
After=network-online.target k3s.service postgresql.service felis-db-backup.service
|
After=network-online.target k3s.service postgresql.service felis-db-backup.service
|
||||||
Wants=network-online.target
|
Wants=network-online.target
|
||||||
|
|
||||||
|
|||||||
+44
-13
@@ -696,7 +696,11 @@ control namespace (or `--registry-namespace`):
|
|||||||
`kubectl -n felis exec deploy/registry -c registry-gc -- rm -f /var/lib/registry/.felis-last-gc`
|
`kubectl -n felis exec deploy/registry -c registry-gc -- rm -f /var/lib/registry/.felis-last-gc`
|
||||||
and restart the pod.
|
and restart the pod.
|
||||||
- **The registry volume is lost:** re-run the installer; it pushes every
|
- **The registry volume is lost:** re-run the installer; it pushes every
|
||||||
platform image again. User images come back from their approved submissions:
|
platform image again. With the off-site copy on (§16),
|
||||||
|
`sudo felis offsite fetch-images` pushes the user images back at their old
|
||||||
|
digests, so servers pinned to them pull again; it pushes only what the
|
||||||
|
registry lacks, so a second run after an interruption is cheap. Without an
|
||||||
|
off-site copy, user images come back from their approved submissions:
|
||||||
the uploaded context of an approved submission stays on the uploads PVC
|
the uploaded context of an approved submission stays on the uploads PVC
|
||||||
(`GET /api/v1/submissions/{id}/context`, its `context_ref` and `image_ref`
|
(`GET /api/v1/submissions/{id}/context`, its `context_ref` and `image_ref`
|
||||||
are in `GET /api/v1/submissions`), so an admin can build it again through
|
are in `GET /api/v1/submissions`), so an admin can build it again through
|
||||||
@@ -1705,7 +1709,20 @@ host yourself, plus the off-site encryption key if the copy is in the bucket.
|
|||||||
bundle, so it takes the fresh-install path, creates the empty database with
|
bundle, so it takes the fresh-install path, creates the empty database with
|
||||||
the restored password and migrates it. It finds `[offsite]` in the restored
|
the restored password and migrates it. It finds `[offsite]` in the restored
|
||||||
`felis.host.toml` and turns the hourly copy back on.
|
`felis.host.toml` and turns the hourly copy back on.
|
||||||
4. Restore the database and bring the servers back:
|
4. Push the user images back into the new registry:
|
||||||
|
|
||||||
|
```
|
||||||
|
sudo felis offsite fetch-images
|
||||||
|
```
|
||||||
|
|
||||||
|
It restores the newest image list in the bucket (`-at <stamp>` for an
|
||||||
|
older one; `felis offsite list` shows them) through the registry's loopback
|
||||||
|
port as the platform principal, verifying every manifest and layer against
|
||||||
|
its digest, and pushes only what the registry lacks. The pruner counts a
|
||||||
|
restored image as freshly pushed and keeps it for 24 hours; finish the next
|
||||||
|
step within that window so the restored servers and whitelist entries keep
|
||||||
|
naming it.
|
||||||
|
5. Restore the database and bring the servers back:
|
||||||
|
|
||||||
```
|
```
|
||||||
kubectl -n felis scale deployment felis-api felis-operator --replicas=0
|
kubectl -n felis scale deployment felis-api felis-operator --replicas=0
|
||||||
@@ -1715,7 +1732,7 @@ host yourself, plus the off-site encryption key if the copy is in the bucket.
|
|||||||
tar -xOf felis-db-....tar k8s/minecraftservers.json | kubectl apply -f -
|
tar -xOf felis-db-....tar k8s/minecraftservers.json | kubectl apply -f -
|
||||||
```
|
```
|
||||||
|
|
||||||
5. Bring the world archives back into the archive volume:
|
6. Bring the world archives back into the archive volume:
|
||||||
|
|
||||||
```
|
```
|
||||||
sudo felis offsite fetch-worlds
|
sudo felis offsite fetch-worlds
|
||||||
@@ -1725,16 +1742,16 @@ host yourself, plus the off-site encryption key if the copy is in the bucket.
|
|||||||
and the volume lacks, provisioning the `felis-backups` volume first if
|
and the volume lacks, provisioning the `felis-backups` volume first if
|
||||||
nothing has used it yet (a short-lived `felis-bind-felis-backups-*` pod). It
|
nothing has used it yet (a short-lived `felis-bind-felis-backups-*` pod). It
|
||||||
lists any it could not find in the bucket. Restore a world from its archive
|
lists any it could not find in the bucket. Restore a world from its archive
|
||||||
as usual (§10, §13). Custom images built on the old host are rebuilt from
|
as usual (§10, §13).
|
||||||
their submissions (§8), or re-pushed.
|
|
||||||
|
|
||||||
### Keep a copy somewhere else
|
### Keep a copy somewhere else
|
||||||
|
|
||||||
A bundle on the same disk as the database protects against mistakes and bad
|
A bundle on the same disk as the database protects against mistakes and bad
|
||||||
upgrades, and a world archive on the same disk as the worlds protects against
|
upgrades, and a world archive on the same disk as the worlds protects against
|
||||||
a deleted server. Neither survives losing the disk. The installer's off-site
|
a deleted server. Neither survives losing the disk, and neither do the user
|
||||||
copy sends both to an S3-compatible bucket (AWS S3, Cloudflare R2, Backblaze
|
images in the platform registry. The installer's off-site copy sends all three
|
||||||
B2, MinIO, ...), encrypted on this host:
|
to an S3-compatible bucket (AWS S3, Cloudflare R2, Backblaze B2, MinIO, ...),
|
||||||
|
encrypted on this host:
|
||||||
|
|
||||||
```
|
```
|
||||||
FELIS_OFFSITE_ENDPOINT=https://<account>.r2.cloudflarestorage.com \
|
FELIS_OFFSITE_ENDPOINT=https://<account>.r2.cloudflarestorage.com \
|
||||||
@@ -1763,9 +1780,22 @@ What runs:
|
|||||||
its retention (`expires_at`) has passed. An object already in the bucket at
|
its retention (`expires_at`) has passed. An object already in the bucket at
|
||||||
the right size is recorded without being sent again, so a run cut short
|
the right size is recorded without being sent again, so a run cut short
|
||||||
resumes. [GO-TESTED: `internal/offsite`]
|
resumes. [GO-TESTED: `internal/offsite`]
|
||||||
- Objects are `worlds/<archive>.fenc` and `db/<bundle>.fenc`: AES-256-GCM in
|
- The same run copies the user images in the platform registry: every
|
||||||
64 KiB segments, so truncation, reordering and a wrong key are all refused
|
repository outside `felis/` and `mirror/` (the installer pushes those again),
|
||||||
on the way back.
|
each manifest the registry's index lists and every layer it names, read
|
||||||
|
through the loopback hostPort. A layer shared by many images is stored once.
|
||||||
|
When the set changed, a new version of the image list is written; versions
|
||||||
|
replaced more than 14 days ago are dropped together with the layers only
|
||||||
|
they named, so an image deleted by mistake stays restorable for two weeks
|
||||||
|
(`fetch-images -at`). A manifest the registry lost mid-run keeps its earlier
|
||||||
|
copy and is listed under `not whole:` in `status`. The copy covers the
|
||||||
|
in-cluster registry that `[registry] url` names; `-registry host:port` points
|
||||||
|
it elsewhere, `-registry off` skips images. [VM-TESTED: 16 images, 638 MiB,
|
||||||
|
restored into an empty registry at the same digests]
|
||||||
|
- Objects are `worlds/<archive>.fenc`, `db/<bundle>.fenc`,
|
||||||
|
`registry/blobs/<sha256>.fenc`, `registry/manifests/<sha256>.fenc` and
|
||||||
|
`registry/index/<stamp>.json.fenc`: AES-256-GCM in 64 KiB segments, so
|
||||||
|
truncation, reordering and a wrong key are all refused on the way back.
|
||||||
- The reaper deletes an idle world only after its archive is in the bucket
|
- The reaper deletes an idle world only after its archive is in the bucket
|
||||||
(§10).
|
(§10).
|
||||||
- The watchdog mails the owners when no sync has completed for 12 hours
|
- The watchdog mails the owners when no sync has completed for 12 hours
|
||||||
@@ -1775,7 +1805,7 @@ Checking it:
|
|||||||
|
|
||||||
```
|
```
|
||||||
sudo felis offsite status # last run, errors, what the bucket holds, what waits
|
sudo felis offsite status # last run, errors, what the bucket holds, what waits
|
||||||
sudo felis offsite list # the bundles in the bucket, newest first
|
sudo felis offsite list # the bundles and image lists in the bucket, newest first
|
||||||
sudo journalctl -u felis-offsite -n 50 --no-pager
|
sudo journalctl -u felis-offsite -n 50 --no-pager
|
||||||
sudo systemctl start felis-offsite.service # run one now
|
sudo systemctl start felis-offsite.service # run one now
|
||||||
```
|
```
|
||||||
@@ -1793,7 +1823,8 @@ Without a bucket, copy the backup directory off the host on a schedule of your
|
|||||||
own (`rsync -a root@felis-host:/var/lib/felis/db-backups/ /backups/felis-db/`,
|
own (`rsync -a root@felis-host:/var/lib/felis/db-backups/ /backups/felis-db/`,
|
||||||
with the `.sha256` sidecars; `sha256sum -c` on the far side proves the copy).
|
with the `.sha256` sidecars; `sha256sum -c` on the far side proves the copy).
|
||||||
That covers the database only; the world archives are under the
|
That covers the database only; the world archives are under the
|
||||||
`felis-backups` volume's directory in `/var/lib/rancher/k3s/storage/`.
|
`felis-backups` volume's directory in `/var/lib/rancher/k3s/storage/`, and the
|
||||||
|
registry's images under the `registry` volume's (`*_felis_registry`).
|
||||||
|
|
||||||
### `FELIS_PRE_MIGRATE_BACKUP=0`
|
### `FELIS_PRE_MIGRATE_BACKUP=0`
|
||||||
|
|
||||||
|
|||||||
@@ -452,3 +452,14 @@ func WriteMirrorStatus(path string, st MirrorStatus) error {
|
|||||||
}
|
}
|
||||||
return os.Rename(tmp, path)
|
return os.Rename(tmp, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Manifest fetches one manifest of host/repo by tag or digest and returns its
|
||||||
|
// bytes and media type; a digest reference is checked against the bytes.
|
||||||
|
func (s *Source) Manifest(ctx context.Context, host, repo, reference string) ([]byte, string, error) {
|
||||||
|
return s.manifest(ctx, SourceRef{Host: host, Repo: repo}, reference)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blob opens one blob of host/repo. The caller checks the bytes against digest.
|
||||||
|
func (s *Source) Blob(ctx context.Context, host, repo, digest string) (io.ReadCloser, error) {
|
||||||
|
return s.blob(ctx, SourceRef{Host: host, Repo: repo}, digest)
|
||||||
|
}
|
||||||
@@ -493,3 +493,23 @@ func (w *prefixWriter) Write(p []byte) (int, error) {
|
|||||||
}
|
}
|
||||||
return len(p), nil
|
return len(p), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UploadBlob uploads the blob open returns into host/repo unless the repository
|
||||||
|
// already holds digest, retrying like Push. open is called once per attempt.
|
||||||
|
func (p *Pusher) UploadBlob(ctx context.Context, host, repo, digest string, size int64, open func() (io.ReadCloser, error)) error {
|
||||||
|
r := Ref{Host: host, Repo: repo}
|
||||||
|
return p.retry(ctx, func() error { return p.uploadBlob(ctx, r, digest, size, open) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// PutManifest stores body in host/repo under reference, a tag or the digest body
|
||||||
|
// hashes to, retrying like Push, and returns that digest.
|
||||||
|
func (p *Pusher) PutManifest(ctx context.Context, host, repo, reference, mediaType string, body []byte) (string, error) {
|
||||||
|
r := Ref{Host: host, Repo: repo, Tag: reference}
|
||||||
|
var digest string
|
||||||
|
err := p.retry(ctx, func() error {
|
||||||
|
d, err := p.putManifest(ctx, r, mediaType, body)
|
||||||
|
digest = d
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
return digest, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,815 @@
|
|||||||
|
package offsite
|
||||||
|
|
||||||
|
// The off-site copy of the platform registry's user images. The installer
|
||||||
|
// pushes everything under felis/ and mirror/ again on any host, so those are
|
||||||
|
// left out; every other repository holds builds that exist nowhere else: a
|
||||||
|
// lost registry volume would otherwise leave each server pinned to one of them
|
||||||
|
// in ImagePullBackOff until someone rebuilds it.
|
||||||
|
//
|
||||||
|
// The bucket holds each blob and manifest once, by digest, encrypted like
|
||||||
|
// everything else, plus an index that says which repository holds which
|
||||||
|
// manifests under which tags. A new index version is written whenever that
|
||||||
|
// changes, and every version stays until ImageHistory after a newer one
|
||||||
|
// replaced it, together with every object it names. So a registry that comes
|
||||||
|
// back empty, a host being rebuilt whose timer runs before anyone restores
|
||||||
|
// anything, only adds a new version: the one before it is still there to
|
||||||
|
// restore from (FetchImages, `felis offsite fetch-images -at`).
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"hash"
|
||||||
|
"io"
|
||||||
|
"maps"
|
||||||
|
"regexp"
|
||||||
|
"slices"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"felis.lolicon.best/internal/registrygate"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
registryDir = "registry/"
|
||||||
|
imageBlobsDir = registryDir + "blobs/"
|
||||||
|
imageManifestsDir = registryDir + "manifests/"
|
||||||
|
imageIndexDir = registryDir + "index/"
|
||||||
|
imageIndexExt = ".json" + objExt
|
||||||
|
imageStampLayout = "20060102T150405Z"
|
||||||
|
|
||||||
|
maxImageIndexBytes = 64 << 20
|
||||||
|
maxImageManifestBytes = 4 << 20
|
||||||
|
)
|
||||||
|
|
||||||
|
// ImageHistory is how long a registry state stays restorable after a newer one
|
||||||
|
// replaced it.
|
||||||
|
const ImageHistory = 14 * 24 * time.Hour
|
||||||
|
|
||||||
|
// ErrImageGone is a manifest or blob the registry lists but no longer holds.
|
||||||
|
var ErrImageGone = errors.New("offsite: not in the registry")
|
||||||
|
|
||||||
|
// ImageSource is the registry the sync reads, anonymously.
|
||||||
|
type ImageSource interface {
|
||||||
|
Repositories(ctx context.Context) ([]string, error)
|
||||||
|
// Revisions lists every manifest repo holds, tagged or not, and its tags.
|
||||||
|
Revisions(ctx context.Context, repo string) (digests []string, tags map[string]string, err error)
|
||||||
|
// Manifest returns the manifest stored under digest and its media type.
|
||||||
|
// A manifest the registry no longer holds is ErrImageGone.
|
||||||
|
Manifest(ctx context.Context, repo, digest string) ([]byte, string, error)
|
||||||
|
// Blob opens a blob; one the registry no longer holds is ErrImageGone.
|
||||||
|
Blob(ctx context.Context, repo, digest string) (io.ReadCloser, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImageTarget is the registry FetchImages pushes into.
|
||||||
|
type ImageTarget interface {
|
||||||
|
// PutBlob uploads the blob open returns unless repo already holds it.
|
||||||
|
PutBlob(ctx context.Context, repo, digest string, size int64, open func() (io.ReadCloser, error)) error
|
||||||
|
// PutManifest stores body under reference, a tag or its digest.
|
||||||
|
PutManifest(ctx context.Context, repo, reference, mediaType string, body []byte) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImageIndex is one state of the registry's user images.
|
||||||
|
type ImageIndex struct {
|
||||||
|
Created time.Time `json:"created"`
|
||||||
|
Repositories map[string]ImageRepo `json:"repositories"`
|
||||||
|
Manifests map[string]ImageManifest `json:"manifests"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImageRepo is one repository: its manifests, and the tag → digest map.
|
||||||
|
type ImageRepo struct {
|
||||||
|
Manifests []string `json:"manifests"`
|
||||||
|
Tags map[string]string `json:"tags,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImageManifest describes a stored manifest: the blobs an image manifest names
|
||||||
|
// (config and layers), or the manifests an index names.
|
||||||
|
type ImageManifest struct {
|
||||||
|
MediaType string `json:"media_type"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
Blobs []ImageBlob `json:"blobs,omitempty"`
|
||||||
|
Children []string `json:"children,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImageBlob is one blob a manifest names.
|
||||||
|
type ImageBlob struct {
|
||||||
|
Digest string `json:"digest"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Images counts the manifests the repositories hold, a manifest in two
|
||||||
|
// repositories counting twice, like two images.
|
||||||
|
func (x *ImageIndex) Images() int {
|
||||||
|
n := 0
|
||||||
|
for _, r := range x.Repositories {
|
||||||
|
n += len(r.Manifests)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func newImageIndex(at time.Time) *ImageIndex {
|
||||||
|
return &ImageIndex{Created: at, Repositories: map[string]ImageRepo{}, Manifests: map[string]ImageManifest{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
var imageDigestRE = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
|
||||||
|
|
||||||
|
func imageBlobKey(digest string) string {
|
||||||
|
return imageBlobsDir + strings.TrimPrefix(digest, "sha256:") + objExt
|
||||||
|
}
|
||||||
|
|
||||||
|
func imageManifestKey(digest string) string {
|
||||||
|
return imageManifestsDir + strings.TrimPrefix(digest, "sha256:") + objExt
|
||||||
|
}
|
||||||
|
|
||||||
|
func imageIndexKey(stamp string) string { return imageIndexDir + stamp + imageIndexExt }
|
||||||
|
|
||||||
|
func reservedRepo(repo string) bool {
|
||||||
|
root, _, _ := strings.Cut(repo, "/")
|
||||||
|
return slices.Contains(registrygate.ReservedRepoRoots, root)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manifest media types the copy understands.
|
||||||
|
const (
|
||||||
|
mediaOCIIndex = "application/vnd.oci.image.index.v1+json"
|
||||||
|
mediaDockerList = "application/vnd.docker.distribution.manifest.list.v2+json"
|
||||||
|
mediaOCIManifest = "application/vnd.oci.image.manifest.v1+json"
|
||||||
|
mediaDockerManifest2 = "application/vnd.docker.distribution.manifest.v2+json"
|
||||||
|
)
|
||||||
|
|
||||||
|
// describeManifest reads the blobs or child manifests out of a manifest.
|
||||||
|
func describeManifest(body []byte, mediaType string) (ImageManifest, error) {
|
||||||
|
type desc struct {
|
||||||
|
Digest string `json:"digest"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
}
|
||||||
|
var m struct {
|
||||||
|
MediaType string `json:"mediaType"`
|
||||||
|
Config *desc `json:"config"`
|
||||||
|
Layers []desc `json:"layers"`
|
||||||
|
Manifests []desc `json:"manifests"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &m); err != nil {
|
||||||
|
return ImageManifest{}, fmt.Errorf("manifest: %w", err)
|
||||||
|
}
|
||||||
|
if mediaType == "" {
|
||||||
|
mediaType = m.MediaType
|
||||||
|
}
|
||||||
|
im := ImageManifest{MediaType: mediaType, Size: int64(len(body))}
|
||||||
|
check := func(d desc) error {
|
||||||
|
if !imageDigestRE.MatchString(d.Digest) || d.Size < 0 {
|
||||||
|
return fmt.Errorf("manifest names %q (size %d), not a sha256 digest", d.Digest, d.Size)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch mediaType {
|
||||||
|
case mediaOCIIndex, mediaDockerList:
|
||||||
|
for _, d := range m.Manifests {
|
||||||
|
if err := check(d); err != nil {
|
||||||
|
return ImageManifest{}, err
|
||||||
|
}
|
||||||
|
if !slices.Contains(im.Children, d.Digest) {
|
||||||
|
im.Children = append(im.Children, d.Digest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case mediaOCIManifest, mediaDockerManifest2:
|
||||||
|
if m.Config == nil {
|
||||||
|
return ImageManifest{}, errors.New("image manifest without a config")
|
||||||
|
}
|
||||||
|
for _, d := range append([]desc{*m.Config}, m.Layers...) {
|
||||||
|
if err := check(d); err != nil {
|
||||||
|
return ImageManifest{}, err
|
||||||
|
}
|
||||||
|
if !slices.ContainsFunc(im.Blobs, func(b ImageBlob) bool { return b.Digest == d.Digest }) {
|
||||||
|
im.Blobs = append(im.Blobs, ImageBlob{Digest: d.Digest, Size: d.Size})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return ImageManifest{}, fmt.Errorf("unsupported manifest type %q", mediaType)
|
||||||
|
}
|
||||||
|
return im, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// imageCopy is one sync pass over the registry.
|
||||||
|
type imageCopy struct {
|
||||||
|
s *Syncer
|
||||||
|
remote map[string]int64
|
||||||
|
prev *ImageIndex
|
||||||
|
next *ImageIndex
|
||||||
|
res *Result
|
||||||
|
fail func(string, ...any)
|
||||||
|
// failed is set by anything that leaves the new index short of the
|
||||||
|
// registry, which rules out pruning in this pass.
|
||||||
|
failed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// errSkipped marks a manifest left out without failing the pass.
|
||||||
|
var errSkipped = errors.New("skipped")
|
||||||
|
|
||||||
|
func (s *Syncer) syncImages(ctx context.Context, res *Result, fail func(string, ...any)) {
|
||||||
|
if s.Images == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
remote, err := s.listSizes(ctx, registryDir)
|
||||||
|
if err != nil {
|
||||||
|
fail("list %s in the bucket: %v", registryDir, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
versions := imageIndexStamps(remote)
|
||||||
|
prev := newImageIndex(time.Time{})
|
||||||
|
if n := len(versions); n > 0 {
|
||||||
|
if prev, err = LoadImageIndex(ctx, s.Bucket, s.Key, versions[n-1]); err != nil {
|
||||||
|
fail("read the registry index %s from the bucket: %v", versions[n-1], err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c := &imageCopy{s: s, remote: remote, prev: prev, next: newImageIndex(s.now().UTC()), res: res, fail: fail}
|
||||||
|
repos, err := s.Images.Repositories(ctx)
|
||||||
|
if err != nil {
|
||||||
|
fail("list the registry's repositories: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sort.Strings(repos)
|
||||||
|
listed := map[string]bool{}
|
||||||
|
for _, repo := range repos {
|
||||||
|
if reservedRepo(repo) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
listed[repo] = true
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
c.fail("stopped before %s: %v", repo, ctx.Err())
|
||||||
|
c.failed = true
|
||||||
|
c.carry(repo)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c.repo(ctx, repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
stamp := ""
|
||||||
|
if len(versions) > 0 {
|
||||||
|
stamp = versions[len(versions)-1]
|
||||||
|
}
|
||||||
|
if len(versions) == 0 || !sameImages(prev, c.next) {
|
||||||
|
stamp = c.next.Created.Format(imageStampLayout)
|
||||||
|
if err := s.putImageIndex(ctx, stamp, c.next); err != nil {
|
||||||
|
fail("write the registry index: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(versions) == 0 || versions[len(versions)-1] != stamp {
|
||||||
|
versions = append(versions, stamp)
|
||||||
|
}
|
||||||
|
s.logf("recorded registry index %s: %d repositories, %d images", stamp, len(c.next.Repositories), c.next.Images())
|
||||||
|
}
|
||||||
|
res.ImageIndex = stamp
|
||||||
|
res.ImageRepos = len(c.next.Repositories)
|
||||||
|
res.Images = c.next.Images()
|
||||||
|
if !c.failed {
|
||||||
|
s.pruneImages(ctx, versions, c.next, remote, res, fail)
|
||||||
|
}
|
||||||
|
for key, size := range remote {
|
||||||
|
if strings.HasPrefix(key, imageBlobsDir) || strings.HasPrefix(key, imageManifestsDir) {
|
||||||
|
res.RemoteImageBytes += size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// repo copies one repository into c.next. A repository that cannot be read in
|
||||||
|
// full keeps the entry the previous index had for it.
|
||||||
|
func (c *imageCopy) repo(ctx context.Context, repo string) {
|
||||||
|
digests, tags, err := c.s.Images.Revisions(ctx, repo)
|
||||||
|
if err != nil {
|
||||||
|
c.fail("list the images of %s: %v", repo, err)
|
||||||
|
c.failed = true
|
||||||
|
c.carry(repo)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entry := ImageRepo{Tags: map[string]string{}}
|
||||||
|
short := false
|
||||||
|
for _, d := range digests {
|
||||||
|
switch err := c.manifest(ctx, repo, d); {
|
||||||
|
case errors.Is(err, errSkipped):
|
||||||
|
case err != nil:
|
||||||
|
c.fail("copy %s@%s: %v", repo, d, err)
|
||||||
|
short = true
|
||||||
|
default:
|
||||||
|
entry.Manifests = append(entry.Manifests, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if short {
|
||||||
|
c.failed = true
|
||||||
|
if _, ok := c.prev.Repositories[repo]; ok {
|
||||||
|
c.carry(repo)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(entry.Manifests)
|
||||||
|
for tag, d := range tags {
|
||||||
|
if slices.Contains(entry.Manifests, d) {
|
||||||
|
entry.Tags[tag] = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(entry.Manifests) > 0 {
|
||||||
|
c.next.Repositories[repo] = entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// carry keeps the previous index's entry for repo, and what it names.
|
||||||
|
func (c *imageCopy) carry(repo string) {
|
||||||
|
entry, ok := c.prev.Repositories[repo]
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.next.Repositories[repo] = entry
|
||||||
|
var add func(d string)
|
||||||
|
add = func(d string) {
|
||||||
|
m, ok := c.prev.Manifests[d]
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.next.Manifests[d] = m
|
||||||
|
for _, child := range m.Children {
|
||||||
|
add(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, d := range entry.Manifests {
|
||||||
|
add(d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// manifest makes sure digest, everything it names, and then the manifest
|
||||||
|
// itself are in the bucket, and records it in c.next. A manifest or blob the
|
||||||
|
// registry lost is errSkipped, unless an earlier pass already copied the
|
||||||
|
// manifest whole: that copy is then the only complete one and stays.
|
||||||
|
func (c *imageCopy) manifest(ctx context.Context, repo, digest string) error {
|
||||||
|
if _, ok := c.next.Manifests[digest]; ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !imageDigestRE.MatchString(digest) {
|
||||||
|
return fmt.Errorf("%q is not a sha256 digest", digest)
|
||||||
|
}
|
||||||
|
key := imageManifestKey(digest)
|
||||||
|
im, stored := c.prev.Manifests[digest]
|
||||||
|
stored = stored && c.remote[key] == SealedSize(im.Size)
|
||||||
|
var body []byte
|
||||||
|
if !stored {
|
||||||
|
b, mt, err := c.s.Images.Manifest(ctx, repo, digest)
|
||||||
|
if err != nil {
|
||||||
|
return c.gone(repo, digest, err)
|
||||||
|
}
|
||||||
|
if im, err = describeManifest(b, mt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body = b
|
||||||
|
}
|
||||||
|
for _, child := range im.Children {
|
||||||
|
if err := c.manifest(ctx, repo, child); err != nil {
|
||||||
|
if errors.Is(err, errSkipped) {
|
||||||
|
return c.gone(repo, digest, fmt.Errorf("%w: child manifest %s", ErrImageGone, child))
|
||||||
|
}
|
||||||
|
return fmt.Errorf("child manifest %s: %w", child, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, b := range im.Blobs {
|
||||||
|
bkey := imageBlobKey(b.Digest)
|
||||||
|
if c.remote[bkey] == SealedSize(b.Size) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := c.s.putImageBlob(ctx, repo, b); err != nil {
|
||||||
|
return c.gone(repo, digest, fmt.Errorf("blob %s: %w", b.Digest, err))
|
||||||
|
}
|
||||||
|
c.remote[bkey] = SealedSize(b.Size)
|
||||||
|
c.res.ImageBlobsUploaded++
|
||||||
|
c.res.BytesUploaded += b.Size
|
||||||
|
}
|
||||||
|
if body != nil {
|
||||||
|
if err := c.s.putBytes(ctx, key, body); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.remote[key] = SealedSize(int64(len(body)))
|
||||||
|
c.res.ImagesUploaded++
|
||||||
|
c.s.logf("copied image %s@%s", repo, digest)
|
||||||
|
}
|
||||||
|
c.next.Manifests[digest] = im
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// gone turns err into errSkipped when it says the registry lost the manifest
|
||||||
|
// or one of its blobs, keeping the copy an earlier pass made if there is one.
|
||||||
|
func (c *imageCopy) gone(repo, digest string, err error) error {
|
||||||
|
if !errors.Is(err, ErrImageGone) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, ok := c.prev.Manifests[digest]; ok && c.prevComplete(digest) {
|
||||||
|
c.res.ImagesIncomplete = append(c.res.ImagesIncomplete, fmt.Sprintf("%s@%s: %v; kept the off-site copy", repo, digest, err))
|
||||||
|
c.addPrev(digest)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
c.res.ImagesIncomplete = append(c.res.ImagesIncomplete, fmt.Sprintf("%s@%s: %v; not copied", repo, digest, err))
|
||||||
|
return errSkipped
|
||||||
|
}
|
||||||
|
|
||||||
|
// prevComplete reports whether every object digest needs is in the bucket.
|
||||||
|
func (c *imageCopy) prevComplete(digest string) bool {
|
||||||
|
m, ok := c.prev.Manifests[digest]
|
||||||
|
if !ok || c.remote[imageManifestKey(digest)] != SealedSize(m.Size) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, b := range m.Blobs {
|
||||||
|
if c.remote[imageBlobKey(b.Digest)] != SealedSize(b.Size) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, child := range m.Children {
|
||||||
|
if !c.prevComplete(child) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *imageCopy) addPrev(digest string) {
|
||||||
|
m := c.prev.Manifests[digest]
|
||||||
|
c.next.Manifests[digest] = m
|
||||||
|
for _, child := range m.Children {
|
||||||
|
c.addPrev(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sameImages reports whether two indexes record the same state.
|
||||||
|
func sameImages(a, b *ImageIndex) bool {
|
||||||
|
ja, err1 := json.Marshal(struct {
|
||||||
|
R map[string]ImageRepo
|
||||||
|
M map[string]ImageManifest
|
||||||
|
}{a.Repositories, a.Manifests})
|
||||||
|
jb, err2 := json.Marshal(struct {
|
||||||
|
R map[string]ImageRepo
|
||||||
|
M map[string]ImageManifest
|
||||||
|
}{b.Repositories, b.Manifests})
|
||||||
|
return err1 == nil && err2 == nil && bytes.Equal(ja, jb)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pruneImages drops the index versions replaced more than ImageHistory ago,
|
||||||
|
// then every blob and manifest no remaining version names.
|
||||||
|
func (s *Syncer) pruneImages(ctx context.Context, versions []string, newest *ImageIndex, remote map[string]int64, res *Result, fail func(string, ...any)) {
|
||||||
|
cutoff := s.now().Add(-ImageHistory)
|
||||||
|
var keep, drop []string
|
||||||
|
for i, v := range versions {
|
||||||
|
if i == len(versions)-1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
replaced, err := time.Parse(imageStampLayout, versions[i+1])
|
||||||
|
if err == nil && replaced.Before(cutoff) {
|
||||||
|
drop = append(drop, v)
|
||||||
|
} else {
|
||||||
|
keep = append(keep, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
live := map[string]bool{}
|
||||||
|
mark := func(x *ImageIndex) {
|
||||||
|
for d, m := range x.Manifests {
|
||||||
|
live[imageManifestKey(d)] = true
|
||||||
|
for _, b := range m.Blobs {
|
||||||
|
live[imageBlobKey(b.Digest)] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mark(newest)
|
||||||
|
for _, v := range keep {
|
||||||
|
x, err := LoadImageIndex(ctx, s.Bucket, s.Key, v)
|
||||||
|
if err != nil {
|
||||||
|
fail("read the registry index %s from the bucket: %v; nothing pruned", v, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mark(x)
|
||||||
|
}
|
||||||
|
for _, v := range drop {
|
||||||
|
if err := s.Bucket.Remove(ctx, imageIndexKey(v)); err != nil {
|
||||||
|
fail("prune registry index %s: %v", v, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete(remote, imageIndexKey(v))
|
||||||
|
res.ImageObjectsPruned++
|
||||||
|
}
|
||||||
|
for _, key := range slices.Sorted(maps.Keys(remote)) {
|
||||||
|
if !strings.HasPrefix(key, imageBlobsDir) && !strings.HasPrefix(key, imageManifestsDir) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if live[key] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := s.Bucket.Remove(ctx, key); err != nil {
|
||||||
|
fail("prune %s: %v", key, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
delete(remote, key)
|
||||||
|
res.ImageObjectsPruned++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// putImageBlob streams one blob from the registry into the bucket, checking it
|
||||||
|
// against its digest and size on the way: a blob that does not match fails
|
||||||
|
// the upload instead of storing bytes a restore would push as that digest.
|
||||||
|
func (s *Syncer) putImageBlob(ctx context.Context, repo string, b ImageBlob) error {
|
||||||
|
rc, err := s.Images.Blob(ctx, repo, b.Digest)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rc.Close()
|
||||||
|
return s.putStream(ctx, imageBlobKey(b.Digest), newDigestReader(rc, b.Digest, b.Size), b.Size)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Syncer) putBytes(ctx context.Context, key string, plain []byte) error {
|
||||||
|
var sealed bytes.Buffer
|
||||||
|
if err := Encrypt(&sealed, bytes.NewReader(plain), s.Key); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.Bucket.Put(ctx, key, &sealed, int64(sealed.Len()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Syncer) putImageIndex(ctx context.Context, stamp string, x *ImageIndex) error {
|
||||||
|
raw, err := json.Marshal(x)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.putBytes(ctx, imageIndexKey(stamp), raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// digestReader passes a blob through and fails at its end when the bytes do not
|
||||||
|
// hash to digest or are not size long.
|
||||||
|
type digestReader struct {
|
||||||
|
r io.Reader
|
||||||
|
h hash.Hash
|
||||||
|
n int64
|
||||||
|
size int64
|
||||||
|
digest string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDigestReader(r io.Reader, digest string, size int64) *digestReader {
|
||||||
|
return &digestReader{r: r, h: sha256.New(), size: size, digest: digest}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *digestReader) Read(p []byte) (int, error) {
|
||||||
|
n, err := d.r.Read(p)
|
||||||
|
d.h.Write(p[:n])
|
||||||
|
d.n += int64(n)
|
||||||
|
if d.n > d.size {
|
||||||
|
return n, fmt.Errorf("blob %s is longer than the %d bytes its manifest records", d.digest, d.size)
|
||||||
|
}
|
||||||
|
if err == io.EOF {
|
||||||
|
if d.n != d.size {
|
||||||
|
return n, fmt.Errorf("blob %s ended after %d of %d bytes", d.digest, d.n, d.size)
|
||||||
|
}
|
||||||
|
if got := "sha256:" + hex.EncodeToString(d.h.Sum(nil)); got != d.digest {
|
||||||
|
return n, fmt.Errorf("blob %s hashes to %s", d.digest, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// imageIndexStamps lists the index versions among keys, oldest first.
|
||||||
|
func imageIndexStamps(keys map[string]int64) []string {
|
||||||
|
var out []string
|
||||||
|
for key := range keys {
|
||||||
|
stamp, ok := strings.CutPrefix(key, imageIndexDir)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stamp, ok = strings.CutSuffix(stamp, imageIndexExt)
|
||||||
|
if _, err := time.Parse(imageStampLayout, stamp); ok && err == nil {
|
||||||
|
out = append(out, stamp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImageIndexes lists the registry index versions in the bucket, oldest first.
|
||||||
|
func ImageIndexes(ctx context.Context, b Bucket) ([]string, error) {
|
||||||
|
objs, err := b.List(ctx, imageIndexDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
keys := make(map[string]int64, len(objs))
|
||||||
|
for _, o := range objs {
|
||||||
|
keys[o.Key] = o.Size
|
||||||
|
}
|
||||||
|
return imageIndexStamps(keys), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadImageIndex reads one index version.
|
||||||
|
func LoadImageIndex(ctx context.Context, b Bucket, key []byte, stamp string) (*ImageIndex, error) {
|
||||||
|
raw, err := getSealed(ctx, b, key, imageIndexKey(stamp), maxImageIndexBytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
x := newImageIndex(time.Time{})
|
||||||
|
if err := json.Unmarshal(raw, x); err != nil {
|
||||||
|
return nil, fmt.Errorf("registry index %s: %w", stamp, err)
|
||||||
|
}
|
||||||
|
if x.Repositories == nil {
|
||||||
|
x.Repositories = map[string]ImageRepo{}
|
||||||
|
}
|
||||||
|
if x.Manifests == nil {
|
||||||
|
x.Manifests = map[string]ImageManifest{}
|
||||||
|
}
|
||||||
|
return x, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChooseImageIndex picks the version a restore uses: at when given, otherwise
|
||||||
|
// the newest. A newest version with no images while an older one has some is
|
||||||
|
// what a rebuilt host's first sync records before the restore, so it is
|
||||||
|
// refused with the versions worth choosing instead.
|
||||||
|
func ChooseImageIndex(ctx context.Context, b Bucket, key []byte, at string) (string, *ImageIndex, error) {
|
||||||
|
versions, err := ImageIndexes(ctx, b)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
if len(versions) == 0 {
|
||||||
|
return "", nil, errors.New("the bucket holds no registry index: no sync has copied images yet")
|
||||||
|
}
|
||||||
|
if at != "" {
|
||||||
|
if !slices.Contains(versions, at) {
|
||||||
|
return "", nil, fmt.Errorf("the bucket holds no registry index %s; it holds %s", at, strings.Join(versions, ", "))
|
||||||
|
}
|
||||||
|
x, err := LoadImageIndex(ctx, b, key, at)
|
||||||
|
return at, x, err
|
||||||
|
}
|
||||||
|
newest := versions[len(versions)-1]
|
||||||
|
x, err := LoadImageIndex(ctx, b, key, newest)
|
||||||
|
if err != nil || len(x.Repositories) > 0 {
|
||||||
|
return newest, x, err
|
||||||
|
}
|
||||||
|
var older []string
|
||||||
|
for i := len(versions) - 2; i >= 0; i-- {
|
||||||
|
o, err := LoadImageIndex(ctx, b, key, versions[i])
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
if len(o.Repositories) > 0 {
|
||||||
|
older = append(older, fmt.Sprintf("%s (%d repositories, %d images)", versions[i], len(o.Repositories), o.Images()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(older) == 0 {
|
||||||
|
return newest, x, nil
|
||||||
|
}
|
||||||
|
return "", nil, fmt.Errorf("the newest registry index %s lists no images, which is what a rebuilt host records before its restore; pick the state to restore with -at: %s",
|
||||||
|
newest, strings.Join(older, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
// getSealed downloads and decrypts a small object.
|
||||||
|
func getSealed(ctx context.Context, b Bucket, key []byte, objKey string, limit int64) ([]byte, error) {
|
||||||
|
rc, err := b.Get(ctx, objKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rc.Close()
|
||||||
|
var out bytes.Buffer
|
||||||
|
if err := Decrypt(&limitWriter{w: &out, left: limit}, rc, key); err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", objKey, err)
|
||||||
|
}
|
||||||
|
return out.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type limitWriter struct {
|
||||||
|
w io.Writer
|
||||||
|
left int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *limitWriter) Write(p []byte) (int, error) {
|
||||||
|
if int64(len(p)) > l.left {
|
||||||
|
return 0, errors.New("object is larger than expected")
|
||||||
|
}
|
||||||
|
l.left -= int64(len(p))
|
||||||
|
return l.w.Write(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchImagesResult is what FetchImages did.
|
||||||
|
type FetchImagesResult struct {
|
||||||
|
Repositories int
|
||||||
|
Manifests int
|
||||||
|
Tags int
|
||||||
|
// BlobsPushed and BytesPushed count the blobs the registry lacked.
|
||||||
|
BlobsPushed int
|
||||||
|
BytesPushed int64
|
||||||
|
Failures []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchImages pushes every image x records back into t, each repository's
|
||||||
|
// blobs first, then its manifests by digest (an index after the manifests it
|
||||||
|
// names), then its tags. What t already holds is skipped, so a second run
|
||||||
|
// finishes what the first could not.
|
||||||
|
func FetchImages(ctx context.Context, b Bucket, key []byte, x *ImageIndex, t ImageTarget, log io.Writer) (FetchImagesResult, error) {
|
||||||
|
var res FetchImagesResult
|
||||||
|
bodies := map[string][]byte{}
|
||||||
|
body := func(d string) ([]byte, error) {
|
||||||
|
if raw, ok := bodies[d]; ok {
|
||||||
|
return raw, nil
|
||||||
|
}
|
||||||
|
raw, err := getSealed(ctx, b, key, imageManifestKey(d), maxImageManifestBytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(raw)
|
||||||
|
if got := "sha256:" + hex.EncodeToString(sum[:]); got != d {
|
||||||
|
return nil, fmt.Errorf("the stored manifest %s hashes to %s", d, got)
|
||||||
|
}
|
||||||
|
bodies[d] = raw
|
||||||
|
return raw, nil
|
||||||
|
}
|
||||||
|
pushedBlobs := map[string]bool{}
|
||||||
|
for _, repo := range slices.Sorted(maps.Keys(x.Repositories)) {
|
||||||
|
entry := x.Repositories[repo]
|
||||||
|
done := map[string]bool{}
|
||||||
|
var push func(d string) error
|
||||||
|
push = func(d string) error {
|
||||||
|
if done[d] {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m, ok := x.Manifests[d]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("the index names %s but does not describe it", d)
|
||||||
|
}
|
||||||
|
for _, child := range m.Children {
|
||||||
|
if err := push(child); err != nil {
|
||||||
|
return fmt.Errorf("child %s: %w", child, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, bl := range m.Blobs {
|
||||||
|
open := func() (io.ReadCloser, error) {
|
||||||
|
if !pushedBlobs[repo+"@"+bl.Digest] {
|
||||||
|
pushedBlobs[repo+"@"+bl.Digest] = true
|
||||||
|
res.BlobsPushed++
|
||||||
|
res.BytesPushed += bl.Size
|
||||||
|
}
|
||||||
|
return openSealed(ctx, b, key, imageBlobKey(bl.Digest))
|
||||||
|
}
|
||||||
|
if err := t.PutBlob(ctx, repo, bl.Digest, bl.Size, open); err != nil {
|
||||||
|
return fmt.Errorf("blob %s: %w", bl.Digest, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
raw, err := body(d)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := t.PutManifest(ctx, repo, d, m.MediaType, raw); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
done[d] = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ok := true
|
||||||
|
for _, d := range entry.Manifests {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
if err := push(d); err != nil {
|
||||||
|
res.Failures = append(res.Failures, fmt.Sprintf("%s@%s: %v", repo, d, err))
|
||||||
|
ok = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
res.Manifests++
|
||||||
|
}
|
||||||
|
for _, tag := range slices.Sorted(maps.Keys(entry.Tags)) {
|
||||||
|
d := entry.Tags[tag]
|
||||||
|
if !done[d] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := t.PutManifest(ctx, repo, tag, x.Manifests[d].MediaType, bodies[d]); err != nil {
|
||||||
|
res.Failures = append(res.Failures, fmt.Sprintf("%s:%s: %v", repo, tag, err))
|
||||||
|
ok = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
res.Tags++
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
res.Repositories++
|
||||||
|
if log != nil {
|
||||||
|
fmt.Fprintf(log, "felis offsite: restored %s (%d images, %d tags)\n", repo, len(entry.Manifests), len(entry.Tags))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(res.Failures) > 0 {
|
||||||
|
return res, fmt.Errorf("%d images or tags failed; first: %s", len(res.Failures), res.Failures[0])
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// openSealed streams the decrypted content of objKey. Decryption authenticates
|
||||||
|
// every segment; the registry checks the whole against its digest.
|
||||||
|
func openSealed(ctx context.Context, b Bucket, key []byte, objKey string) (io.ReadCloser, error) {
|
||||||
|
rc, err := b.Get(ctx, objKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pr, pw := io.Pipe()
|
||||||
|
go func() {
|
||||||
|
err := Decrypt(pw, rc, key)
|
||||||
|
rc.Close()
|
||||||
|
pw.CloseWithError(err)
|
||||||
|
}()
|
||||||
|
return pr, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,454 @@
|
|||||||
|
package offsite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- fake registry ---------------------------------------------------------
|
||||||
|
|
||||||
|
type fakeRepo struct {
|
||||||
|
digests []string
|
||||||
|
tags map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeManifest struct {
|
||||||
|
body []byte
|
||||||
|
mediaType string
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeRegistry is the registry as the sync reads it and as a restore writes
|
||||||
|
// it: one blob and manifest store shared by every repository, as
|
||||||
|
// distribution keeps it, and per-repository links.
|
||||||
|
type fakeRegistry struct {
|
||||||
|
repos map[string]*fakeRepo
|
||||||
|
blobs map[string][]byte
|
||||||
|
manifests map[string]fakeManifest
|
||||||
|
// linked records which blobs each repository holds, for a restore.
|
||||||
|
linked map[string]map[string]bool
|
||||||
|
revErr map[string]error
|
||||||
|
gone map[string]bool
|
||||||
|
corrupt map[string]bool
|
||||||
|
blobGets int
|
||||||
|
// log is what a restore did, in order.
|
||||||
|
log []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeRegistry() *fakeRegistry {
|
||||||
|
return &fakeRegistry{
|
||||||
|
repos: map[string]*fakeRepo{}, blobs: map[string][]byte{}, manifests: map[string]fakeManifest{},
|
||||||
|
linked: map[string]map[string]bool{}, revErr: map[string]error{}, gone: map[string]bool{}, corrupt: map[string]bool{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func digestOf(b []byte) string {
|
||||||
|
sum := sha256.Sum256(b)
|
||||||
|
return "sha256:" + hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRegistry) blob(data []byte) map[string]any {
|
||||||
|
d := digestOf(data)
|
||||||
|
r.blobs[d] = data
|
||||||
|
return map[string]any{"mediaType": "application/octet-stream", "digest": d, "size": len(data)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRegistry) link(repo, digest string, tags ...string) {
|
||||||
|
rp := r.repos[repo]
|
||||||
|
if rp == nil {
|
||||||
|
rp = &fakeRepo{tags: map[string]string{}}
|
||||||
|
r.repos[repo] = rp
|
||||||
|
}
|
||||||
|
if !slices.Contains(rp.digests, digest) {
|
||||||
|
rp.digests = append(rp.digests, digest)
|
||||||
|
}
|
||||||
|
for _, t := range tags {
|
||||||
|
rp.tags[t] = digest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRegistry) putManifest(mediaType string, m map[string]any) string {
|
||||||
|
m["schemaVersion"] = 2
|
||||||
|
m["mediaType"] = mediaType
|
||||||
|
body, _ := json.Marshal(m)
|
||||||
|
d := digestOf(body)
|
||||||
|
r.manifests[d] = fakeManifest{body: body, mediaType: mediaType}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// image stores an image manifest of the given layers in repo and returns its digest.
|
||||||
|
func (r *fakeRegistry) image(repo, tag string, layers ...[]byte) string {
|
||||||
|
var ls []any
|
||||||
|
for _, l := range layers {
|
||||||
|
ls = append(ls, r.blob(l))
|
||||||
|
}
|
||||||
|
cfg := r.blob([]byte(fmt.Sprintf(`{"architecture":"arm64","repo":%q,"tag":%q}`, repo, tag)))
|
||||||
|
d := r.putManifest(mediaDockerManifest2, map[string]any{"config": cfg, "layers": ls})
|
||||||
|
r.link(repo, d, tag)
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// index stores an image index over children in repo.
|
||||||
|
func (r *fakeRegistry) index(repo, tag string, children ...string) string {
|
||||||
|
var ms []any
|
||||||
|
for _, c := range children {
|
||||||
|
ms = append(ms, map[string]any{"mediaType": mediaOCIManifest, "digest": c, "size": len(r.manifests[c].body)})
|
||||||
|
}
|
||||||
|
d := r.putManifest(mediaOCIIndex, map[string]any{"manifests": ms})
|
||||||
|
r.link(repo, d, tag)
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRegistry) Repositories(context.Context) ([]string, error) {
|
||||||
|
var out []string
|
||||||
|
for name := range r.repos {
|
||||||
|
out = append(out, name)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRegistry) Revisions(_ context.Context, repo string) ([]string, map[string]string, error) {
|
||||||
|
if err := r.revErr[repo]; err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
rp := r.repos[repo]
|
||||||
|
return slices.Clone(rp.digests), rp.tags, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRegistry) Manifest(_ context.Context, repo, digest string) ([]byte, string, error) {
|
||||||
|
m, ok := r.manifests[digest]
|
||||||
|
if !ok || r.gone[digest] {
|
||||||
|
return nil, "", fmt.Errorf("%w: manifest %s", ErrImageGone, digest)
|
||||||
|
}
|
||||||
|
return m.body, m.mediaType, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRegistry) Blob(_ context.Context, repo, digest string) (io.ReadCloser, error) {
|
||||||
|
r.blobGets++
|
||||||
|
b, ok := r.blobs[digest]
|
||||||
|
if !ok || r.gone[digest] {
|
||||||
|
return nil, fmt.Errorf("%w: blob %s", ErrImageGone, digest)
|
||||||
|
}
|
||||||
|
if r.corrupt[digest] {
|
||||||
|
b = append(bytes.Clone(b), 'x')
|
||||||
|
}
|
||||||
|
return io.NopCloser(bytes.NewReader(b)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRegistry) PutBlob(_ context.Context, repo, digest string, size int64, open func() (io.ReadCloser, error)) error {
|
||||||
|
if r.linked[repo][digest] {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rc, err := open()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rc.Close()
|
||||||
|
data, err := io.ReadAll(rc)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if int64(len(data)) != size || digestOf(data) != digest {
|
||||||
|
return fmt.Errorf("blob upload does not match %s", digest)
|
||||||
|
}
|
||||||
|
r.blobs[digest] = data
|
||||||
|
if r.linked[repo] == nil {
|
||||||
|
r.linked[repo] = map[string]bool{}
|
||||||
|
}
|
||||||
|
r.linked[repo][digest] = true
|
||||||
|
r.log = append(r.log, "blob "+repo+" "+digest)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRegistry) PutManifest(_ context.Context, repo, reference, mediaType string, body []byte) error {
|
||||||
|
d := digestOf(body)
|
||||||
|
if strings.HasPrefix(reference, "sha256:") && reference != d {
|
||||||
|
return fmt.Errorf("manifest %s hashes to %s", reference, d)
|
||||||
|
}
|
||||||
|
// Like distribution: everything a manifest names must be in the repository.
|
||||||
|
im, err := describeManifest(body, mediaType)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, b := range im.Blobs {
|
||||||
|
if !r.linked[repo][b.Digest] {
|
||||||
|
return fmt.Errorf("blob unknown: %s", b.Digest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, c := range im.Children {
|
||||||
|
if r.repos[repo] == nil || !slices.Contains(r.repos[repo].digests, c) {
|
||||||
|
return fmt.Errorf("manifest unknown: %s", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.manifests[d] = fakeManifest{body: body, mediaType: mediaType}
|
||||||
|
if strings.HasPrefix(reference, "sha256:") {
|
||||||
|
r.link(repo, d)
|
||||||
|
} else {
|
||||||
|
r.link(repo, d, reference)
|
||||||
|
}
|
||||||
|
r.log = append(r.log, "manifest "+repo+" "+reference)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func layer(n int) []byte {
|
||||||
|
b := make([]byte, n)
|
||||||
|
rand.Read(b)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func newImageSyncer(t *testing.T, reg *fakeRegistry) (*Syncer, *memBucket, *time.Time) {
|
||||||
|
t.Helper()
|
||||||
|
b := newMemBucket()
|
||||||
|
clock := now
|
||||||
|
return &Syncer{
|
||||||
|
Bucket: b, Catalog: &fakeCatalog{}, Key: testKey(t), Images: reg,
|
||||||
|
Now: func() time.Time { return clock },
|
||||||
|
}, b, &clock
|
||||||
|
}
|
||||||
|
|
||||||
|
func keysUnder(b *memBucket, prefix string) []string {
|
||||||
|
objs, _ := b.List(context.Background(), prefix)
|
||||||
|
var out []string
|
||||||
|
for _, o := range objs {
|
||||||
|
out = append(out, o.Key)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- tests -----------------------------------------------------------------
|
||||||
|
|
||||||
|
// TestSyncImagesCopiesUserImages: user repositories are copied blob by blob,
|
||||||
|
// each blob once however many images share it, and the platform's reserved
|
||||||
|
// repositories are left alone. A second run with nothing new sends nothing.
|
||||||
|
func TestSyncImagesCopiesUserImages(t *testing.T) {
|
||||||
|
reg := newFakeRegistry()
|
||||||
|
shared := layer(3000)
|
||||||
|
a := reg.image("user-uploads/sub-a", "latest", shared, layer(70000))
|
||||||
|
child := reg.image("e2e/multi", "arm64", shared)
|
||||||
|
idx := reg.index("e2e/multi", "latest", child)
|
||||||
|
reg.image("felis/felis", "v1", layer(5000))
|
||||||
|
reg.image("mirror/trivy-db", "2", layer(5000))
|
||||||
|
|
||||||
|
s, b, _ := newImageSyncer(t, reg)
|
||||||
|
res, err := s.Run(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run: %v", err)
|
||||||
|
}
|
||||||
|
if res.ImageRepos != 2 || res.Images != 3 || res.ImagesUploaded != 3 {
|
||||||
|
t.Fatalf("result = %+v, want 2 repositories, 3 images, 3 uploaded", res)
|
||||||
|
}
|
||||||
|
// shared layer once, one unique layer, two configs
|
||||||
|
if got := keysUnder(b, imageBlobsDir); len(got) != 4 || res.ImageBlobsUploaded != 4 {
|
||||||
|
t.Fatalf("blobs in bucket = %v (uploaded %d), want 4", got, res.ImageBlobsUploaded)
|
||||||
|
}
|
||||||
|
for _, d := range []string{a, child, idx} {
|
||||||
|
if _, ok := b.objs[imageManifestKey(d)]; !ok {
|
||||||
|
t.Errorf("manifest %s not in the bucket", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
x, err := LoadImageIndex(context.Background(), b, s.Key, res.ImageIndex)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, ok := x.Repositories["felis/felis"]; ok {
|
||||||
|
t.Fatal("reserved repository felis/felis was copied")
|
||||||
|
}
|
||||||
|
if got := x.Repositories["e2e/multi"]; got.Tags["latest"] != idx || got.Tags["arm64"] != child {
|
||||||
|
t.Fatalf("e2e/multi tags = %v", got.Tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
puts, gets := b.puts, reg.blobGets
|
||||||
|
res, err = s.Run(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second Run: %v", err)
|
||||||
|
}
|
||||||
|
if b.puts != puts || reg.blobGets != gets {
|
||||||
|
t.Fatalf("second run sent %d objects and read %d blobs, want none", b.puts-puts, reg.blobGets-gets)
|
||||||
|
}
|
||||||
|
if len(keysUnder(b, imageIndexDir)) != 1 {
|
||||||
|
t.Fatalf("an unchanged registry wrote another index: %v", keysUnder(b, imageIndexDir))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSyncImagesRejectsCorruptBlob: bytes that do not hash to the digest never
|
||||||
|
// become that digest's object, the image stays out of the index, and the run
|
||||||
|
// fails so the next one tries again.
|
||||||
|
func TestSyncImagesRejectsCorruptBlob(t *testing.T) {
|
||||||
|
reg := newFakeRegistry()
|
||||||
|
bad := layer(100)
|
||||||
|
reg.image("user/a", "v1", bad)
|
||||||
|
reg.corrupt[digestOf(bad)] = true
|
||||||
|
|
||||||
|
s, b, _ := newImageSyncer(t, reg)
|
||||||
|
res, err := s.Run(context.Background())
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "longer than") {
|
||||||
|
t.Fatalf("Run = %v, want the corrupt blob to fail it", err)
|
||||||
|
}
|
||||||
|
if _, ok := b.objs[imageBlobKey(digestOf(bad))]; ok {
|
||||||
|
t.Fatal("the corrupt blob was stored")
|
||||||
|
}
|
||||||
|
if len(keysUnder(b, imageManifestsDir)) != 0 || res.Images != 0 {
|
||||||
|
t.Fatalf("manifest stored without its blob: %v, images=%d", keysUnder(b, imageManifestsDir), res.Images)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSyncImagesSkipsWhatTheRegistryLost: a manifest whose blob the registry no
|
||||||
|
// longer has is reported, not copied, and does not fail the run; one copied
|
||||||
|
// earlier keeps its off-site copy.
|
||||||
|
func TestSyncImagesSkipsWhatTheRegistryLost(t *testing.T) {
|
||||||
|
reg := newFakeRegistry()
|
||||||
|
keptLayer := layer(100)
|
||||||
|
kept := reg.image("user/a", "v1", keptLayer)
|
||||||
|
s, b, _ := newImageSyncer(t, reg)
|
||||||
|
if _, err := s.Run(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
lostLayer := layer(100)
|
||||||
|
lost := reg.image("user/a", "v2", lostLayer)
|
||||||
|
reg.gone[digestOf(lostLayer)] = true
|
||||||
|
res, err := s.Run(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run = %v, want a lost blob to be reported only", err)
|
||||||
|
}
|
||||||
|
if len(res.ImagesIncomplete) != 1 || !strings.Contains(res.ImagesIncomplete[0], lost) {
|
||||||
|
t.Fatalf("incomplete = %v, want %s", res.ImagesIncomplete, lost)
|
||||||
|
}
|
||||||
|
x, _ := LoadImageIndex(context.Background(), b, s.Key, res.ImageIndex)
|
||||||
|
if got := x.Repositories["user/a"]; !slices.Equal(got.Manifests, []string{kept}) || got.Tags["v2"] != "" {
|
||||||
|
t.Fatalf("user/a = %+v, want only the complete image", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSyncImagesKeepsReplacedStates: a registry that comes back empty (a host
|
||||||
|
// being rebuilt) writes a new index version but prunes nothing; the objects of
|
||||||
|
// the old state go only ImageHistory after it was replaced.
|
||||||
|
func TestSyncImagesKeepsReplacedStates(t *testing.T) {
|
||||||
|
reg := newFakeRegistry()
|
||||||
|
reg.image("user/a", "v1", layer(100))
|
||||||
|
s, b, clock := newImageSyncer(t, reg)
|
||||||
|
if _, err := s.Run(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
objects := len(keysUnder(b, imageBlobsDir)) + len(keysUnder(b, imageManifestsDir))
|
||||||
|
|
||||||
|
reg.repos = map[string]*fakeRepo{}
|
||||||
|
*clock = clock.Add(time.Hour)
|
||||||
|
res, err := s.Run(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.ImageRepos != 0 || len(keysUnder(b, imageIndexDir)) != 2 || res.ImageObjectsPruned != 0 {
|
||||||
|
t.Fatalf("after emptying: repos=%d versions=%v pruned=%d", res.ImageRepos, keysUnder(b, imageIndexDir), res.ImageObjectsPruned)
|
||||||
|
}
|
||||||
|
if got := len(keysUnder(b, imageBlobsDir)) + len(keysUnder(b, imageManifestsDir)); got != objects {
|
||||||
|
t.Fatalf("objects = %d, want the old state's %d kept", got, objects)
|
||||||
|
}
|
||||||
|
// A restore now refuses the empty newest state and names the old one.
|
||||||
|
if _, _, err := ChooseImageIndex(context.Background(), b, s.Key, ""); err == nil || !strings.Contains(err.Error(), "-at") {
|
||||||
|
t.Fatalf("ChooseImageIndex = %v, want it to point at -at", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
*clock = clock.Add(ImageHistory - 2*time.Hour)
|
||||||
|
if res, _ := s.Run(context.Background()); res.ImageObjectsPruned != 0 {
|
||||||
|
t.Fatalf("pruned %d objects inside the history window", res.ImageObjectsPruned)
|
||||||
|
}
|
||||||
|
*clock = clock.Add(3 * time.Hour)
|
||||||
|
res, err = s.Run(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.ImageObjectsPruned != objects+1 || len(keysUnder(b, imageBlobsDir)) != 0 || len(keysUnder(b, imageIndexDir)) != 1 {
|
||||||
|
t.Fatalf("pruned %d, left blobs %v and versions %v", res.ImageObjectsPruned, keysUnder(b, imageBlobsDir), keysUnder(b, imageIndexDir))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSyncImagesUnreadableRepoKeepsItsEntry: a repository the registry fails to
|
||||||
|
// list keeps its last recorded state, and the failed run prunes nothing.
|
||||||
|
func TestSyncImagesUnreadableRepoKeepsItsEntry(t *testing.T) {
|
||||||
|
reg := newFakeRegistry()
|
||||||
|
a := reg.image("user/a", "v1", layer(100))
|
||||||
|
s, b, clock := newImageSyncer(t, reg)
|
||||||
|
if _, err := s.Run(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
reg.revErr["user/a"] = errors.New("gate restarting")
|
||||||
|
reg.image("user/b", "v1", layer(100))
|
||||||
|
*clock = clock.Add(ImageHistory * 2)
|
||||||
|
res, err := s.Run(context.Background())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Run succeeded with an unreadable repository")
|
||||||
|
}
|
||||||
|
x, _ := LoadImageIndex(context.Background(), b, s.Key, res.ImageIndex)
|
||||||
|
if got := x.Repositories["user/a"]; !slices.Equal(got.Manifests, []string{a}) {
|
||||||
|
t.Fatalf("user/a = %+v, want its last recorded state", got)
|
||||||
|
}
|
||||||
|
if _, ok := x.Repositories["user/b"]; !ok || res.ImageObjectsPruned != 0 {
|
||||||
|
t.Fatalf("user/b missing or pruned %d", res.ImageObjectsPruned)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFetchImagesRestoresRegistry: a fresh registry gets back every user image
|
||||||
|
// with the same digests and tags, blobs before the manifests that name them and
|
||||||
|
// an index after its children; a second run pushes nothing.
|
||||||
|
func TestFetchImagesRestoresRegistry(t *testing.T) {
|
||||||
|
reg := newFakeRegistry()
|
||||||
|
shared := layer(3000)
|
||||||
|
a := reg.image("user/a", "v1", shared, layer(9000))
|
||||||
|
child := reg.image("user/multi", "arm64", shared)
|
||||||
|
idx := reg.index("user/multi", "latest", child)
|
||||||
|
s, b, _ := newImageSyncer(t, reg)
|
||||||
|
if _, err := s.Run(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stamp, x, err := ChooseImageIndex(context.Background(), b, s.Key, "")
|
||||||
|
if err != nil || stamp == "" {
|
||||||
|
t.Fatalf("ChooseImageIndex = %q, %v", stamp, err)
|
||||||
|
}
|
||||||
|
fresh := newFakeRegistry()
|
||||||
|
res, err := FetchImages(context.Background(), b, s.Key, x, fresh, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FetchImages: %v", err)
|
||||||
|
}
|
||||||
|
if res.Repositories != 2 || res.Manifests != 3 || res.Tags != 3 || res.BlobsPushed != 5 {
|
||||||
|
t.Fatalf("result = %+v", res)
|
||||||
|
}
|
||||||
|
for repo, want := range map[string]map[string]string{
|
||||||
|
"user/a": {"v1": a},
|
||||||
|
"user/multi": {"arm64": child, "latest": idx},
|
||||||
|
} {
|
||||||
|
for tag, d := range want {
|
||||||
|
if got := fresh.repos[repo].tags[tag]; got != d {
|
||||||
|
t.Errorf("%s:%s = %s, want %s", repo, tag, got, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !bytes.Equal(fresh.manifests[idx].body, reg.manifests[idx].body) {
|
||||||
|
t.Fatal("restored index differs from the original")
|
||||||
|
}
|
||||||
|
pos := func(entry string) int { return slices.Index(fresh.log, entry) }
|
||||||
|
if pos("manifest user/multi "+child) > pos("manifest user/multi "+idx) {
|
||||||
|
t.Fatalf("index pushed before its child: %v", fresh.log)
|
||||||
|
}
|
||||||
|
|
||||||
|
n := len(fresh.log)
|
||||||
|
if _, err := FetchImages(context.Background(), b, s.Key, x, fresh, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, e := range fresh.log[n:] {
|
||||||
|
if strings.HasPrefix(e, "blob ") {
|
||||||
|
t.Fatalf("second restore uploaded a blob again: %s", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+38
-15
@@ -1,8 +1,10 @@
|
|||||||
// Package offsite keeps a second copy of what a lost node would take with it:
|
// Package offsite keeps a second copy of what a lost node would take with it:
|
||||||
// every world archive (world_backups) and the newest control-plane database
|
// every world archive (world_backups), the newest control-plane database
|
||||||
// bundles (internal/dbbackup), encrypted, in an S3-compatible bucket off the
|
// bundles (internal/dbbackup) and the user images in the platform registry
|
||||||
// machine. `felis offsite sync` runs it from felis-offsite.timer on the host,
|
// (images.go), encrypted, in an S3-compatible bucket off the machine. `felis
|
||||||
// which is where both the archive volume and the bundle directory live.
|
// offsite sync` runs it from felis-offsite.timer on the host, which is where
|
||||||
|
// the archive volume and the bundle directory live and where the registry
|
||||||
|
// answers on its loopback hostPort.
|
||||||
//
|
//
|
||||||
// The database records the copy: world_backups.offsite_at is set once an
|
// The database records the copy: world_backups.offsite_at is set once an
|
||||||
// archive's object is in the bucket, and with [offsite] configured the reaper
|
// archive's object is in the bucket, and with [offsite] configured the reaper
|
||||||
@@ -89,6 +91,9 @@ type Syncer struct {
|
|||||||
// bucket keeps.
|
// bucket keeps.
|
||||||
DBDir string
|
DBDir string
|
||||||
DBKeep int
|
DBKeep int
|
||||||
|
// Images is the platform registry whose user images are copied (images.go);
|
||||||
|
// nil copies none.
|
||||||
|
Images ImageSource
|
||||||
Now func() time.Time
|
Now func() time.Time
|
||||||
Log io.Writer
|
Log io.Writer
|
||||||
}
|
}
|
||||||
@@ -110,6 +115,18 @@ type Result struct {
|
|||||||
DBPruned int `json:"db_pruned"`
|
DBPruned int `json:"db_pruned"`
|
||||||
RemoteDB int `json:"remote_db"`
|
RemoteDB int `json:"remote_db"`
|
||||||
NewestDB string `json:"newest_db,omitempty"`
|
NewestDB string `json:"newest_db,omitempty"`
|
||||||
|
// ImageIndex is the newest registry index version in the bucket, which
|
||||||
|
// lists ImageRepos repositories holding Images images.
|
||||||
|
ImageIndex string `json:"image_index,omitempty"`
|
||||||
|
ImageRepos int `json:"image_repos"`
|
||||||
|
Images int `json:"images"`
|
||||||
|
ImagesUploaded int `json:"images_uploaded"`
|
||||||
|
ImageBlobsUploaded int `json:"image_blobs_uploaded"`
|
||||||
|
ImageObjectsPruned int `json:"image_objects_pruned"`
|
||||||
|
RemoteImageBytes int64 `json:"remote_image_bytes"`
|
||||||
|
// ImagesIncomplete are manifests the registry lists without holding all
|
||||||
|
// of them, so there was nothing whole to copy.
|
||||||
|
ImagesIncomplete []string `json:"images_incomplete,omitempty"`
|
||||||
Errors []string `json:"errors,omitempty"`
|
Errors []string `json:"errors,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,9 +143,9 @@ func (s *Syncer) logf(format string, args ...any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run does one pass: world archives, then database bundles, then expiry. A
|
// Run does one pass: world archives, database bundles, registry images, then
|
||||||
// failure on one item is recorded and the pass carries on; the returned error
|
// expiry. A failure on one item is recorded and the pass carries on; the
|
||||||
// is non-nil when anything failed.
|
// returned error is non-nil when anything failed.
|
||||||
func (s *Syncer) Run(ctx context.Context) (Result, error) {
|
func (s *Syncer) Run(ctx context.Context) (Result, error) {
|
||||||
var res Result
|
var res Result
|
||||||
fail := func(format string, args ...any) {
|
fail := func(format string, args ...any) {
|
||||||
@@ -143,6 +160,7 @@ func (s *Syncer) Run(ctx context.Context) (Result, error) {
|
|||||||
}
|
}
|
||||||
s.syncWorlds(ctx, remoteWorlds, &res, fail)
|
s.syncWorlds(ctx, remoteWorlds, &res, fail)
|
||||||
s.syncDB(ctx, &res, fail)
|
s.syncDB(ctx, &res, fail)
|
||||||
|
s.syncImages(ctx, &res, fail)
|
||||||
s.expireWorlds(ctx, remoteWorlds, &res, fail)
|
s.expireWorlds(ctx, remoteWorlds, &res, fail)
|
||||||
|
|
||||||
for _, size := range remoteWorlds {
|
for _, size := range remoteWorlds {
|
||||||
@@ -327,23 +345,28 @@ func (s *Syncer) expireWorlds(ctx context.Context, remote map[string]int64, res
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// putFile encrypts the file at p into key. The sealed size is known in
|
// putFile encrypts the file at p into key.
|
||||||
// advance, so the upload streams: nothing larger than one part is buffered.
|
|
||||||
func (s *Syncer) putFile(ctx context.Context, key, p string, size int64) error {
|
func (s *Syncer) putFile(ctx context.Context, key, p string, size int64) error {
|
||||||
f, err := os.Open(p)
|
f, err := os.Open(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
|
// A file that changed size under us would not match the declared length;
|
||||||
|
// LimitReader keeps the stream to the size we announced and the bucket's
|
||||||
|
// length check catches a short one.
|
||||||
|
return s.putStream(ctx, key, io.LimitReader(f, size), size)
|
||||||
|
}
|
||||||
|
|
||||||
|
// putStream encrypts size bytes of src into key. The sealed size is known in
|
||||||
|
// advance, so the upload streams: nothing larger than one part is buffered.
|
||||||
|
// An error from src fails the upload.
|
||||||
|
func (s *Syncer) putStream(ctx context.Context, key string, src io.Reader, size int64) error {
|
||||||
pr, pw := io.Pipe()
|
pr, pw := io.Pipe()
|
||||||
go func() {
|
go func() {
|
||||||
// A file that changed size under us would not match the declared
|
pw.CloseWithError(Encrypt(pw, src, s.Key))
|
||||||
// length; LimitReader keeps the stream to the size we announced and
|
|
||||||
// the length check below catches a short one.
|
|
||||||
err := Encrypt(pw, io.LimitReader(f, size), s.Key)
|
|
||||||
pw.CloseWithError(err)
|
|
||||||
}()
|
}()
|
||||||
err = s.Bucket.Put(ctx, key, pr, SealedSize(size))
|
err := s.Bucket.Put(ctx, key, pr, SealedSize(size))
|
||||||
pr.CloseWithError(errors.New("upload finished"))
|
pr.CloseWithError(errors.New("upload finished"))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -361,8 +361,8 @@ func OffsiteFinding(statusFile string, now time.Time) *Finding {
|
|||||||
}
|
}
|
||||||
f := &Finding{
|
f := &Finding{
|
||||||
Key: "offsite", Severity: Warning, For: backupFor,
|
Key: "offsite", Severity: Warning, For: backupFor,
|
||||||
Summary: "异地备份从未成功同步过,世界归档与数据库备份只在本机",
|
Summary: "异地备份从未成功同步过,世界归档、数据库备份与用户镜像只在本机",
|
||||||
SummaryEN: "the off-site copy has never completed; world archives and database bundles exist on this machine only",
|
SummaryEN: "the off-site copy has never completed; world archives, database bundles and user images exist on this machine only",
|
||||||
Hint: hint,
|
Hint: hint,
|
||||||
}
|
}
|
||||||
if st != nil && !st.LastSuccess.IsZero() {
|
if st != nil && !st.LastSuccess.IsZero() {
|
||||||
|
|||||||
Reference in new issue
Block a user