feat(imagepush): 写出按架构的发布镜像包并支持按名推送
This commit is contained in:
8 files changed
+1135
-3
No files matched your search
@@ -0,0 +1,109 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user