fix(offsite): 服务器或白名单按摘要钉住的 felis/ 与 mirror/ 版本也进异地副本,恢复只按摘要推回不动安装器的 tag
This commit is contained in:
8 files changed
+158
-20
No files matched your search
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user