fix(offsite): 服务器或白名单按摘要钉住的 felis/ 与 mirror/ 版本也进异地副本,恢复只按摘要推回不动安装器的 tag

This commit is contained in:
Lemon-miaow committed 2026-09-25 05:00:02 +08:00
1 parent 8fb3d298ae
commit 2779d8f5cf
8 files changed
+158 -20

No files matched your search

+1
View File
@@ -294,6 +294,7 @@ func runOffsiteSync(cfg *config.Config, env *offsiteEnv, src offsiteSources, log
} }
if src.registry != "" { if src.registry != "" {
s.Images = newRegistryImages(src.registry) s.Images = newRegistryImages(src.registry)
s.ImagePins = imagePins(drv.DB(), cfg.Registry.URL)
} }
return s.Run(ctx) return s.Run(ctx)
} }
+46
View File
@@ -2,15 +2,18 @@ package main
import ( import (
"context" "context"
"database/sql"
"errors" "errors"
"flag" "flag"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"os" "os"
"slices"
"strings" "strings"
"time" "time"
"felis.lolicon.best/internal/apis/felis/v1alpha1"
"felis.lolicon.best/internal/config" "felis.lolicon.best/internal/config"
"felis.lolicon.best/internal/imagepush" "felis.lolicon.best/internal/imagepush"
"felis.lolicon.best/internal/offsite" "felis.lolicon.best/internal/offsite"
@@ -72,6 +75,49 @@ func (r *registryImages) PutManifest(ctx context.Context, repo, reference, media
return err return err
} }
// imagePins lists, per repository of the registry refs spell as host, the
// digests the MinecraftServers' specs and the image whitelist pin: what a
// restored database and its servers will ask the registry for.
func imagePins(db *sql.DB, host string) func(ctx context.Context) (map[string][]string, error) {
return func(ctx context.Context) (map[string][]string, error) {
cl, err := buildSystemServerClient()
if err != nil {
return nil, fmt.Errorf("reach the cluster: %w", err)
}
var servers v1alpha1.MinecraftServerList
if err := cl.List(ctx, &servers); err != nil {
return nil, fmt.Errorf("list MinecraftServers: %w", err)
}
refs := make([]string, 0, len(servers.Items))
for _, s := range servers.Items {
refs = append(refs, s.Spec.Image)
}
rows, err := db.QueryContext(ctx, `SELECT image_ref FROM image_whitelist`)
if err != nil {
return nil, fmt.Errorf("read the image whitelist: %w", err)
}
defer rows.Close()
for rows.Next() {
var ref string
if err := rows.Scan(&ref); err != nil {
return nil, fmt.Errorf("read the image whitelist: %w", err)
}
refs = append(refs, ref)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("read the image whitelist: %w", err)
}
pins := map[string][]string{}
for _, ref := range refs {
repo, _, digest, ok := registryprune.ParseRef(ref, host)
if ok && digest != "" && !slices.Contains(pins[repo], digest) {
pins[repo] = append(pins[repo], digest)
}
}
return pins, nil
}
}
// registryGone marks a 404 as a manifest or blob the registry no longer holds. // registryGone marks a 404 as a manifest or blob the registry no longer holds.
func registryGone(err error) error { func registryGone(err error) error {
var se *imagepush.StatusError var se *imagepush.StatusError
+8 -3
View File
@@ -1792,9 +1792,14 @@ What runs:
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`]
- The same run copies the user images in the platform registry: every - The same run copies the user images in the platform registry: every
repository outside `felis/` and `mirror/` (the installer pushes those again), repository outside `felis/` and `mirror/`, each manifest the registry's index
each manifest the registry's index lists and every layer it names, read lists and every layer it names, read through the loopback hostPort. A layer
through the loopback hostPort. A layer shared by many images is stored once. shared by many images is stored once. The installer pushes `felis/` and
`mirror/` again on a new host, but at new digests, so from those the run
copies only the revisions a MinecraftServer or a whitelist entry pins by
digest (a server created from the platform's Paper image, for one), without
their tags; a restore puts them back by digest and leaves the installer's
tags alone.
When the set changed, a new version of the image list is written; versions 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 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 they named, so an image deleted by mistake stays restorable for two weeks
+32 -12
View File
@@ -1,10 +1,13 @@
package offsite package offsite
// The off-site copy of the platform registry's user images. The installer // The off-site copy of the platform registry's user images. Every repository
// pushes everything under felis/ and mirror/ again on any host, so those are // outside felis/ and mirror/ holds builds that exist nowhere else: a lost
// left out; every other repository holds builds that exist nowhere else: a // registry volume would otherwise leave each server pinned to one of them in
// lost registry volume would otherwise leave each server pinned to one of them // ImagePullBackOff until someone rebuilds it. The installer pushes felis/ and
// in ImagePullBackOff until someone rebuilds it. // mirror/ again on any host, but under the same tags at new digests (a build
// is not reproducible, an upstream tag moves on), so from those only the
// revisions a server or a whitelist entry pins by digest are copied, without
// their tags: the tags belong to whatever the installer pushed last.
// //
// The bucket holds each blob and manifest once, by digest, encrypted like // The bucket holds each blob and manifest once, by digest, encrypted like
// everything else, plus an index that says which repository holds which // everything else, plus an index that says which repository holds which
@@ -234,19 +237,28 @@ func (s *Syncer) syncImages(ctx context.Context, res *Result, fail func(string,
return return
} }
sort.Strings(repos) sort.Strings(repos)
listed := map[string]bool{} pins, pinsKnown := map[string][]string{}, true
if s.ImagePins != nil {
if pins, err = s.ImagePins(ctx); err != nil {
fail("list the images servers and the whitelist pin: %v", err)
pinsKnown, c.failed = false, true
}
}
for _, repo := range repos { for _, repo := range repos {
if reservedRepo(repo) { if reservedRepo(repo) && !pinsKnown {
c.carry(repo)
continue
}
if reservedRepo(repo) && len(pins[repo]) == 0 {
continue continue
} }
listed[repo] = true
if ctx.Err() != nil { if ctx.Err() != nil {
c.fail("stopped before %s: %v", repo, ctx.Err()) c.fail("stopped before %s: %v", repo, ctx.Err())
c.failed = true c.failed = true
c.carry(repo) c.carry(repo)
continue continue
} }
c.repo(ctx, repo) c.repo(ctx, repo, pins[repo])
} }
stamp := "" stamp := ""
@@ -277,9 +289,10 @@ func (s *Syncer) syncImages(ctx context.Context, res *Result, fail func(string,
} }
} }
// repo copies one repository into c.next. A repository that cannot be read in // repo copies one repository into c.next; of a reserved one, only the pinned
// full keeps the entry the previous index had for it. // revisions and none of its tags. A repository that cannot be read in full
func (c *imageCopy) repo(ctx context.Context, repo string) { // keeps the entry the previous index had for it.
func (c *imageCopy) repo(ctx context.Context, repo string, pinned []string) {
digests, tags, err := c.s.Images.Revisions(ctx, repo) digests, tags, err := c.s.Images.Revisions(ctx, repo)
if err != nil { if err != nil {
c.fail("list the images of %s: %v", repo, err) c.fail("list the images of %s: %v", repo, err)
@@ -287,6 +300,10 @@ func (c *imageCopy) repo(ctx context.Context, repo string) {
c.carry(repo) c.carry(repo)
return return
} }
if reservedRepo(repo) {
digests = slices.DeleteFunc(digests, func(d string) bool { return !slices.Contains(pinned, d) })
tags = nil
}
entry := ImageRepo{Tags: map[string]string{}} entry := ImageRepo{Tags: map[string]string{}}
short := false short := false
for _, d := range digests { for _, d := range digests {
@@ -732,6 +749,9 @@ func FetchImages(ctx context.Context, b Bucket, key []byte, x *ImageIndex, t Ima
pushedBlobs := map[string]bool{} pushedBlobs := map[string]bool{}
for _, repo := range slices.Sorted(maps.Keys(x.Repositories)) { for _, repo := range slices.Sorted(maps.Keys(x.Repositories)) {
entry := x.Repositories[repo] entry := x.Repositories[repo]
if reservedRepo(repo) {
entry.Tags = nil // the installer's tags stay where it put them
}
done := map[string]bool{} done := map[string]bool{}
var push func(d string) error var push func(d string) error
push = func(d string) error { push = func(d string) error {
+62
View File
@@ -280,6 +280,68 @@ func TestSyncImagesCopiesUserImages(t *testing.T) {
} }
} }
// TestSyncImagesCopiesPinnedPlatformRevisions: of felis/ and mirror/ only the
// revisions a pin names are copied, without tags; a restore puts them back by
// digest and leaves the tags the installer pushed on the new host alone. When
// the pins cannot be read, the previous copy of those revisions is kept and
// the run fails.
func TestSyncImagesCopiesPinnedPlatformRevisions(t *testing.T) {
reg := newFakeRegistry()
old := reg.image("felis/paper", "demo", layer(4000))
current := reg.image("felis/paper", "demo", layer(4000))
reg.image("mirror/trivy", "1", layer(2000))
user := reg.image("user/a", "v1", layer(1000))
s, b, clock := newImageSyncer(t, reg)
s.ImagePins = func(context.Context) (map[string][]string, error) {
return map[string][]string{"felis/paper": {old}, "user/a": {user}}, nil
}
res, err := s.Run(context.Background())
if err != nil {
t.Fatalf("Run: %v", err)
}
x, err := LoadImageIndex(context.Background(), b, s.Key, res.ImageIndex)
if err != nil {
t.Fatal(err)
}
paper, ok := x.Repositories["felis/paper"]
if !ok || !slices.Equal(paper.Manifests, []string{old}) || len(paper.Tags) != 0 {
t.Fatalf("felis/paper = %+v, want only the pinned %s and no tags", paper, old)
}
if _, ok := x.Repositories["mirror/trivy"]; ok {
t.Fatal("unpinned mirror/trivy was copied")
}
if got := x.Repositories["user/a"]; got.Tags["v1"] != user {
t.Fatalf("user/a = %+v", got)
}
fresh := newFakeRegistry()
rebuilt := fresh.image("felis/paper", "demo", layer(4000))
if _, err := FetchImages(context.Background(), b, s.Key, x, fresh, nil); err != nil {
t.Fatalf("FetchImages: %v", err)
}
if got := fresh.repos["felis/paper"]; got.tags["demo"] != rebuilt || !slices.Contains(got.digests, old) {
t.Fatalf("restored felis/paper: tags %v digests %v, want demo=%s and %s present", got.tags, got.digests, rebuilt, old)
}
if slices.Contains(fresh.repos["felis/paper"].digests, current) {
t.Fatal("the unpinned revision was restored")
}
*clock = clock.Add(time.Hour)
s.ImagePins = func(context.Context) (map[string][]string, error) { return nil, errors.New("cluster down") }
res, err = s.Run(context.Background())
if err == nil {
t.Fatal("Run succeeded without the pins")
}
x, err = LoadImageIndex(context.Background(), b, s.Key, res.ImageIndex)
if err != nil {
t.Fatal(err)
}
if got := x.Repositories["felis/paper"]; !slices.Equal(got.Manifests, []string{old}) {
t.Fatalf("without the pins felis/paper = %+v, want the previous copy kept", got)
}
}
// TestSyncImagesRejectsCorruptBlob: bytes that do not hash to the digest never // 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 // become that digest's object, the image stays out of the index, and the run
// fails so the next one tries again. // fails so the next one tries again.
+4
View File
@@ -95,6 +95,10 @@ type Syncer struct {
// Images is the platform registry whose user images are copied (images.go); // Images is the platform registry whose user images are copied (images.go);
// nil copies none. // nil copies none.
Images ImageSource Images ImageSource
// ImagePins lists, per repository, the digests a server's spec or a
// whitelist entry pins. Under felis/ and mirror/ only those revisions are
// copied (images.go); nil copies none there.
ImagePins func(ctx context.Context) (map[string][]string, error)
// UploadsDir is the host directory of the uploads volume, whose submission // UploadsDir is the host directory of the uploads volume, whose submission
// contexts are copied (uploads.go); empty copies none. // contexts are copied (uploads.go); empty copies none.
UploadsDir string UploadsDir string
+3 -3
View File
@@ -172,7 +172,7 @@ func (p *Pruner) log() *slog.Logger {
func Plan(indexes map[string]*registrygate.Index, refs []string, host string, now time.Time, grace time.Duration, keepTagged int) []Target { func Plan(indexes map[string]*registrygate.Index, refs []string, host string, now time.Time, grace time.Duration, keepTagged int) []Target {
keep := map[Target]bool{} keep := map[Target]bool{}
for _, ref := range refs { for _, ref := range refs {
repo, tag, digest, ok := parseRef(ref, host) repo, tag, digest, ok := ParseRef(ref, host)
if !ok { if !ok {
continue continue
} }
@@ -255,9 +255,9 @@ func reserved(repo string) bool {
return false return false
} }
// parseRef splits host/repo[:tag][@digest] for refs under host. A ref with // ParseRef splits host/repo[:tag][@digest] for refs under host. A ref with
// neither tag nor digest means :latest, as it does for every image client. // neither tag nor digest means :latest, as it does for every image client.
func parseRef(ref, host string) (repo, tag, digest string, ok bool) { func ParseRef(ref, host string) (repo, tag, digest string, ok bool) {
rest, ok := strings.CutPrefix(strings.TrimSpace(ref), host+"/") rest, ok := strings.CutPrefix(strings.TrimSpace(ref), host+"/")
if !ok || rest == "" { if !ok || rest == "" {
return "", "", "", false return "", "", "", false
+2 -2
View File
@@ -124,9 +124,9 @@ func TestParseRef(t *testing.T) {
{"other:5000/felis/paper:demo", "", "", "", false}, {"other:5000/felis/paper:demo", "", "", "", false},
{host + "/", "", "", "", false}, {host + "/", "", "", "", false},
} { } {
repo, tag, digest, ok := parseRef(c.ref, host) repo, tag, digest, ok := ParseRef(c.ref, host)
if repo != c.repo || tag != c.tag || digest != c.digest || ok != c.ok { if repo != c.repo || tag != c.tag || digest != c.digest || ok != c.ok {
t.Errorf("parseRef(%q) = %q %q %q %v, want %q %q %q %v", c.ref, repo, tag, digest, ok, c.repo, c.tag, c.digest, c.ok) t.Errorf("ParseRef(%q) = %q %q %q %v, want %q %q %q %v", c.ref, repo, tag, digest, ok, c.repo, c.tag, c.digest, c.ok)
} }
} }
} }