add fake-registry-server command to agnhost

* command run the limited subset of OCI API and serve static
images from the registry (which are pre-loaded when building an image)
* add pause version to zeitgeist rule for agnhost
* bump version of agnhost to 2.57
This commit is contained in:
Yevhen Dubovskoi
2025-07-29 09:58:52 +00:00
parent 8085d5c458
commit 3da372354d
10 changed files with 601 additions and 4 deletions

View File

@@ -238,6 +238,8 @@ dependencies:
match: registry.k8s.io\/pause:\d+\.\d+
- path: test/utils/image/manifest.go
match: configs\[Pause\] = Config{list\.GcRegistry, "pause", "\d+\.\d+(.\d+)?"}
- path: test/images/agnhost/fakeregistryserver/images.txt
match: pause\s
- name: "registry.k8s.io/build-image/setcap: dependents"
version: bookworm-v1.0.4

View File

@@ -13,7 +13,22 @@
# limitations under the License.
ARG BASEIMAGE
FROM $BASEIMAGE
ARG GOLANG_VERSION
FROM golang:$GOLANG_VERSION AS preparer
RUN go install github.com/google/go-containerregistry/cmd/crane@latest && \
apt-get update && apt-get install -y jq
COPY fakeregistryserver/prepare_registry.sh /prepare_registry.sh
COPY fakeregistryserver/images.txt /images.txt
RUN chmod +x /prepare_registry.sh
# run the script during the build to create the artifact inside the image
RUN /prepare_registry.sh
FROM $BASEIMAGE AS main
CROSS_BUILD_COPY qemu-QEMUARCH-static /usr/bin/
@@ -38,7 +53,7 @@ RUN tar -xzvf /coredns.tgz && rm -f /coredns.tgz
# PORT 8080 needed by: netexec, nettest, resource-consumer, resource-consumer-controller
# PORT 8081 needed by: netexec
# PORT 9376 needed by: serve-hostname
# PORT 5000 needed by: grpc-health-checking
# PORT 5000 needed by: grpc-health-checking, fake-registry-server
EXPOSE 80 8080 8081 9376 5000
# from netexec
@@ -49,6 +64,7 @@ ADD porter/localhost.crt localhost.crt
ADD porter/localhost.key localhost.key
ADD agnhost agnhost
COPY --from=preparer /registry /var/registry
# needed for the entrypoint-tester related tests. Some of the entrypoint-tester related tests
# overrides this image's entrypoint with agnhost-2 binary, and will verify that the correct

View File

