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) }