Skip to content

Commit ed91851

Browse files
wu-shengclaude
andcommitted
Leave image referrers out of kind image archives, and test a containerd 1.7 node
#150 saves images for the kind nodes' platform with Docker's containerd image store and loads them with `kind load image-archive`. Docker saves the referrers of the image beside it in the archive's top-level index - its attestations, and the Cosign / Sigstore signatures Docker 29 pulls for them - each marked with the `io.containerd.manifest.subject` annotation. kind imports the archive with `ctr images import --all-platforms --digests`. A containerd 1.7 node (node images built by kind v0.20 to v0.26) creates an image for every top-level descriptor and unpacks it, and a referrer has no filesystem layers, so the import fails with "mismatched image rootfs and manifest layers" - apache/skywalking-horizon-ui's istio case (kind v0.25's kindest/node:v1.28.15) failed so on the SkyWalking Rover image. containerd 2, kind's default node, only unpacks manifests that have filesystem layers, so the kind e2e passed. The referrers are now removed from the archive's index.json before kind loads it; a descriptor that names an image is kept even when marked. The kept descriptors and every other entry are copied as saved. Both archive files are closed before the rewritten one replaces the original, which Windows requires. A new kind e2e case runs a containerd 1.7 node, imports the SkyWalking OAP and Rover images that failed in real use, as pulled by the run itself, and checks both reached the node. The Build workflow's Linux jobs run on ubuntu-26.04 instead of ubuntu-latest: ubuntu-latest jobs sat queued with no runner while the ubuntu-24.04 / ubuntu-26.04 jobs started within seconds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent c45db27 commit ed91851

11 files changed

Lines changed: 548 additions & 13 deletions

File tree

‎.github/workflows/build.yaml‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ on:
2525
jobs:
2626
lint-and-test:
2727
name: Lint and Test
28-
runs-on: ubuntu-latest
28+
runs-on: ubuntu-26.04
2929
steps:
3030
- name: Set up Go
3131
uses: actions/setup-go@v5
@@ -49,7 +49,7 @@ jobs:
4949
strategy:
5050
matrix:
5151
include:
52-
- os: ubuntu-latest
52+
- os: ubuntu-26.04
5353
target: linux
5454
- os: windows-latest
5555
target: windows
@@ -69,16 +69,16 @@ jobs:
6969
- name: Build
7070
run: make ${{ matrix.target }}
7171
- name: Build Docker Image
72-
if: matrix.os == 'ubuntu-latest'
72+
if: matrix.os == 'ubuntu-26.04'
7373
run: make docker
7474
build:
7575
name: Build
76-
runs-on: ubuntu-latest
76+
runs-on: ubuntu-26.04
7777
needs:
7878
- lint-and-test
7979
- build-platform
8080
steps:
8181
- name: Check out code into the Go module directory
8282
uses: actions/checkout@v4
8383
- name: Result
84-
run: echo "Lint and Test, Build (ubuntu-latest), Build (windows-latest), Build (macos-latest) completed successfully!"
84+
run: echo "Lint and Test, Build (ubuntu-26.04), Build (windows-latest), Build (macos-latest) completed successfully!"

‎.github/workflows/e2e-test.yaml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,5 +66,8 @@ jobs:
6666
- name: Run KinD E2E Test
6767
run: make e2e-test-kind
6868

69+
- name: Run KinD containerd 1.7 E2E Test
70+
run: make e2e-test-kind-containerd-1.7
71+
6972
- name: Run KinD Collect-on-Failure E2E Test
7073
run: make e2e-test-kind-collect