@@ -182,6 +182,23 @@ Usage:
kubectl exec test-agnhost -- /agnhost fake-gitserver
```
### fake-registry-server
Starts a fake OCI registry server that serves static image files. This can be used to test
pulling images from a private (with `--private` flag) or public registry.
Private registry has static credentials `user:password`
Usage:
```console
kubectl exec test-agnhost -- /agnhost fake-registry-server [--private]
```
#### Adding new image to the registry
Adding a new image requires a new version of agnhost. To add new image, add a new line
to `test/images/agnhost/fakeregistryserver/images.txt` in format `<image> <tag> <internal tag>`
### guestbook

View File

@@ -1 +1 @@
2.56
2.57

View File

@@ -28,6 +28,7 @@ import (
"k8s.io/kubernetes/test/images/agnhost/dns"
"k8s.io/kubernetes/test/images/agnhost/entrypoint-tester"
"k8s.io/kubernetes/test/images/agnhost/fakegitserver"
"k8s.io/kubernetes/test/images/agnhost/fakeregistryserver"
grpchealthchecking "k8s.io/kubernetes/test/images/agnhost/grpc-health-checking"
"k8s.io/kubernetes/test/images/agnhost/guestbook"
"k8s.io/kubernetes/test/images/agnhost/inclusterclient"
@@ -68,6 +69,7 @@ func main() {
rootCmd.AddCommand(dns.CmdEtcHosts)
rootCmd.AddCommand(entrypoint.CmdEntrypointTester)
rootCmd.AddCommand(fakegitserver.CmdFakeGitServer)
rootCmd.AddCommand(fakeregistryserver.CmdFakeRegistryServer)
rootCmd.AddCommand(guestbook.CmdGuestbook)
rootCmd.AddCommand(inclusterclient.CmdInClusterClient)
rootCmd.AddCommand(liveness.CmdLiveness)

View File

@@ -0,0 +1 @@
pause 3.10.1 testing

View File

@@ -0,0 +1,88 @@
#!/bin/bash
# Copyright 2025 The Kubernetes Authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
set -o errexit
set -o nounset
set -o pipefail
readonly REGISTRY_URL="registry.k8s.io"
readonly REGISTRY_DIR="/registry"
# This script prepares a directory with container images to be used as a fake registry,
# then creates a tarball of that directory inside the container.
# function to download an image manifest and its blobs to create a fake registry layout.
prepare_image() {
local image_name="$1"
local tag="$2"
local internal_tag="$3"
local image_dir="$REGISTRY_DIR/$image_name"
echo "--- Preparing image: ${image_name}:${tag} as ${image_name}:${internal_tag} ---"
mkdir -p "$image_dir/manifests"
mkdir -p "$image_dir/blobs"
echo "Downloading and filtering manifest list for $image_name:$tag..."
local tmp_manifest_path="$image_dir/manifests/tmp_${internal_tag}"
# download the manifest and pipe it to jq to filter out windows images
crane manifest "$REGISTRY_URL/$image_name:$tag" | jq '.manifests |= map(select(.platform.os != "windows"))' > "$tmp_manifest_path"
echo "Saved manifest list to $tmp_manifest_path"
local manifest_digest
manifest_digest="sha256:$(sha256sum < "$tmp_manifest_path" | awk '{print $1}')"
mv "$tmp_manifest_path" "$image_dir/manifests/$manifest_digest"
echo "Saved manifest list to $image_dir/manifests/$manifest_digest"
# the file named after the tag now contains only the digest, acting as a redirect pointer
echo "$manifest_digest" > "$image_dir/manifests/${internal_tag}"
echo "Created tag file ${internal_tag} pointing to digest $manifest_digest"
echo "Parsing manifest list and downloading individual manifests and blobs..."
jq -r '.manifests[].digest' < "$image_dir/manifests/$manifest_digest" | while read -r individual_manifest_digest; do
echo " Downloading manifest $individual_manifest_digest..."
local individual_manifest_path="$image_dir/manifests/$individual_manifest_digest"
crane manifest "$REGISTRY_URL/$image_name@$individual_manifest_digest" > "$individual_manifest_path"
echo " Saved manifest to $individual_manifest_path"
local config_digest
config_digest=$(jq -r '.config.digest' < "$individual_manifest_path")
echo " Downloading config blob $config_digest..."
crane blob "$REGISTRY_URL/$image_name@$config_digest" > "$image_dir/blobs/$config_digest"
echo " Saved config blob to $image_dir/blobs/$config_digest"
jq -r '.layers[].digest' < "$individual_manifest_path" | while read -r layer_digest; do
echo " Downloading layer blob $layer_digest..."
crane blob "$REGISTRY_URL/$image_name@$layer_digest" > "$image_dir/blobs/$layer_digest"
echo " Saved layer blob to $image_dir/blobs/$layer_digest"
done
done
echo "--- Successfully prepared ${image_name}:${internal_tag} ---"
}
# create the registry directory
mkdir -p "$REGISTRY_DIR"
echo "--> Processing images.txt..."
while read -r image tag internal_tag; do
# skip empty lines or comments
[[ -z "$image" || "$image" == \#* ]] && continue
prepare_image "$image" "$tag" "$internal_tag"
done < /images.txt
echo "--> Done"

View File

@@ -0,0 +1,161 @@
/*
Copyright 2025 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package fakeregistryserver
import (
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strings"
"github.com/spf13/cobra"
)
var (
port int
private bool
registryDir = "/var/registry"
)
const (
privateRegistryUser = "user"
privateRegistryPass = "password"
)
func init() {
CmdFakeRegistryServer.Flags().IntVar(&port, "port", 5000, "Port number.")
CmdFakeRegistryServer.Flags().BoolVar(&private, "private", false, "Enable authentication for the registry.")
}
// CmdFakeRegistryServer is the cobra command for the fake registry server
var CmdFakeRegistryServer = &cobra.Command{
Use: "fake-registry-server",
Short: "Starts a fake registry server for testing",
Long: fmt.Sprintf("Starts a fake registry server that serves static OCI image files from %s folder", registryDir),
Run: main,
}
func main(cmd *cobra.Command, args []string) {
registryMux := NewRegistryServerMux(private)
addr := fmt.Sprintf(":%d", port)
log.Printf("HTTP server starting to listen on %s", addr)
if err := http.ListenAndServe(addr, registryMux); err != nil {
log.Fatalf("Error while starting the HTTP server: %v", err)
}
}
func NewRegistryServerMux(isPrivate bool) *http.ServeMux {
mux := http.NewServeMux()
var v2Handler http.Handler = http.HandlerFunc(handleV2)
if isPrivate {
v2Handler = auth(v2Handler)
}
mux.Handle("/v2/", v2Handler)
return mux
}
func auth(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, pass, ok := r.BasicAuth()
if !ok || user != privateRegistryUser || pass != privateRegistryPass {
w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte("Unauthorized\n"))
return
}
h.ServeHTTP(w, r)
})
}
// handleBlobs serves blob requests
func handleBlobs(w http.ResponseWriter, r *http.Request, imageName, identifier string) {
filePath := fmt.Sprintf("%s/%s/blobs/%s", registryDir, imageName, identifier)
w.Header().Set("Content-Type", "application/octet-stream")
log.Printf("Serving blob: %s", filePath)
http.ServeFile(w, r, filePath)
}
// handleManifests serves manifest requests. It dynamically sets the Content-Type
// based on the manifest's mediaType field. If the identifier is a tag, it
// reads the digest from the tag file and issues a redirect.
func handleManifests(w http.ResponseWriter, r *http.Request, imageName, identifier string) {
filePath := fmt.Sprintf("%s/%s/manifests/%s", registryDir, imageName, identifier)
// if the identifier is not a digest, assume it's a tag and perform a redirect.
if !strings.HasPrefix(identifier, "sha256:") {
digest, err := os.ReadFile(filePath)
if err != nil {
http.NotFound(w, r)
return
}
redirectURL := strings.Replace(r.URL.String(), identifier, strings.TrimSpace(string(digest)), 1)
w.Header().Set("Location", redirectURL)
w.WriteHeader(http.StatusTemporaryRedirect)
return
}
manifestContent, err := os.ReadFile(filePath)
if err != nil {
http.NotFound(w, r)
return
}
var manifestData struct {
MediaType string `json:"mediaType"`
}
if err := json.Unmarshal(manifestContent, &manifestData); err == nil && manifestData.MediaType != "" {
w.Header().Set("Content-Type", manifestData.MediaType)
}
log.Printf("Serving manifest: %s", filePath)
_, _ = w.Write(manifestContent)
}
func handleV2(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Docker-Distribution-Api-Version", "registry/2.0")
if r.URL.Path == "/v2/" {
w.WriteHeader(http.StatusOK)
return
}
path := strings.TrimPrefix(r.URL.Path, "/v2/")
parts := strings.Split(path, "/")
if len(parts) < 3 {
http.NotFound(w, r)
return
}
imageName := parts[0]
objectType := parts[1]
identifier := parts[2]
switch objectType {
case "blobs":
handleBlobs(w, r, imageName, identifier)
case "manifests":
handleManifests(w, r, imageName, identifier)
default:
http.NotFound(w, r)
}
}

View File

@@ -0,0 +1,310 @@
/*
Copyright 2025 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package fakeregistryserver
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
)
const (
testImageName = "pause"
testTag = "testing"
testManifestDigest = "sha256:f11bf0cbf1d8f08b83261a5bde660d016fbad261f5a84e7c603c0eba4e217811"
testBlobDigest = "sha256:19e4906e80f6945d2222896120e909003ebf8028c30ebc8c99c3c42a35fb6b7f"
testManifestContent = `{
"mediaType": "application/vnd.docker.distribution.manifest.list.v2+json",
"manifests": [
{
"digest": "sha256:e5b941ef8f71de54dc3a13398226c269ba217d06650a21bd3afcf9d890cf1f41",
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
"platform": {
"architecture": "amd64",
"os": "linux"
},
"size": 528
}
],
"schemaVersion": 2
}`
testBlobContent = `this is a fake blob`
)
func closeBody(t *testing.T, resp *http.Response) {
err := resp.Body.Close()
if err != nil {
t.Fatalf("Error closing response body: %v", err)
}
}
// setupTestRegistry creates a temporary directory structure for the fake registry.
func setupTestRegistry(t *testing.T) (string, func() error) {
t.Helper()
tempDir, err := os.MkdirTemp("", "fake-registry-")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
manifestsDir := filepath.Join(tempDir, testImageName, "manifests")
blobsDir := filepath.Join(tempDir, testImageName, "blobs")
if err := os.MkdirAll(manifestsDir, 0755); err != nil {
t.Fatalf("Failed to create manifests dir: %v", err)
}
if err := os.MkdirAll(blobsDir, 0755); err != nil {
t.Fatalf("Failed to create blobs dir: %v", err)
}
// write the manifest file
if err := os.WriteFile(filepath.Join(manifestsDir, testManifestDigest), []byte(testManifestContent), 0644); err != nil {
t.Fatalf("Failed to write manifest file: %v", err)
}
// write the tag file
if err := os.WriteFile(filepath.Join(manifestsDir, testTag), []byte(testManifestDigest), 0644); err != nil {
t.Fatalf("Failed to write tag file: %v", err)
}
// write the blob file
if err := os.WriteFile(filepath.Join(blobsDir, testBlobDigest), []byte(testBlobContent), 0644); err != nil {
t.Fatalf("Failed to write blob file: %v", err)
}
cleanup := func() error {
return os.RemoveAll(tempDir)
}
return tempDir, cleanup
}
func TestRegistryServer(t *testing.T) {
tempDir, cleanup := setupTestRegistry(t)
defer func() {
if err := cleanup(); err != nil {
t.Fatalf("Failed to cleanup temp dir: %v", err)
}
}()
originalRegistryDir := registryDir
registryDir = tempDir
defer func() { registryDir = originalRegistryDir }()
t.Run("Public Mode", func(t *testing.T) {
server := httptest.NewServer(NewRegistryServerMux(false))
defer server.Close()
client := server.Client()
t.Run("GET /v2/", func(t *testing.T) {
resp, err := client.Get(server.URL + "/v2/")
if err != nil {
t.Fatalf("Request failed: %v", err)
}
defer closeBody(t, resp)
if resp.StatusCode != http.StatusOK {
t.Errorf("Expected status OK; got %v", resp.Status)
}
})
t.Run("HEAD manifest tag not exists", func(t *testing.T) {
url := fmt.Sprintf("%s/v2/%s/manifests/%s", server.URL, testImageName, "non-exists")
resp, err := client.Head(url)
if err != nil {
t.Fatalf("Request failed: %v", err)
}
defer closeBody(t, resp)
if resp.StatusCode != http.StatusNotFound {
t.Errorf("Expected status NotFound; got %v", resp.Status)
}
})
t.Run("GET manifest by tag", func(t *testing.T) {
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}
defer func() { client.CheckRedirect = nil }()
url := fmt.Sprintf("%s/v2/%s/manifests/%s", server.URL, testImageName, testTag)
resp, err := client.Get(url)
if err != nil {
t.Fatalf("Request failed: %v", err)
}
defer closeBody(t, resp)
if resp.StatusCode != http.StatusTemporaryRedirect {
t.Errorf("Expected status Temporary Redirect; got %v", resp.Status)
}
expectedLocation := fmt.Sprintf("/v2/%s/manifests/%s", testImageName, testManifestDigest)
if loc := resp.Header.Get("Location"); loc != expectedLocation {
t.Errorf("Expected redirect to %q; got %q", expectedLocation, loc)
}
})
t.Run("HEAD manifest by digest", func(t *testing.T) {
url := fmt.Sprintf("%s/v2/%s/manifests/%s", server.URL, testImageName, testManifestDigest)
resp, err := client.Head(url)
if err != nil {
t.Fatalf("Request failed: %v", err)
}
defer closeBody(t, resp)
if resp.StatusCode != http.StatusOK {
t.Errorf("Expected status OK; got %v", resp.Status)
}
})
t.Run("HEAD manifest digest not exists", func(t *testing.T) {
url := fmt.Sprintf("%s/v2/%s/manifests/%s", server.URL, testImageName, "sha256:non-exists")
resp, err := client.Head(url)
if err != nil {
t.Fatalf("Request failed: %v", err)
}
defer closeBody(t, resp)
if resp.StatusCode != http.StatusNotFound {
t.Errorf("Expected status NotFound; got %v", resp.Status)
}
})
t.Run("GET manifest by digest", func(t *testing.T) {
url := fmt.Sprintf("%s/v2/%s/manifests/%s", server.URL, testImageName, testManifestDigest)
resp, err := client.Get(url)
if err != nil {
t.Fatalf("Request failed: %v", err)
}
defer closeBody(t, resp)
if resp.StatusCode != http.StatusOK {
t.Errorf("Expected status OK; got %v", resp.Status)
}
body, _ := io.ReadAll(resp.Body)
if string(body) != testManifestContent {
t.Errorf("Expected body %q; got %q", testManifestContent, string(body))
}
if ctype := resp.Header.Get("Content-Type"); ctype != "application/vnd.docker.distribution.manifest.list.v2+json" {
t.Errorf("Expected Content-Type header to be set from manifest; got %q", ctype)
}
})
t.Run("HEAD blob", func(t *testing.T) {
url := fmt.Sprintf("%s/v2/%s/blobs/%s", server.URL, testImageName, testBlobDigest)
resp, err := client.Head(url)
if err != nil {
t.Fatalf("Request failed: %v", err)
}
defer closeBody(t, resp)
if resp.StatusCode != http.StatusOK {
t.Errorf("Expected status OK; got %v", resp.Status)
}
})
t.Run("HEAD blob not exists", func(t *testing.T) {
url := fmt.Sprintf("%s/v2/%s/blobs/%s", server.URL, testImageName, "non-exists")
resp, err := client.Head(url)
if err != nil {
t.Fatalf("Request failed: %v", err)
}
defer closeBody(t, resp)
if resp.StatusCode != http.StatusNotFound {
t.Errorf("Expected status NotFound; got %v", resp.Status)
}
})
t.Run("GET blob", func(t *testing.T) {
url := fmt.Sprintf("%s/v2/%s/blobs/%s", server.URL, testImageName, testBlobDigest)
resp, err := client.Get(url)
if err != nil {
t.Fatalf("Request failed: %v", err)
}
defer closeBody(t, resp)
if resp.StatusCode != http.StatusOK {
t.Errorf("Expected status OK; got %v", resp.Status)
}
body, _ := io.ReadAll(resp.Body)
if string(body) != testBlobContent {
t.Errorf("Expected body %q; got %q", testBlobContent, string(body))
}
})
})
t.Run("Private Mode", func(t *testing.T) {
server := httptest.NewServer(NewRegistryServerMux(true))
defer server.Close()
client := server.Client()
t.Run("GET blob without auth", func(t *testing.T) {
url := fmt.Sprintf("%s/v2/%s/blobs/%s", server.URL, testImageName, testBlobDigest)
resp, err := client.Get(url)
if err != nil {
t.Fatalf("Request failed: %v", err)
}
defer closeBody(t, resp)
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("Expected status Unauthorized; got %v", resp.Status)
}
})
t.Run("GET blob with correct auth", func(t *testing.T) {
url := fmt.Sprintf("%s/v2/%s/blobs/%s", server.URL, testImageName, testBlobDigest)
req, _ := http.NewRequest(http.MethodGet, url, nil)
req.SetBasicAuth(privateRegistryUser, privateRegistryPass)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("Request failed: %v", err)
}
defer closeBody(t, resp)
if resp.StatusCode != http.StatusOK {
t.Errorf("Expected status OK; got %v", resp.Status)
}
})
t.Run("GET manifest without auth", func(t *testing.T) {
url := fmt.Sprintf("%s/v2/%s/manifests/%s", server.URL, testImageName, testManifestDigest)
resp, err := client.Get(url)
if err != nil {
t.Fatalf("Request failed: %v", err)
}
defer closeBody(t, resp)
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("Expected status Unauthorized; got %v", resp.Status)
}
})
t.Run("GET manifest with correct auth", func(t *testing.T) {
url := fmt.Sprintf("%s/v2/%s/manifests/%s", server.URL, testImageName, testManifestDigest)
req, _ := http.NewRequest(http.MethodGet, url, nil)
req.SetBasicAuth(privateRegistryUser, privateRegistryPass)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("Request failed: %v", err)
}
defer closeBody(t, resp)
if resp.StatusCode != http.StatusOK {
t.Errorf("Expected status OK; got %v", resp.Status)
}
})
})
}

View File

@@ -187,7 +187,7 @@ build() {
# `--provenance=false --sbom=false` is set to avoid creating a manifest list: https://github.com/kubernetes/kubernetes/issues/123266
docker buildx build --progress=plain --no-cache --pull --output=type="${output_type}" --platform "${os_name}/${arch}" --provenance=false --sbom=false \
--build-arg BASEIMAGE="${base_image}" --build-arg REGISTRY="${REGISTRY}" --build-arg OS_VERSION="${os_version}" \
--build-arg BASEIMAGE="${base_image}" --build-arg REGISTRY="${REGISTRY}" --build-arg OS_VERSION="${os_version}" --build-arg GOLANG_VERSION="${GOLANG_VERSION}" \
-t "${REGISTRY}/${image}:${TAG}-${suffix}" -f "${dockerfile_name}" \
--label "image_version=${TAG}" --label "commit_id=${GIT_COMMIT_ID}" \
--label "git_url=https://github.com/kubernetes/kubernetes/tree/${GIT_COMMIT_ID}/test/images/${img_folder}" .