Unverified Commit 7beb4319 authored by Lemon-miaow's avatar Lemon-miaow
Browse files

feat(imagepush): 写出按架构的发布镜像包并支持按名推送

parent c24396d5
Loading
Loading
Loading
Loading
+109 −0
Changes for cmd/felis/imagebundle.go: 109 added lines, 0 removed lines.
Original line number Diff line number Diff line
package main

import (
	"bufio"
	"context"
	"errors"
	"flag"
	"fmt"
	"io"
	"os"
	"os/signal"
	"strings"
	"syscall"

	"felis.lolicon.best/internal/imagepush"
)

// cmdImageBundle writes a release's image bundle: one OCI layout tar holding every
// image an install runs, for one platform, plus its listing (one "role name
// manifest-digest config-digest" line per image). deploy/build-release-artifacts.sh
// runs it in CI; deploy/bootstrap.sh imports the tar into k3s's containerd and
// pushes it into the platform registry with push-image --image.
//
//	--layout role=name=path   an image buildx wrote with --output type=oci
//	--pull role=ref           a digest-pinned public image, named repository@digest
//
// The tar and the listing are written beside their final paths and renamed into
// place, so a failed run leaves neither behind.
func cmdImageBundle(args []string, _, stderr io.Writer) int {
	fs := flag.NewFlagSet("image-bundle", flag.ContinueOnError)
	fs.SetOutput(stderr)
	platform := fs.String("platform", "", "os/arch the bundle is for, e.g. linux/arm64")
	out := fs.String("out", "", "path of the bundle tar to write")
	list := fs.String("list", "", "path of the listing to write")
	var images []imagepush.BundleImage
	fs.Func("layout", "role=name=path of an OCI layout tar (repeatable)", func(v string) error {
		role, rest, ok := strings.Cut(v, "=")
		name, path, ok2 := strings.Cut(rest, "=")
		if !ok || !ok2 || role == "" || name == "" || path == "" {
			return fmt.Errorf("want role=name=path, got %q", v)
		}
		images = append(images, imagepush.BundleImage{Role: role, Name: name, Layout: path})
		return nil
	})
	fs.Func("pull", "role=ref of a digest-pinned public image (repeatable)", func(v string) error {
		role, ref, ok := strings.Cut(v, "=")
		if !ok || role == "" || !strings.Contains(ref, "@sha256:") {
			return fmt.Errorf("want role=ref with ref pinned by digest, got %q", v)
		}
		images = append(images, imagepush.BundleImage{Role: role, Name: imagepush.PinnedName(ref), Source: ref})
		return nil
	})
	if err := fs.Parse(args); err != nil {
		if errors.Is(err, flag.ErrHelp) {
			return 0
		}
		return 2
	}
	if *platform == "" || *out == "" || *list == "" || len(images) == 0 {
		fmt.Fprintln(stderr, "felis image-bundle: --platform, --out, --list and at least one --layout or --pull are required")
		return 2
	}
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()
	if err := writeImageBundle(ctx, &imagepush.Source{Platform: *platform}, images, *out, *list); err != nil {
		fmt.Fprintf(stderr, "felis image-bundle: %v\n", err)
		return 1
	}
	return 0
}

func writeImageBundle(ctx context.Context, s *imagepush.Source, images []imagepush.BundleImage, out, list string) (err error) {
	tmpOut, tmpList := out+".tmp", list+".tmp"
	defer func() {
		if err != nil {
			os.Remove(tmpOut)
			os.Remove(tmpList)
		}
	}()
	f, err := os.Create(tmpOut)
	if err != nil {
		return err
	}
	w := bufio.NewWriterSize(f, 1<<20)
	entries, err := imagepush.WriteBundle(ctx, s, images, w)
	if err == nil {
		err = w.Flush()
	}
	if err == nil {
		err = f.Sync()
	}
	if cerr := f.Close(); err == nil {
		err = cerr
	}
	if err != nil {
		return err
	}
	var b strings.Builder
	for _, e := range entries {
		fmt.Fprintf(&b, "%s %s %s %s\n", e.Role, e.Name, e.Digest, e.Config)
	}
	if err := os.WriteFile(tmpList, []byte(b.String()), 0o644); err != nil {
		return err
	}
	if err := os.Rename(tmpOut, out); err != nil {
		return err
	}
	return os.Rename(tmpList, list)
}
+131 −0
Changes for cmd/felis/imagebundle_test.go: 131 added lines, 0 removed lines.
Original line number Diff line number Diff line
package main

import (
	"archive/tar"
	"bytes"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"os"
	"path/filepath"
	"strings"
	"testing"
)