‎CHANGES.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ Unreleased
99
* Upgrade Go dependencies affected by known security advisories: `golang.org/x/crypto`, `github.com/containerd/containerd/v2`, `google.golang.org/grpc`, `github.com/moby/go-archive`, `github.com/moby/spdystream`, `github.com/moby/buildkit`, and the OpenTelemetry SDK and OTLP trace exporters.
1010
* Upgrade `testcontainers-go` to v0.44.0 so Docker Compose no longer imports a BuildKit package removed in the patched release.
1111
* Use `github.com/moby/moby/client` for Docker Engine API calls. `github.com/docker/docker` has no patched release for its advisories.
12-
* Fix `setup.kind.import-images` failing with `ctr: content digest ... not found` when Docker uses the containerd image store (the default from Docker 29, e.g. on `ubuntu-26.04` runners). Images are now saved for the kind nodes' platform and loaded with `kind load image-archive`; Docker with the classic image store keeps `kind load docker-image`.
12+
* Fix `setup.kind.import-images` failing when Docker uses the containerd image store (the default from Docker 29, e.g. on `ubuntu-26.04` runners). Images are now saved for the kind nodes' platform, so a multi-platform image no longer fails with `ctr: content digest ... not found`, and the referrers saved beside an image, such as its attestations and their signatures, are removed from the archive's top-level index: a containerd 1.7 node (node images built by kind v0.20 to v0.26) otherwise imports each one as an image and fails with `ctr: mismatched image rootfs and manifest layers`. The archive is loaded with `kind load image-archive`; Docker with the classic image store keeps `kind load docker-image`.
1313

1414
1.4.0
1515
------------------

‎Makefile‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,12 @@ e2e-test-kind:
124124
printf 'FROM busybox:1.37.0\n' | docker build -t skywalking-infra-e2e/kind-local:test -
125125
./bin/$(GOOS)/$(PROJECT) run -c ./test/e2e/kind/e2e.yaml
126126

127+
.PHONY: e2e-test-kind-containerd-1.7
128+
# Run E2E test with KinD on a containerd 1.7 node (kind v0.25's kindest/node:v1.28.15)
129+
e2e-test-kind-containerd-1.7:
130+
$(MAKE) $(GOOS)
131+
./bin/$(GOOS)/$(PROJECT) run -c ./test/e2e/kind/containerd-1.7/e2e.yaml
132+
127133
.PHONY: e2e-test-kind-collect
128134
# Run E2E test with KinD to verify collect-on-failure functionality
129135
# This test intentionally fails verification to trigger file collection.

