Ctrl K
ComunidadGuías…

iclient/incus_image_test.go

Rama develop · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package iclient

import (
	"archive/tar"
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"math/rand/v2"
	"mime/multipart"
	"net/http"
	"strings"
	"testing"
	"time"

	"github.com/lxc/incus/v7/shared/api"
	"github.com/stretchr/testify/require"
)

// waitOperation drains an operation channel and returns its outcome, which is
// the last value: the channel closes on a terminal state.
func waitOperation(t *testing.T, updates <-chan api.Operation) api.Operation {
	t.Helper()

	last := api.Operation{}

	for update := range updates {
		last = update
	}

	require.True(t, last.StatusCode.IsFinal(), "the channel closed before a terminal state: %+v", last)
	require.Empty(t, last.Err, "operation failed")

	return last
}

// testProject creates a throwaway project and removes it on cleanup.
func testProject(t *testing.T, conn *Connection, prefix string) string {
	t.Helper()

	name := fmt.Sprintf("%s-%d", prefix, rand.Uint32())

	err := conn.CreateProject(t.Context(), api.ProjectsPost{
		Name: name,
		ProjectPut: api.ProjectPut{
			Config: map[string]string{"features.images": "true"},
		},
	})
	require.NoError(t, err)

	t.Cleanup(func() {
		// t.Context is already canceled by the time cleanup runs.
		_ = conn.DeleteProject(context.Background(), name, &DeleteProjectArgs{Force: true})
	})

	return name
}

// TestIncusImagePullAndCopy is the flow the image resource performs: pull an
// OCI image from a registry into one project, then copy it into another.
// Neither hop touches a registry from here; incusd does the fetching.
func TestIncusImagePullAndCopy(t *testing.T) {
	skipE2E(t)
	t.Parallel()

	ctx := t.Context()
	conn := testConnection(t)

	cache := testProject(t, conn, "iclient-cache")
	target := testProject(t, conn, "iclient-target")

	// The remote comes from the Incus configuration, so this follows whatever
	// mirror docker.io is pointed at rather than reaching the registry direct.
	config, err := ReadConfig("")
	require.NoError(t, err)

	registry := testRegistry(t, config)
	require.Equal(t, "oci", registry.Protocol, "docker.io is not an OCI remote here")

	// Hop A: registry to the cache project. The alias is fully qualified, a
	// registry having no notion of Docker Hub's implicit library/ prefix.
	pullCtx, cancel := context.WithTimeout(ctx, 10*time.Minute)
	defer cancel()

	updates, err := conn.CreateImage(pullCtx, cache, api.ImagesPost{
		Source: &api.ImagesPostSource{
			ImageSource: api.ImageSource{
				Alias:       "library/alpine:latest",
				Server:      registry.Addrs[0],
				Protocol:    registry.Protocol,
				Certificate: registry.ServerCert,
			},
			Type: "image",
			Mode: "pull",
		},
	}, nil)
	require.NoError(t, err)

	pulled := waitOperation(t, updates)

	fingerprint := operationFingerprint(t, pulled)
	require.NotEmpty(t, fingerprint)

	image, _, err := conn.GetImage(ctx, cache, fingerprint, nil)
	require.NoError(t, err)
	require.Equal(t, fingerprint, image.Fingerprint)

	// Hop B: cache project to the target project. CopyImage owns the secret a
	// private image needs.
	updates, err = conn.CopyImage(ctx, target, conn, cache, fingerprint, nil)
	require.NoError(t, err)

	waitOperation(t, updates)

	copied, _, err := conn.GetImage(ctx, target, fingerprint, nil)
	require.NoError(t, err)
	require.Equal(t, fingerprint, copied.Fingerprint, "the copy keeps the fingerprint")

	// The cache still holds it: a copy is not a move.
	_, _, err = conn.GetImage(ctx, cache, fingerprint, nil)
	require.NoError(t, err)
}

// operationFingerprint returns the image an operation created. Incus reports
// it in the operation metadata, not in Resources, which stays empty here.
func operationFingerprint(t *testing.T, op api.Operation) string {
	t.Helper()

	fingerprint, ok := op.Metadata["fingerprint"].(string)
	require.True(t, ok, "no fingerprint in the operation metadata: %+v", op.Metadata)

	return fingerprint
}

// tarOf builds a tarball holding the given files.
func tarOf(t *testing.T, files map[string]string) []byte {
	t.Helper()

	buf := &bytes.Buffer{}
	archive := tar.NewWriter(buf)

	for name, content := range files {
		require.NoError(t, archive.WriteHeader(&tar.Header{
			Name: name,
			Mode: 0o644,
			Size: int64(len(content)),
		}))

		_, err := io.WriteString(archive, content)
		require.NoError(t, err)
	}

	require.NoError(t, archive.Close())

	return buf.Bytes()
}

// TestIncusWriteImageParts pins the multipart field names. The server tells a
// container rootfs from a disk image by them, so they are not cosmetic.
func TestIncusWriteImageParts(t *testing.T) {
	t.Parallel()

	for _, tt := range []struct {
		kind string
		want string
	}{
		{"", "rootfs"},
		{"container", "rootfs"},
		{"virtual-machine", "rootfs.img"},
	} {
		t.Run(tt.kind, func(t *testing.T) {
			t.Parallel()

			body := &bytes.Buffer{}
			form := multipart.NewWriter(body)

			require.NoError(t, writeImageParts(form, &ImageCreateArgs{
				MetaFile:   strings.NewReader("meta"),
				MetaName:   "metadata.tar",
				RootfsFile: strings.NewReader("root"),
				RootfsName: "rootfs.tar",
				Type:       tt.kind,
			}))

			parts := map[string]string{}

			reader := multipart.NewReader(body, form.Boundary())

			for {
				part, err := reader.NextPart()
				if err != nil {
					break
				}

				content, err := io.ReadAll(part)
				require.NoError(t, err)

				parts[part.FormName()] = string(content)
			}

			require.Equal(t, map[string]string{"metadata": "meta", tt.want: "root"}, parts)
		})
	}
}