// writeTestLayout writes an OCI layout tar holding one linux/arch image with one
// layer, the way buildx --output type=oci leaves a single-platform build, and
// returns its path with the manifest and config digests.
func writeTestLayout(t *testing.T, dir, arch, layer string) (path, manifestDigest, configDigest string) {
	t.Helper()
	blobs := map[string][]byte{}
	add := func(b []byte) (string, int) {
		sum := sha256.Sum256(b)
		d := "sha256:" + hex.EncodeToString(sum[:])
		blobs[d] = b
		return d, len(b)
	}
	cfgDigest, cfgSize := add([]byte(`{"architecture":"` + arch + `","os":"linux","rootfs":{"type":"layers"}}`))
	layerDigest, layerSize := add([]byte(layer))
	manifest := fmt.Sprintf(`{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json",`+
		`"config":{"mediaType":"application/vnd.oci.image.config.v1+json","digest":%q,"size":%d},`+
		`"layers":[{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":%q,"size":%d}]}`,
		cfgDigest, cfgSize, layerDigest, layerSize)
	mDigest, mSize := add([]byte(manifest))
	index, _ := json.Marshal(map[string]any{
		"schemaVersion": 2,
		"manifests": []map[string]any{{
			"mediaType": "application/vnd.oci.image.manifest.v1+json", "digest": mDigest, "size": mSize,
		}},
	})
	var buf bytes.Buffer
	tw := tar.NewWriter(&buf)
	put := func(name string, b []byte) {
		tw.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: int64(len(b)), Typeflag: tar.TypeReg})
		tw.Write(b)
	}
	put("oci-layout", []byte(`{"imageLayoutVersion":"1.0.0"}`))
	put("index.json", index)
	for d, b := range blobs {
		put("blobs/sha256/"+strings.TrimPrefix(d, "sha256:"), b)
	}
	tw.Close()
	path = filepath.Join(dir, arch+"-"+layer+".tar")
	if err := os.WriteFile(path, buf.Bytes(), 0o644); err != nil {
		t.Fatal(err)
	}
	return path, mDigest, cfgDigest
}

func TestImageBundleWritesTheListingTheInstallerReads(t *testing.T) {
	dir := t.TempDir()
	limbo, limboDigest, limboConfig := writeTestLayout(t, dir, "arm64", "limbo")
	lobby, lobbyDigest, lobbyConfig := writeTestLayout(t, dir, "arm64", "lobby")
	out, list := filepath.Join(dir, "images.tar"), filepath.Join(dir, "images.txt")
	var stderr bytes.Buffer
	code := cmdImageBundle([]string{"--platform", "linux/arm64", "--out", out, "--list", list,
		"--layout", "limbo=registry.felis.svc:5000/felis/limbo:demo=" + limbo,
		"--layout", "lobby=registry.felis.svc:5000/felis/lobby:demo=" + lobby,
	}, nil, &stderr)
	if code != 0 {
		t.Fatalf("image-bundle = %d: %s", code, stderr.String())
	}
	// deploy/bootstrap.sh reads this with `read -r role name digest config`.
	got, _ := os.ReadFile(list)
	want := "limbo registry.felis.svc:5000/felis/limbo:demo " + limboDigest + " " + limboConfig + "\n" +
		"lobby registry.felis.svc:5000/felis/lobby:demo " + lobbyDigest + " " + lobbyConfig + "\n"
	if string(got) != want {
		t.Errorf("listing:\n%s\nwant:\n%s", got, want)
	}
	if fi, err := os.Stat(out); err != nil || fi.Size() == 0 {
		t.Errorf("bundle: %v", err)
	}
	if leftovers, _ := filepath.Glob(filepath.Join(dir, "*.tmp")); len(leftovers) != 0 {
		t.Errorf("left behind %v", leftovers)
	}
}

func TestImageBundleLeavesNothingWhenAnImageIsRefused(t *testing.T) {
	dir := t.TempDir()
	amd, _, _ := writeTestLayout(t, dir, "amd64", "limbo")
	out, list := filepath.Join(dir, "images.tar"), filepath.Join(dir, "images.txt")
	// The pair an earlier run wrote stays as it was: a listing beside a bundle it
	// does not describe would have the installer look for images that are not there.
	os.WriteFile(out, []byte("old bundle"), 0o644)
	os.WriteFile(list, []byte("old listing\n"), 0o644)
	var stderr bytes.Buffer
	code := cmdImageBundle([]string{"--platform", "linux/arm64", "--out", out, "--list", list,
		"--layout", "limbo=registry.felis.svc:5000/felis/limbo:demo=" + amd}, nil, &stderr)
	if code != 1 || !strings.Contains(stderr.String(), "is a linux/amd64 image") {
		t.Fatalf("image-bundle = %d: %s", code, stderr.String())
	}
	if got, _ := os.ReadFile(out); string(got) != "old bundle" {
		t.Errorf("bundle was replaced with %d bytes", len(got))
	}
	if got, _ := os.ReadFile(list); string(got) != "old listing\n" {
		t.Errorf("listing was replaced with %q", got)
	}
	if leftovers, _ := filepath.Glob(filepath.Join(dir, "*.tmp")); len(leftovers) != 0 {
		t.Errorf("left behind %v", leftovers)
	}
}