‎go.mod‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ require (
66
github.com/containerd/platforms v1.0.0-rc.4
77
github.com/google/go-cmp v0.7.0
88
github.com/moby/moby/client v0.5.0
9+
github.com/opencontainers/go-digest v1.0.0
910
github.com/opencontainers/image-spec v1.1.1
1011
github.com/pterm/pterm v0.12.45
1112
github.com/sirupsen/logrus v1.9.4
@@ -139,7 +140,6 @@ require (
139140
github.com/morikuni/aec v1.1.0 // indirect
140141
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
141142
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect
142-
github.com/opencontainers/go-digest v1.0.0 // indirect
143143
github.com/pelletier/go-toml v1.9.5 // indirect
144144
github.com/pelletier/go-toml/v2 v2.4.1 // indirect
145145
github.com/peterbourgon/diskv v2.0.1+incompatible // indirect

‎internal/components/setup/kind_image.go‎

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,11 @@ const (
4545
// loadDockerImage runs `kind load docker-image`: kind saves the whole image
4646
// and the nodes import it with `ctr images import --all-platforms`.
4747
loadDockerImage imageLoadMode = iota
48-
// loadPlatformArchive saves only the platform of the kind nodes, then runs
49-
// `kind load image-archive`. With docker's containerd image store, a pulled
50-
// multi-platform image only holds the content of the platform it was pulled
51-
// for, so importing all of its platforms fails with
48+
// loadPlatformArchive saves only the platform of the kind nodes, leaves the
49+
// referrers of the image, such as attestations, out of the archive, then
50+
// runs `kind load image-archive`. With docker's containerd image store, a
51+
// pulled multi-platform image only holds the content of the platform it was
52+
// pulled for, so importing all of its platforms fails with
5253
// "content digest ... not found" (kubernetes-sigs/kind#3795).
5354
loadPlatformArchive
5455
)
@@ -145,8 +146,8 @@ func importImages(ctx context.Context, images []string, clusterName string) erro
145146
return nil
146147
}
147148

148-
// loadImageArchive saves the platform of the image to a temporary archive and
149-
// loads it with `kind load image-archive`.
149+
// loadImageArchive saves the platform of the image to a temporary archive,
150+
// without its referrers, and loads it with `kind load image-archive`.
150151
func loadImageArchive(ctx context.Context, cli *docker.Client, image string, platform ocispec.Platform, clusterName string) error {
151152
archive, err := os.CreateTemp(util.WorkDir, "kind-image-*.tar")
152153
if err != nil {
@@ -165,6 +166,13 @@ func loadImageArchive(ctx context.Context, cli *docker.Client, image string, pla
165166
if err := archive.Close(); err != nil {
166167
return fmt.Errorf("write image archive error: %w", err)
167168
}
169+
dropped, err := dropReferrers(archive.Name())
170+
if err != nil {
171+
return err
172+
}
173+
if dropped > 0 {
174+
logger.Log.Infof("left %d referrer(s) of %s, such as attestations, out of the archive", dropped, image)
175+
}
168176
return runKindLoad("load", "image-archive", archive.Name(), "--name", clusterName)
169177
}
170178

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
// Licensed to Apache Software Foundation (ASF) under one or more contributor
2+
// license agreements. See the NOTICE file distributed with
3+
// this work for additional information regarding copyright
4+
// ownership. Apache Software Foundation (ASF) licenses this file to you under
5+
// the Apache License, Version 2.0 (the "License"); you may
6+
// not use this file except in compliance with the License.
7+
// You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing,
12+
// software distributed under the License is distributed on an
13+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
// KIND, either express or implied. See the License for the
15+
// specific language governing permissions and limitations
16+
// under the License.
17+
//
18+
19+
package setup
20+
21+
import (
22+
"archive/tar"
23+
"bytes"
24+
"encoding/json"
25+
"errors"
26+
"fmt"
27+
"io"
28+
"os"
29+
"path/filepath"
30+
"strings"
31+
32+
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
33+
)
34+
35+
const (
36+
ociIndexFile = "index.json"
37+
// manifestSubjectAnnotation marks a top-level descriptor of an image archive
38+
// as a referrer of another manifest, such as an attestation or a signature.
39+
manifestSubjectAnnotation = "io.containerd.manifest.subject"
40+
imageNameAnnotation = "io.containerd.image.name"
41+
)
42+
43+
// dropReferrers removes the referrers from the top-level index of an image
44+
// archive saved by docker, and leaves the archive untouched when it has none.
45+
//
46+
// docker's containerd image store saves the referrers of an image, such as its
47+
// attestations and their signatures, beside it in the top-level index, marked
48+
// with manifestSubjectAnnotation. kind imports the archive with `ctr images
49+
// import --all-platforms --digests`: containerd 1.7 (node images built by kind
50+
// v0.20 to v0.26) creates an image for every top-level descriptor and unpacks
51+
// it, and as a referrer has no filesystem layers, the import fails with
52+
// "mismatched image rootfs and manifest layers"; containerd 2 only unpacks
53+
// manifests that have filesystem layers. A descriptor that names an image is
54+
// kept even when marked. The blobs of the referrers stay in the archive,
55+
// unreferenced.
56+
func dropReferrers(path string) (int, error) {
57+
data, err := readArchiveFile(path, ociIndexFile)
58+
if err != nil || data == nil {
59+
return 0, err
60+
}
61+
var index map[string]json.RawMessage
62+
if err := json.Unmarshal(data, &index); err != nil {
63+
return 0, fmt.Errorf("parse %s of the image archive error: %w", ociIndexFile, err)
64+
}
65+
var manifests []json.RawMessage
66+
if raw, ok := index["manifests"]; ok {
67+
if err := json.Unmarshal(raw, &manifests); err != nil {
68+
return 0, fmt.Errorf("parse %s of the image archive error: %w", ociIndexFile, err)
69+
}
70+
}
71+
72+
// The kept descriptors are copied as saved, so no field of theirs is lost.
73+
kept := make([]json.RawMessage, 0, len(manifests))
74+
for _, m := range manifests {
75+
var desc struct {
76+
Annotations map[string]string `json:"annotations"`
77+
}
78+
if err := json.Unmarshal(m, &desc); err != nil {
79+
return 0, fmt.Errorf("parse %s of the image archive error: %w", ociIndexFile, err)
80+
}
81+
_, referrer := desc.Annotations[manifestSubjectAnnotation]
82+
_, named := desc.Annotations[imageNameAnnotation]
83+
_, refNamed := desc.Annotations[ocispec.AnnotationRefName]
84+
if !referrer || named || refNamed {
85+
kept = append(kept, m)
86+
}
87+
}
88+
dropped := len(manifests) - len(kept)
89+
if dropped == 0 {
90+
return 0, nil
91+
}
92+
93+
if index["manifests"], err = json.Marshal(kept); err != nil {
94+
return 0, err
95+
}
96+
newIndex, err := json.Marshal(index)
97+
if err != nil {
98+
return 0, err
99+
}
100+
return dropped, rewriteArchiveFile(path, ociIndexFile, newIndex)
101+
}
102+
103+
// readArchiveFile returns the content of a file in a tar archive, or nil when
104+
// the archive does not have it.
105+
func readArchiveFile(path, name string) ([]byte, error) {
106+
f, err := os.Open(path)
107+
if err != nil {
108+
return nil, fmt.Errorf("open image archive error: %w", err)
109+
}
110+
defer func() {
111+
_ = f.Close()
112+
}()
113+
tr := tar.NewReader(f)
114+
for {
115+
hdr, err := tr.Next()
116+
if errors.Is(err, io.EOF) {
117+
return nil, nil
118+
}
119+
if err != nil {
120+
return nil, fmt.Errorf("read image archive error: %w", err)
121+
}
122+
if strings.TrimPrefix(hdr.Name, "./") == name {
123+
data, err := io.ReadAll(tr)
124+
if err != nil {
125+
return nil, fmt.Errorf("read %s of the image archive error: %w", name, err)
126+
}
127+
return data, nil
128+
}
129+
}
130+
}
131+
132+
// rewriteArchiveFile replaces the content of one file of a tar archive, copying
133+
// every other entry as it is.
134+
func rewriteArchiveFile(path, name string, content []byte) error {
135+
dst, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path)+".*")
136+
if err != nil {
137+
return fmt.Errorf("create image archive error: %w", err)
138+
}
139+
err = copyArchive(path, dst, name, content)
140+
if closeErr := dst.Close(); err == nil && closeErr != nil {
141+
err = fmt.Errorf("write image archive error: %w", closeErr)
142+
}
143+
// Both archives are closed by now: Windows cannot replace an open file.
144+
if err == nil {
145+
err = os.Rename(dst.Name(), path)
146+
}
147+
if err != nil {
148+
_ = os.Remove(dst.Name())
149+
}
150+
return err
151+
}
152+
153+
func copyArchive(path string, dst io.Writer, name string, content []byte) error {
154+
src, err := os.Open(path)
155+
if err != nil {
156+
return fmt.Errorf("open image archive error: %w", err)
157+
}
158+
defer func() {
159+
_ = src.Close()
160+
}()
161+
tr := tar.NewReader(src)
162+
tw := tar.NewWriter(dst)
163+
for {
164+
hdr, err := tr.Next()
165+
if errors.Is(err, io.EOF) {
166+
break
167+
}
168+
if err != nil {
169+
return fmt.Errorf("read image archive error: %w", err)
170+
}
171+
var body io.Reader = tr
172+
if strings.TrimPrefix(hdr.Name, "./") == name {
173+
hdr.Size = int64(len(content))
174+
body = bytes.NewReader(content)
175+
}
176+
if err := tw.WriteHeader(hdr); err != nil {
177+
return fmt.Errorf("write image archive error: %w", err)
178+
}
179+
if _, err := io.Copy(tw, body); err != nil {
180+
return fmt.Errorf("write image archive error: %w", err)
181+
}
182+
}
183+
if err := tw.Close(); err != nil {
184+
return fmt.Errorf("write image archive error: %w", err)
185+
}
186+
return nil
187+
}

0 commit comments

Comments
 (0)