// TestIncusImageUploadHeader pins how an upload describes itself; anything left
// out of these headers is silently dropped.
func TestIncusImageUploadHeader(t *testing.T) {
	t.Parallel()

	header := imageUploadHeader(api.ImagesPost{
		Aliases:  []api.ImageAlias{{Name: "one"}, {Name: "two"}},
		Filename: "built.tar",
		ImagePut: api.ImagePut{
			Public:     true,
			Profiles:   []string{"default", "extra"},
			Properties: map[string]string{"oci.uid": "1000"},
		},
	})

	require.Equal(t, "alias=one&alias=two", header.Get("X-Incus-aliases"))
	require.Equal(t, "profile=default&profile=extra", header.Get("X-Incus-profiles"))
	require.Equal(t, "oci.uid=1000", header.Get("X-Incus-properties"))
	require.Equal(t, "true", header.Get("X-Incus-public"))
	require.Equal(t, "built.tar", header.Get("X-Incus-filename"))
}

// TestIncusImageUploadHeaderEmpty: a header the server would read as a value
// must not be sent when there is no value.
func TestIncusImageUploadHeaderEmpty(t *testing.T) {
	t.Parallel()

	require.Empty(t, imageUploadHeader(api.ImagesPost{}))
}

// TestIncusUploadHeaderReachesTheWire is the other half: the header the upload
// builds has to survive the request, not just be assembled correctly.
func TestIncusUploadHeaderReachesTheWire(t *testing.T) {
	t.Parallel()

	conn, seen := recordingServer(t, `{}`)

	_, _, err := conn.send(t.Context(), http.MethodPost, "/1.0/images",
		strings.NewReader("tarball"), "application/octet-stream", "",
		imageUploadHeader(api.ImagesPost{Aliases: []api.ImageAlias{{Name: "built"}}}))
	require.NoError(t, err)

	sent := seen.all()
	require.Len(t, sent, 1)
	require.Equal(t, "alias=built", sent[0].header.Get("X-Incus-aliases"))
	require.Equal(t, "application/octet-stream", sent[0].header.Get("Content-Type"))
	require.Equal(t, "tarball", sent[0].body)
}

func TestIncusCreateImageRefusesWithoutMetadata(t *testing.T) {
	t.Parallel()

	conn, seen := recordingServer(t, `{}`)

	_, err := conn.CreateImage(t.Context(), "myproject", api.ImagesPost{}, &ImageCreateArgs{
		RootfsFile: strings.NewReader("root"),
	})
	require.Error(t, err)
	require.Empty(t, seen.all(), "it must refuse before asking the server")
}

// TestIncusCreateImageUploadAgainstRealIncus imports a split image the way the
// compose `build:` path does, from tarballs rather than from a remote.
func TestIncusCreateImageUploadAgainstRealIncus(t *testing.T) {
	skipLocal(t)
	t.Parallel()

	ctx := t.Context()
	conn := testConnection(t)

	project := testProject(t, conn, "iclient-upload")

	server, _, err := conn.GetServer(ctx)
	require.NoError(t, err)
	require.NotEmpty(t, server.Environment.Architectures)

	metadata, err := json.Marshal(api.ImageMetadata{
		Architecture: server.Environment.Architectures[0],
		CreationDate: time.Now().Unix(),
		Properties:   map[string]string{"description": "iclient upload test"},
	})
	require.NoError(t, err)

	const alias = "iclient-uploaded"

	updates, err := conn.CreateImage(ctx, project, api.ImagesPost{
		Aliases:  []api.ImageAlias{{Name: alias}},
		ImagePut: api.ImagePut{Public: true, Properties: map[string]string{"iclient.test": "yes"}},
	}, &ImageCreateArgs{
		MetaFile:   bytes.NewReader(tarOf(t, map[string]string{"metadata.yaml": string(metadata)})),
		MetaName:   "metadata.tar",
		RootfsFile: bytes.NewReader(tarOf(t, map[string]string{"etc/hostname": "iclient\n"})),
		RootfsName: "rootfs.tar",
	})
	require.NoError(t, err)

	imported := waitOperation(t, updates)

	fingerprint := operationFingerprint(t, imported)
	require.NotEmpty(t, fingerprint)

	// Only a lookup by alias notices an upload whose headers went missing.
	resolved, _, err := conn.GetImageAlias(ctx, project, alias, nil)
	require.NoError(t, err, "the upload did not carry its aliases")
	require.Equal(t, fingerprint, resolved.Target)

	image, etag, err := conn.GetImage(ctx, project, fingerprint, nil)
	require.NoError(t, err)
	require.Equal(t, "iclient upload test", image.Properties["description"],
		"the server reads the metadata out of the uploaded tarball")
	require.Equal(t, "yes", image.Properties["iclient.test"], "the upload did not carry its properties")
	require.True(t, image.Public, "the upload did not carry its public flag")

	// Proves the image is usable and not merely accepted: a conditional update
	// needs the ETag the upload's own read handed back.
	require.NoError(t, conn.UpdateImage(ctx, project, fingerprint, api.ImagePut{
		Properties: image.Properties,
		Public:     false,
	}, etag))

	updates, err = conn.DeleteImage(ctx, project, fingerprint)
	require.NoError(t, err)

	waitOperation(t, updates)

	_, _, err = conn.GetImage(ctx, project, fingerprint, nil)
	require.Error(t, err)
}