func TestPushImageReadsABundleByImageName(t *testing.T) {
	dir := t.TempDir()
	limbo, _, _ := writeTestLayout(t, dir, "arm64", "limbo")
	out, list := filepath.Join(dir, "images.tar"), filepath.Join(dir, "images.txt")
	if code := cmdImageBundle([]string{"--platform", "linux/arm64", "--out", out, "--list", list,
		"--layout", "limbo=registry.felis.svc:5000/felis/limbo:demo=" + limbo}, nil, &bytes.Buffer{}); code != 0 {
		t.Fatal("image-bundle failed")
	}
	t.Setenv("FELIS_REGISTRY_USERNAME", "platform")
	t.Setenv("FELIS_REGISTRY_PASSWORD", "x")
	// The name is looked up in the bundle's index before the registry is contacted,
	// so a name the bundle lacks fails here with what it does hold.
	var stderr bytes.Buffer
	code := cmdPushImage([]string{"--tar", out, "--image", "registry.felis.svc:5000/felis/lobby:demo",
		"--ref", "127.0.0.1:1/felis/lobby:demo"}, &bytes.Buffer{}, &stderr)
	if code != 1 || !strings.Contains(stderr.String(), "holds no image named registry.felis.svc:5000/felis/lobby:demo (it holds: registry.felis.svc:5000/felis/limbo:demo") {
		t.Fatalf("push-image = %d: %s", code, stderr.String())
	}
}
+9 −2
Changes for cmd/felis/registrygate.go: 9 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -131,7 +131,8 @@ func loopbackAddr(addr string) bool {
func cmdPushImage(args []string, stdout, stderr io.Writer) int {
	fs := flag.NewFlagSet("push-image", flag.ContinueOnError)
	fs.SetOutput(stderr)
	tarPath := fs.String("tar", "", "image tarball Kaniko wrote with --tar-path")
	tarPath := fs.String("tar", "", "image tarball Kaniko wrote with --tar-path, or with --image an OCI layout tar")
	image := fs.String("image", "", "push the image this name (io.containerd.image.name) marks in the OCI layout tar --tar, e.g. a release's image bundle")
	ref := fs.String("ref", "", "host/repository:tag to publish it as")
	scheme := fs.String("scheme", "http", "registry scheme: http for the in-cluster registry, https otherwise")
	if err := fs.Parse(args); err != nil {
@@ -154,7 +155,13 @@ func cmdPushImage(args []string, stdout, stderr io.Writer) int {
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()
	p := &imagepush.Pusher{Scheme: *scheme, Username: user, Password: pass, Log: stderr}
	digest, err := p.Push(ctx, *tarPath, *ref)
	var digest string
	var err error
	if *image != "" {
		digest, err = p.PushLayout(ctx, *tarPath, *image, *ref)
	} else {
		digest, err = p.Push(ctx, *tarPath, *ref)
	}
	if err != nil {
		fmt.Fprintf(stderr, "felis push-image: %v\n", err)
		return 1
+1 −0
Changes for cmd/felis/run.go: 1 added line, 0 removed lines.
Original line number Diff line number Diff line
@@ -78,6 +78,7 @@ var commands = map[string]func(args []string, stdout, stderr io.Writer) int{
	"init-forwarding":    cmdInitForwarding,
	"init-volume":        cmdInitVolume,
	"pin-images":         cmdPinImages,
	"image-bundle":       cmdImageBundle,
	"version":            cmdVersion,
	"update":             cmdUpdate,
	"watchdog":           cmdWatchdog,
+1 −1
Changes for cmd/felis/run_test.go: 1 added line, 1 removed line.
Original line number Diff line number Diff line
@@ -43,7 +43,7 @@ func TestRunUnknownCommand(t *testing.T) {
// decision rather than an oversight.
var undocumentedCommands = map[string]bool{
	"bootstrap-assets": true, "init-forwarding": true, "init-volume": true,
	"pin-images": true,
	"pin-images": true, "image-bundle": true,
}

// The usage text and the dispatch table must describe the same set of commands.
Loading