diff --git a/cmd/unikraft/auth_test.go b/cmd/unikraft/auth_test.go index 7eefd29a..a1808b6e 100644 --- a/cmd/unikraft/auth_test.go +++ b/cmd/unikraft/auth_test.go @@ -29,8 +29,8 @@ func authTests(t *testing.T, r *testRunner) { {args: []string{unikraftCmd, "profile", "list"}}, {args: []string{unikraftCmd, "metro", "list"}}, {args: []string{unikraftCmd, "logout"}}, - {args: []string{unikraftCmd, "profile", "list"}, allowErr: true}, - {args: []string{unikraftCmd, "metro", "list"}, allowErr: true}, + {args: []string{unikraftCmd, "profile", "list"}, err: errMaybe}, + {args: []string{unikraftCmd, "metro", "list"}, err: errMaybe}, }) }) } diff --git a/cmd/unikraft/build_test.go b/cmd/unikraft/build_test.go index c3fe32be..5554814a 100644 --- a/cmd/unikraft/build_test.go +++ b/cmd/unikraft/build_test.go @@ -18,23 +18,40 @@ func buildTests(t *testing.T, r *testRunner) { }) }) - var busybox, metroName string + var metroName string + type variant struct { + name string + image string + } + var variants []variant if r.cfg != nil { metroName = r.cfg.MetroName - busybox = fmt.Sprintf("%s/busybox-e2e:$UNIQ_IMAGE", r.cfg.Profile.Organization) - // this is what we'd use to test direct push - // busybox := fmt.Sprintf("%s/%s/busybox-e2e:$UNIQ_IMAGE", cfg.Metro.Index().Host, cfg.Profile.Organization) + variants = []variant{ + { + name: "registry", + image: fmt.Sprintf("%s/busybox-e2e:$UNIQ_IMAGE", r.cfg.Profile.Organization), + }, + { + name: "direct-push", + image: fmt.Sprintf("%s/%s/busybox-e2e:$UNIQ_IMAGE", r.cfg.Metro.Index().Host, r.cfg.Profile.Organization), + }, + } } t.Run("busybox", func(t *testing.T) { + if r.cfg == nil { + t.Skip("busybox tests require online config") + } for _, format := range []string{"cpio", "erofs"} { t.Run(format, func(t *testing.T) { - r. - online(). - withCleaners(buildCleaners). - withContext(map[string]string{ - "Dockerfile": ` + for _, v := range variants { + t.Run(v.name, func(t *testing.T) { + r. + online(). + withCleaners(buildCleaners). + withContext(map[string]string{ + "Dockerfile": ` FROM busybox:latest RUN echo "unikraft-e2e" > /etc/unikraft-e2e COPY <= errMaybe { exitCode = exitErr.ExitCode() - // ignore exit errors for help commands err = nil } require.NoError(t, err, "command %q failed\nstdout:\n%s\nstderr:\n%s", @@ -245,6 +252,9 @@ func (b *testBuilder) run(t *testing.T, commands []command) { stdout.String(), stderr.String(), ) + if command.err == errYes { + require.NotZero(t, exitCode, "command %q was expected to fail but succeeded", strings.Join(args, " ")) + } report := report{ args: command.args, @@ -411,6 +421,11 @@ var cleaners = []cleaner{ pattern: regexp.MustCompile(`\b\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|(\+\d{2}:\d{2}))?\b`), repl: "YYYY-MM-DDTHH:MM:SSZ", }, + { + // datetimes like "2000-01-02 12:34:56 +0100 BST" change between runs + pattern: regexp.MustCompile(`\b\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?\s+[+-]\d{4}\s+[A-Z]{1,5}\b`), + repl: "YYYY-MM-DD HH:MM:SS +0000 UTC", + }, { // kernel log timestamps like "[ 0.065015]" change between runs pattern: regexp.MustCompile(`\[\s*\d+\.\d+\]`), diff --git a/cmd/unikraft/testdata/TestGolden/build/busybox/cpio b/cmd/unikraft/testdata/TestGolden/build/busybox/cpio deleted file mode 100644 index d9a348ae..00000000 --- a/cmd/unikraft/testdata/TestGolden/build/busybox/cpio +++ /dev/null @@ -1,51 +0,0 @@ -$ unikraft build . --output test/busybox-e2e:$UNIQ_IMAGE - -stderr: - │ found buildkit addr= version=vX.Y.Z - -$ unikraft run --name test-$UNIQ_INST --metro test --output quiet --image test/busybox-e2e:$UNIQ_IMAGE - -stdout: - test/test- - -$ unikraft instance wait --until state==stopped --timeout 10s test-$UNIQ_INST - -stdout: - metro: test - name: test- - uuid: 12345678-1234-1234-1234-123456789abc - state: stopped - image: test/busybox-e2e - resources: - memory: 128MiB - vcpus: 1 - networks: - - uuid: 12345678-1234-1234-1234-123456789abc - private-ip: 10.X.X.X - mac: aa:bb:cc:dd:ee:ff - timestamps: - created: RELATIVE_TIME - stop: - reason: app exit - exit-code: 0 - -$ unikraft instance logs test-$UNIQ_INST - -stdout: - test- ┏ [ 0.000000] RDSEED32 is broken. Disabling the corresponding CPUID bit. - test- │ == BEGIN /etc/unikraft-e2e == - test- │ unikraft-e2e - test- │ == END /etc/unikraft-e2e == - test- │ == BEGIN ls /etc/unikraft-e2e == - test- │ /etc/unikraft-e2e - test- │ == END ls /etc/unikraft-e2e == - test- │ == BEGIN status == - test- │ UNIKRAFT_E2E_OK - test- │ == END status == - test- │ Application exited with 0x0 (exit code: 0) - test- │ [ 0.000000] reboot: Restarting system - -$ unikraft instance delete test-$UNIQ_INST - -stdout: - test- diff --git a/cmd/unikraft/testdata/TestGolden/build/busybox/cpio/direct-push b/cmd/unikraft/testdata/TestGolden/build/busybox/cpio/direct-push new file mode 100644 index 00000000..26bfc560 --- /dev/null +++ b/cmd/unikraft/testdata/TestGolden/build/busybox/cpio/direct-push @@ -0,0 +1,103 @@ +$ unikraft build . --output index.unikraft.test/test/busybox-e2e:$UNIQ_IMAGE + +stderr: + │ found buildkit addr= version=vX.Y.Z + +$ unikraft image inspect index.unikraft.test/test/busybox-e2e:$UNIQ_IMAGE + +stdout: + ref: index.unikraft.test/test/busybox-e2e: + digest: sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890 + config: + platform: kraftcloud/x86_64 + cmd: ["sh", "/entrypoint.sh"] + env: + PATH: /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + metadata: + created: YYYY-MM-DD HH:MM:SS +0000 UTC + kernel: + digest: sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890 + media-type: application/vnd.oci.image.layer.v1.tar + annotations: + org.unikraft.kernel.image: /unikraft/bin/kernel + size: X.XXXMiB + initrd: + digest: sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890 + media-type: application/vnd.oci.image.layer.v1.tar + annotations: + org.unikraft.kernel.initrd: /unikraft/bin/initrd + size: X.XXXMiB + +$ unikraft image ls index.unikraft.test/test/busybox-e2e:$UNIQ_IMAGE -okv + +stdout: + ref: index.unikraft.test/test/busybox-e2e: + digest: sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890 + +$ unikraft run --name test-$UNIQ_INST --metro test --output quiet --image index.unikraft.test/test/busybox-e2e:$UNIQ_IMAGE + +stdout: + test/test- + +$ unikraft instance wait --until state==stopped --timeout 10s test-$UNIQ_INST + +stdout: + metro: test + name: test- + uuid: 12345678-1234-1234-1234-123456789abc + state: stopped + image: index.unikraft.test/test/busybox-e2e + resources: + memory: X.XXXMiB + vcpus: 1 + networks: + - uuid: 12345678-1234-1234-1234-123456789abc + private-ip: 10.X.X.X + mac: aa:bb:cc:dd:ee:ff + timestamps: + created: RELATIVE_TIME + stop: + reason: app exit + exit-code: 0 + +$ unikraft instance logs test-$UNIQ_INST + +stdout: + test- ┏ [ 0.000000] RDSEED32 is broken. Disabling the corresponding CPUID bit. + test- │ == BEGIN /etc/unikraft-e2e == + test- │ unikraft-e2e + test- │ == END /etc/unikraft-e2e == + test- │ == BEGIN ls /etc/unikraft-e2e == + test- │ /etc/unikraft-e2e + test- │ == END ls /etc/unikraft-e2e == + test- │ == BEGIN status == + test- │ UNIKRAFT_E2E_OK + test- │ == END status == + test- │ Application exited with 0x0 (exit code: 0) + test- │ [ 0.000000] reboot: Restarting system + +$ unikraft instance delete test-$UNIQ_INST + +stdout: + test- + +$ unikraft image delete index.unikraft.test/test/busybox-e2e:$UNIQ_IMAGE + +stdout: + index.unikraft.test/test/busybox-e2e: + +$ unikraft image inspect index.unikraft.test/test/busybox-e2e:$UNIQ_IMAGE + +stderr: + │ + │ error: + │ failed to resolve image "index.unikraft.test/test/busybox-e2e:": index.unikraft.test/test/busybox-e2e:: not found + │ + +exit code: 1 + +$ unikraft image ls index.unikraft.test/test/busybox-e2e:$UNIQ_IMAGE -okv + +stdout: + ref: index.unikraft.test/test/busybox-e2e: + digest: sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890 diff --git a/cmd/unikraft/testdata/TestGolden/build/busybox/cpio/registry b/cmd/unikraft/testdata/TestGolden/build/busybox/cpio/registry new file mode 100644 index 00000000..6bcb3560 --- /dev/null +++ b/cmd/unikraft/testdata/TestGolden/build/busybox/cpio/registry @@ -0,0 +1,107 @@ +$ unikraft build . --output test/busybox-e2e:$UNIQ_IMAGE + +stderr: + │ found buildkit addr= version=vX.Y.Z + +$ unikraft image inspect test/busybox-e2e:$UNIQ_IMAGE + +stdout: + ref: test/busybox-e2e: + digest: sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890 + config: + platform: kraftcloud/x86_64 + cmd: ["sh", "/entrypoint.sh"] + env: + PATH: /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + metadata: + created: YYYY-MM-DD HH:MM:SS +0000 UTC + kernel: + digest: sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890 + media-type: application/vnd.oci.image.layer.v1.tar + annotations: + org.unikraft.kernel.image: /unikraft/bin/kernel + size: X.XXXMiB + initrd: + digest: sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890 + media-type: application/vnd.oci.image.layer.v1.tar + annotations: + org.unikraft.kernel.initrd: /unikraft/bin/initrd + size: X.XXXMiB + +$ unikraft image ls test/busybox-e2e:$UNIQ_IMAGE -okv + +stdout: + ref: test/busybox-e2e: + digest: sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890 + +$ unikraft run --name test-$UNIQ_INST --metro test --output quiet --image test/busybox-e2e:$UNIQ_IMAGE + +stdout: + test/test- + +$ unikraft instance wait --until state==stopped --timeout 10s test-$UNIQ_INST + +stdout: + metro: test + name: test- + uuid: 12345678-1234-1234-1234-123456789abc + state: stopped + image: test/busybox-e2e + resources: + memory: X.XXXMiB + vcpus: 1 + networks: + - uuid: 12345678-1234-1234-1234-123456789abc + private-ip: 10.X.X.X + mac: aa:bb:cc:dd:ee:ff + timestamps: + created: RELATIVE_TIME + stop: + reason: app exit + exit-code: 0 + +$ unikraft instance logs test-$UNIQ_INST + +stdout: + test- ┏ [ 0.000000] RDSEED32 is broken. Disabling the corresponding CPUID bit. + test- │ == BEGIN /etc/unikraft-e2e == + test- │ unikraft-e2e + test- │ == END /etc/unikraft-e2e == + test- │ == BEGIN ls /etc/unikraft-e2e == + test- │ /etc/unikraft-e2e + test- │ == END ls /etc/unikraft-e2e == + test- │ == BEGIN status == + test- │ UNIKRAFT_E2E_OK + test- │ == END status == + test- │ Application exited with 0x0 (exit code: 0) + test- │ [ 0.000000] reboot: Restarting system + +$ unikraft instance delete test-$UNIQ_INST + +stdout: + test- + +$ unikraft image delete test/busybox-e2e:$UNIQ_IMAGE + +stdout: + unikraft.io/test/busybox-e2e: + +$ unikraft image inspect test/busybox-e2e:$UNIQ_IMAGE + +stderr: + │ + │ error: + │ failed to resolve image "unikraft.io/test/busybox-e2e:": unikraft.io/test/busybox-e2e:: not found + │ + +exit code: 1 + +$ unikraft image ls test/busybox-e2e:$UNIQ_IMAGE -okv + +stderr: + │ + │ error: + │ references not found: [unikraft.io/test/busybox-e2e:] + │ + +exit code: 1 diff --git a/cmd/unikraft/testdata/TestGolden/build/busybox/erofs b/cmd/unikraft/testdata/TestGolden/build/busybox/erofs deleted file mode 100644 index d9a348ae..00000000 --- a/cmd/unikraft/testdata/TestGolden/build/busybox/erofs +++ /dev/null @@ -1,51 +0,0 @@ -$ unikraft build . --output test/busybox-e2e:$UNIQ_IMAGE - -stderr: - │ found buildkit addr= version=vX.Y.Z - -$ unikraft run --name test-$UNIQ_INST --metro test --output quiet --image test/busybox-e2e:$UNIQ_IMAGE - -stdout: - test/test- - -$ unikraft instance wait --until state==stopped --timeout 10s test-$UNIQ_INST - -stdout: - metro: test - name: test- - uuid: 12345678-1234-1234-1234-123456789abc - state: stopped - image: test/busybox-e2e - resources: - memory: 128MiB - vcpus: 1 - networks: - - uuid: 12345678-1234-1234-1234-123456789abc - private-ip: 10.X.X.X - mac: aa:bb:cc:dd:ee:ff - timestamps: - created: RELATIVE_TIME - stop: - reason: app exit - exit-code: 0 - -$ unikraft instance logs test-$UNIQ_INST - -stdout: - test- ┏ [ 0.000000] RDSEED32 is broken. Disabling the corresponding CPUID bit. - test- │ == BEGIN /etc/unikraft-e2e == - test- │ unikraft-e2e - test- │ == END /etc/unikraft-e2e == - test- │ == BEGIN ls /etc/unikraft-e2e == - test- │ /etc/unikraft-e2e - test- │ == END ls /etc/unikraft-e2e == - test- │ == BEGIN status == - test- │ UNIKRAFT_E2E_OK - test- │ == END status == - test- │ Application exited with 0x0 (exit code: 0) - test- │ [ 0.000000] reboot: Restarting system - -$ unikraft instance delete test-$UNIQ_INST - -stdout: - test- diff --git a/cmd/unikraft/testdata/TestGolden/build/busybox/erofs/direct-push b/cmd/unikraft/testdata/TestGolden/build/busybox/erofs/direct-push new file mode 100644 index 00000000..26bfc560 --- /dev/null +++ b/cmd/unikraft/testdata/TestGolden/build/busybox/erofs/direct-push @@ -0,0 +1,103 @@ +$ unikraft build . --output index.unikraft.test/test/busybox-e2e:$UNIQ_IMAGE + +stderr: + │ found buildkit addr= version=vX.Y.Z + +$ unikraft image inspect index.unikraft.test/test/busybox-e2e:$UNIQ_IMAGE + +stdout: + ref: index.unikraft.test/test/busybox-e2e: + digest: sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890 + config: + platform: kraftcloud/x86_64 + cmd: ["sh", "/entrypoint.sh"] + env: + PATH: /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + metadata: + created: YYYY-MM-DD HH:MM:SS +0000 UTC + kernel: + digest: sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890 + media-type: application/vnd.oci.image.layer.v1.tar + annotations: + org.unikraft.kernel.image: /unikraft/bin/kernel + size: X.XXXMiB + initrd: + digest: sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890 + media-type: application/vnd.oci.image.layer.v1.tar + annotations: + org.unikraft.kernel.initrd: /unikraft/bin/initrd + size: X.XXXMiB + +$ unikraft image ls index.unikraft.test/test/busybox-e2e:$UNIQ_IMAGE -okv + +stdout: + ref: index.unikraft.test/test/busybox-e2e: + digest: sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890 + +$ unikraft run --name test-$UNIQ_INST --metro test --output quiet --image index.unikraft.test/test/busybox-e2e:$UNIQ_IMAGE + +stdout: + test/test- + +$ unikraft instance wait --until state==stopped --timeout 10s test-$UNIQ_INST + +stdout: + metro: test + name: test- + uuid: 12345678-1234-1234-1234-123456789abc + state: stopped + image: index.unikraft.test/test/busybox-e2e + resources: + memory: X.XXXMiB + vcpus: 1 + networks: + - uuid: 12345678-1234-1234-1234-123456789abc + private-ip: 10.X.X.X + mac: aa:bb:cc:dd:ee:ff + timestamps: + created: RELATIVE_TIME + stop: + reason: app exit + exit-code: 0 + +$ unikraft instance logs test-$UNIQ_INST + +stdout: + test- ┏ [ 0.000000] RDSEED32 is broken. Disabling the corresponding CPUID bit. + test- │ == BEGIN /etc/unikraft-e2e == + test- │ unikraft-e2e + test- │ == END /etc/unikraft-e2e == + test- │ == BEGIN ls /etc/unikraft-e2e == + test- │ /etc/unikraft-e2e + test- │ == END ls /etc/unikraft-e2e == + test- │ == BEGIN status == + test- │ UNIKRAFT_E2E_OK + test- │ == END status == + test- │ Application exited with 0x0 (exit code: 0) + test- │ [ 0.000000] reboot: Restarting system + +$ unikraft instance delete test-$UNIQ_INST + +stdout: + test- + +$ unikraft image delete index.unikraft.test/test/busybox-e2e:$UNIQ_IMAGE + +stdout: + index.unikraft.test/test/busybox-e2e: + +$ unikraft image inspect index.unikraft.test/test/busybox-e2e:$UNIQ_IMAGE + +stderr: + │ + │ error: + │ failed to resolve image "index.unikraft.test/test/busybox-e2e:": index.unikraft.test/test/busybox-e2e:: not found + │ + +exit code: 1 + +$ unikraft image ls index.unikraft.test/test/busybox-e2e:$UNIQ_IMAGE -okv + +stdout: + ref: index.unikraft.test/test/busybox-e2e: + digest: sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890 diff --git a/cmd/unikraft/testdata/TestGolden/build/busybox/erofs/registry b/cmd/unikraft/testdata/TestGolden/build/busybox/erofs/registry new file mode 100644 index 00000000..6bcb3560 --- /dev/null +++ b/cmd/unikraft/testdata/TestGolden/build/busybox/erofs/registry @@ -0,0 +1,107 @@ +$ unikraft build . --output test/busybox-e2e:$UNIQ_IMAGE + +stderr: + │ found buildkit addr= version=vX.Y.Z + +$ unikraft image inspect test/busybox-e2e:$UNIQ_IMAGE + +stdout: + ref: test/busybox-e2e: + digest: sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890 + config: + platform: kraftcloud/x86_64 + cmd: ["sh", "/entrypoint.sh"] + env: + PATH: /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + metadata: + created: YYYY-MM-DD HH:MM:SS +0000 UTC + kernel: + digest: sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890 + media-type: application/vnd.oci.image.layer.v1.tar + annotations: + org.unikraft.kernel.image: /unikraft/bin/kernel + size: X.XXXMiB + initrd: + digest: sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890 + media-type: application/vnd.oci.image.layer.v1.tar + annotations: + org.unikraft.kernel.initrd: /unikraft/bin/initrd + size: X.XXXMiB + +$ unikraft image ls test/busybox-e2e:$UNIQ_IMAGE -okv + +stdout: + ref: test/busybox-e2e: + digest: sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890 + +$ unikraft run --name test-$UNIQ_INST --metro test --output quiet --image test/busybox-e2e:$UNIQ_IMAGE + +stdout: + test/test- + +$ unikraft instance wait --until state==stopped --timeout 10s test-$UNIQ_INST + +stdout: + metro: test + name: test- + uuid: 12345678-1234-1234-1234-123456789abc + state: stopped + image: test/busybox-e2e + resources: + memory: X.XXXMiB + vcpus: 1 + networks: + - uuid: 12345678-1234-1234-1234-123456789abc + private-ip: 10.X.X.X + mac: aa:bb:cc:dd:ee:ff + timestamps: + created: RELATIVE_TIME + stop: + reason: app exit + exit-code: 0 + +$ unikraft instance logs test-$UNIQ_INST + +stdout: + test- ┏ [ 0.000000] RDSEED32 is broken. Disabling the corresponding CPUID bit. + test- │ == BEGIN /etc/unikraft-e2e == + test- │ unikraft-e2e + test- │ == END /etc/unikraft-e2e == + test- │ == BEGIN ls /etc/unikraft-e2e == + test- │ /etc/unikraft-e2e + test- │ == END ls /etc/unikraft-e2e == + test- │ == BEGIN status == + test- │ UNIKRAFT_E2E_OK + test- │ == END status == + test- │ Application exited with 0x0 (exit code: 0) + test- │ [ 0.000000] reboot: Restarting system + +$ unikraft instance delete test-$UNIQ_INST + +stdout: + test- + +$ unikraft image delete test/busybox-e2e:$UNIQ_IMAGE + +stdout: + unikraft.io/test/busybox-e2e: + +$ unikraft image inspect test/busybox-e2e:$UNIQ_IMAGE + +stderr: + │ + │ error: + │ failed to resolve image "unikraft.io/test/busybox-e2e:": unikraft.io/test/busybox-e2e:: not found + │ + +exit code: 1 + +$ unikraft image ls test/busybox-e2e:$UNIQ_IMAGE -okv + +stderr: + │ + │ error: + │ references not found: [unikraft.io/test/busybox-e2e:] + │ + +exit code: 1 diff --git a/cmd/unikraft/testdata/TestGolden/images/help b/cmd/unikraft/testdata/TestGolden/images/help index e691ef01..f29e1de0 100644 --- a/cmd/unikraft/testdata/TestGolden/images/help +++ b/cmd/unikraft/testdata/TestGolden/images/help @@ -7,12 +7,12 @@ stdout: unikraft images [flags] Resources: + list, ls + List images. get, inspect, show Inspect a image. delete, rm, remove Remove a image. - list, ls - List images. copy Copy images. diff --git a/cmd/unikraft/volumes_test.go b/cmd/unikraft/volumes_test.go index b709205b..88a617f7 100644 --- a/cmd/unikraft/volumes_test.go +++ b/cmd/unikraft/volumes_test.go @@ -68,21 +68,21 @@ func volumesTests(t *testing.T, r *testRunner) { // Offline: missing --source errors before any network call. t.Run("missing-source", func(t *testing.T) { r.run(t, []command{ - {args: []string{unikraftCmd, "volume", "import", "my-volume"}, allowErr: true}, + {args: []string{unikraftCmd, "volume", "import", "my-volume"}, err: errYes}, }) }) // Offline: port below the allowed range errors before any network call. t.Run("invalid-port", func(t *testing.T) { r.run(t, []command{ - {args: []string{unikraftCmd, "volume", "import", "my-volume", "--source", ".", "--port", "80"}, allowErr: true}, + {args: []string{unikraftCmd, "volume", "import", "my-volume", "--source", ".", "--port", "80"}, err: errYes}, }) }) // Offline: port above the allowed range errors before any network call. t.Run("invalid-port-high", func(t *testing.T) { r.run(t, []command{ - {args: []string{unikraftCmd, "volume", "import", "my-volume", "--source", ".", "--port", "99999"}, allowErr: true}, + {args: []string{unikraftCmd, "volume", "import", "my-volume", "--source", ".", "--port", "99999"}, err: errYes}, }) }) diff --git a/go.mod b/go.mod index 7c54b090..798ad22e 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module unikraft.com/cli -go 1.26.0 +go 1.26.1 require ( charm.land/bubbles/v2 v2.1.0 @@ -43,7 +43,7 @@ require ( gotest.tools/v3 v3.5.2 mvdan.cc/sh/v3 v3.13.1 sigs.k8s.io/yaml v1.6.0 - unikraft.com/cloud/sdk v0.0.0-20260416133315-be4aec303a89 + unikraft.com/cloud/sdk v0.0.0-20260429110911-ff0c948a8c0d unikraft.com/x/colors v0.0.0-20260313145522-d793c36d706e unikraft.com/x/filters v0.0.0-20260416164455-ec39ae908f3f unikraft.com/x/fingerprint v0.0.0-20260126094137-ab6e717e5679 diff --git a/go.sum b/go.sum index 341603be..b6f30e3a 100644 --- a/go.sum +++ b/go.sum @@ -495,8 +495,8 @@ sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= tailscale.com v1.94.1 h1:0dAst/ozTuFkgmxZULc3oNwR9+qPIt5ucvzH7kaM0Jw= tailscale.com v1.94.1/go.mod h1:gLnVrEOP32GWvroaAHHGhjSGMPJ1i4DvqNwEg+Yuov4= -unikraft.com/cloud/sdk v0.0.0-20260416133315-be4aec303a89 h1:hMNR+ulLXyGiGFoRnOeKkfAgp/bH5Fzz4nrU36ZEgcE= -unikraft.com/cloud/sdk v0.0.0-20260416133315-be4aec303a89/go.mod h1:mB0KNJFoeiV8zucDtuAFGW16tUsusA/ZHShhqbqhA5Q= +unikraft.com/cloud/sdk v0.0.0-20260429110911-ff0c948a8c0d h1:rxSn/SibpN/lzX83H7XBBgasbqxUsmeM6F/iDaY8WVI= +unikraft.com/cloud/sdk v0.0.0-20260429110911-ff0c948a8c0d/go.mod h1:S7aGf6JP7grJSG0i+cmePnuPkuRCEbfWy3vWKXksi44= unikraft.com/x/colors v0.0.0-20260313145522-d793c36d706e h1:C/V6l4ut5XpcVTN5CvnskRv6NHDbyIeLdgFVLEJ9BIE= unikraft.com/x/colors v0.0.0-20260313145522-d793c36d706e/go.mod h1:SVlAGfyQ7MwJom7m9M2w83+TrO+nJoiLxeduJAxagEo= unikraft.com/x/filters v0.0.0-20260416164455-ec39ae908f3f h1:v6pitpzsBnOjyzDIW0/YAEHHSI6cNOw4QOwQV0uD+dc= diff --git a/internal/cmd/images.go b/internal/cmd/images.go index dca1baed..f9c0d4cc 100644 --- a/internal/cmd/images.go +++ b/internal/cmd/images.go @@ -17,13 +17,16 @@ import ( "github.com/distribution/reference" "github.com/opencontainers/go-digest" "unikraft.com/cloud/sdk/controlplane" + "unikraft.com/cloud/sdk/platform" "unikraft.com/cloud/sdk/platform/group" + "unikraft.com/x/joinerrgroup" "unikraft.com/x/kingkong" "unikraft.com/x/log" "unikraft.com/x/ptr" imagespec "unikraft.com/x/image-spec" + "unikraft.com/cli/internal/config" "unikraft.com/cli/internal/images" "unikraft.com/cli/internal/multimetro" "unikraft.com/cli/internal/resource" @@ -34,17 +37,13 @@ import ( type ImagesCmd struct { cmd.ResourceCmd[ImageEntry] + cmd.ListableResourceCmd[ImageEntry] cmd.GettableResourceCmd[Image] cmd.DeletableResourceCmd[Image] - List ImagesListCmd `cmd:"" help:"List images." aliases:"ls"` Copy ImagesCopyCmd `cmd:"" help:"Copy images."` } -type ImagesListCmd struct { - cmd.ResourceListCmd[ImageEntry] -} - type Image struct { Ref types.ImageRef[reference.Named] `field:",short"` Digest digest.Digest `field:",long"` @@ -91,7 +90,7 @@ func (i Image) Key() resource.Key { } func (i Image) Raw() any { - return nil // NOTE: no platform API response associated + return i.Image.Descriptor } func (i Image) Fields(ctx context.Context) ([]resource.Field, error) { @@ -246,14 +245,15 @@ func (Image) Examples() map[cmd.CmdType][]kingkong.Example { } type ImageEntry struct { - Ref types.ImageRef[reference.NamedTagged] `field:",short"` - Digest digest.Digest `field:",short"` + Ref types.ImageRef[reference.Named] `field:",short"` + Digest digest.Digest `field:",short"` Namespace string Canonical reference.Canonical `field:"-"` - Image controlplane.Image `field:"-" json:"image"` + controlplaneImage *controlplane.Image + platformImage *platform.Image } func (ImageEntry) Type() resource.Type { @@ -268,15 +268,17 @@ func (i ImageEntry) Key() resource.Key { } func (i ImageEntry) Raw() any { - return i.Image + if i.controlplaneImage != nil { + return i.controlplaneImage + } + if i.platformImage != nil { + return i.platformImage + } + return nil } func (i ImageEntry) Fields(ctx context.Context) ([]resource.Field, error) { - result, err := resource.FieldsFromStruct(i) - if err != nil { - return nil, err - } - return result, nil + return resource.FieldsFromStruct(i) } func (ImageEntry) Examples() map[cmd.CmdType][]kingkong.Example { @@ -289,28 +291,85 @@ func (ImageEntry) List(ctx context.Context) ([]resource.Resource, error) { return nil, err } - log.G(ctx).Trace().Msg("listing images") - resp, err := client.ListImages(ctx, controlplane.ListImagesOpts{Details: new(true)}) - if err != nil { + var controlplaneResults, platformResults []resource.Resource + + eg, ctx := joinerrgroup.WithContext(ctx) + eg.Go(func() error { + log.G(ctx).Trace().Msg("listing images from controlplane") + resp, err := client.ListImages(ctx, controlplane.ListImagesOpts{Details: new(true)}) + if err != nil { + return err + } + if resp.Data != nil { + for _, image := range resp.Data.Images { + entries, err := ImageEntry{}.loadFromControlplane(image) + if err != nil { + return err + } + for _, entry := range entries { + controlplaneResults = append(controlplaneResults, entry) + } + } + } + return nil + }) + eg.Go(func() error { + var err error + platformResults, err = listPlatformImages(ctx) + return err + }) + if err := eg.Wait(); err != nil { return nil, err } - if resp.Data == nil { - return nil, nil + + seen := make(map[string]struct{}, len(controlplaneResults)) + for _, r := range controlplaneResults { + seen[r.(ImageEntry).Ref.Reference.String()] = struct{}{} + } + results := controlplaneResults + for _, r := range platformResults { + ref := r.(ImageEntry).Ref.Reference.String() + if _, ok := seen[ref]; ok { + continue + } + seen[ref] = struct{}{} + results = append(results, r) } - var results []resource.Resource - var errs []error - for _, image := range resp.Data.Images { - entries, err := ImageEntry{}.load(image) + return results, nil +} + +func listPlatformImages(ctx context.Context) ([]resource.Resource, error) { + g, err := multimetro.NewClient(ctx) + if err != nil { + return nil, err + } + + return group.CollectAllSlices(ctx, g, func(ctx context.Context, c multimetro.MetroClient) ([]resource.Resource, error) { + log.G(ctx).Trace().Msg("listing images from image-store") + + resp, err := c.GetImageStore(ctx, nil, platform.GetImageStoreOpts{}) if err != nil { - errs = append(errs, err) - continue + log.G(ctx).Trace().Err(err).Msg("skipping image-store listing") + return nil, nil } - for _, entry := range entries { - results = append(results, entry) + + var results []resource.Resource + var errs []error + if resp.Data != nil { + for _, image := range resp.Data.Images { + entries, err := ImageEntry{}.loadFromPlatform(image, &c.Metro) + if err != nil { + errs = append(errs, err) + continue + } + for _, entry := range entries { + results = append(results, entry) + } + } } - } - return results, errors.Join(errs...) + return results, errors.Join(errs...) + }) } func (ImageEntry) Get(ctx context.Context, keys []string) ([]resource.Resource, error) { @@ -329,42 +388,58 @@ func (ImageEntry) Get(ctx context.Context, keys []string) ([]resource.Resource, } log.G(ctx).Trace().Msg("getting images") - details := true - resp, err := client.ListImages(ctx, controlplane.ListImagesOpts{Details: &details}) + resp, err := client.ListImages(ctx, controlplane.ListImagesOpts{Details: new(true)}) if err != nil { return nil, err } - if resp.Data == nil { - refs := make(group.Refs, 0, len(normalizedKeys)) - for _, key := range normalizedKeys { - refs = append(refs, group.Ref{Name: key}) - } - return nil, group.ErrRefNotFound{Refs: refs} - } found := make(map[string]struct{}, len(normalizedKeys)) var results []resource.Resource var errs []error - for _, image := range resp.Data.Images { - entries, err := ImageEntry{}.load(image) - if err != nil { - errs = append(errs, err) - continue + if resp.Data != nil { + for _, image := range resp.Data.Images { + entries, err := ImageEntry{}.loadFromControlplane(image) + if err != nil { + errs = append(errs, err) + continue + } + for _, key := range normalizedKeys { + if _, ok := found[key]; ok { + continue + } + for _, entry := range entries { + matchRef := reference.Named(entry.Ref.Reference) + if entry.Canonical != nil { + matchRef = entry.Canonical + } + if xreference.MatchNamed(matchRef, key) { + found[key] = struct{}{} + results = append(results, entry) + break + } + } + } } + } + + platformResults, platformErr := listPlatformImages(ctx) + if platformErr != nil { + errs = append(errs, platformErr) + } + for _, r := range platformResults { + entry := r.(ImageEntry) for _, key := range normalizedKeys { if _, ok := found[key]; ok { continue } - for _, entry := range entries { - matchRef := reference.Named(entry.Ref.Reference) - if entry.Canonical != nil { - matchRef = entry.Canonical - } - if xreference.MatchNamed(matchRef, key) { - found[key] = struct{}{} - results = append(results, entry) - break - } + matchRef := reference.Named(entry.Ref.Reference) + if entry.Canonical != nil { + matchRef = entry.Canonical + } + if xreference.MatchNamed(matchRef, key) { + found[key] = struct{}{} + results = append(results, r) + break } } } @@ -383,7 +458,7 @@ func (ImageEntry) Get(ctx context.Context, keys []string) ([]resource.Resource, return results, errors.Join(errors.Join(errs...), missingErr) } -func (ImageEntry) load(image controlplane.Image) ([]ImageEntry, error) { +func (ImageEntry) loadFromControlplane(image controlplane.Image) ([]ImageEntry, error) { name := strings.TrimSpace(ptr.ZeroIfNil(image.Name)) if name == "" { return nil, fmt.Errorf("image has no name") @@ -393,8 +468,8 @@ func (ImageEntry) load(image controlplane.Image) ([]ImageEntry, error) { return nil, fmt.Errorf("could not parse image name %q: %w", name, err) } var baseDigest digest.Digest - if baseDigested, ok := base.(reference.Digested); ok { - baseDigest = baseDigested.Digest() + if d, ok := base.(reference.Digested); ok { + baseDigest = d.Digest() } base = reference.TrimNamed(base) @@ -440,30 +515,32 @@ func (ImageEntry) load(image controlplane.Image) ([]ImageEntry, error) { } if len(tagged) == 0 { - return nil, fmt.Errorf("image has no tags") + return nil, nil } - // move latest to front if present - idx := slices.IndexFunc(tagged, func(t reference.NamedTagged) bool { + // Move latest to front if present. + if idx := slices.IndexFunc(tagged, func(t reference.NamedTagged) bool { return t.Tag() == "latest" - }) - if idx > 0 { + }); idx > 0 { latest := tagged[idx] - tagged = append(tagged[:idx], tagged[idx+1:]...) - tagged = append([]reference.NamedTagged{latest}, tagged...) + tagged = slices.Insert(slices.Delete(tagged, idx, idx+1), 0, latest) + } + + if len(tagged) == 0 { + return nil, nil } results := make([]ImageEntry, 0, len(tagged)) for _, tag := range tagged { - result := ImageEntry{ - Image: image, - } - tagDigest := tagDigests[tag.Tag()] if tagDigest == "" { tagDigest = baseDigest } - result.Digest = tagDigest + + result := ImageEntry{ + controlplaneImage: &image, + Digest: tagDigest, + } if tagDigest != "" { canonical, err := reference.WithDigest(tag, tagDigest) if err != nil { @@ -471,14 +548,95 @@ func (ImageEntry) load(image controlplane.Image) ([]ImageEntry, error) { } result.Canonical = canonical } - result.Ref.Reference = tag if ns, _, ok := strings.Cut(reference.Path(tag), "/"); ok { result.Namespace = ns } results = append(results, result) } + return results, nil +} + +func (ImageEntry) loadFromPlatform(image platform.Image, metro *config.Metro) ([]ImageEntry, error) { + url := strings.TrimSpace(ptr.ZeroIfNil(image.Url)) + if url == "" { + return nil, fmt.Errorf("platform image has no url") + } + parsed, err := images.ParseNormalizedNamedMetro(metro, url) + if err != nil { + return nil, fmt.Errorf("could not parse platform image url %q: %w", url, err) + } + + var baseDigest digest.Digest + if d, ok := parsed.(reference.Digested); ok { + baseDigest = d.Digest() + } + base, err := reference.ParseNamed(metro.Index().Host + "/" + reference.Path(parsed)) + if err != nil { + return nil, fmt.Errorf("could not construct platform image ref: %w", err) + } + + var tagged []reference.NamedTagged + for _, tag := range image.Tags { + if strings.HasPrefix(tag, "sha256:") { + // Digest entry, not a tag. + continue + } + + var tagVal string + if strings.Contains(tag, ":") { + // Legacy format: "image:tag" + _, tagVal, _ = strings.Cut(tag, ":") + if strings.HasPrefix(tagVal, "sha256:") { + continue + } + } else { + // Raw tag format from /v1/image-store + tagVal = tag + } + if tagVal == "" { + continue + } + ref, err := reference.WithTag(base, tagVal) + if err != nil { + return nil, fmt.Errorf("could not parse platform image tag %q: %w", tag, err) + } + tagged = append(tagged, ref) + } + + // Move latest to front if present. + if idx := slices.IndexFunc(tagged, func(t reference.NamedTagged) bool { + return t.Tag() == "latest" + }); idx > 0 { + latest := tagged[idx] + tagged = slices.Insert(slices.Delete(tagged, idx, idx+1), 0, latest) + } + + if len(tagged) == 0 { + // No tags means the image is dangling; skip it. + return nil, nil + } + + results := make([]ImageEntry, 0, len(tagged)) + for _, tag := range tagged { + result := ImageEntry{ + platformImage: &image, + Digest: baseDigest, + } + if baseDigest != "" { + canonical, err := reference.WithDigest(tag, baseDigest) + if err != nil { + return nil, fmt.Errorf("could not create image canonical reference: %w", err) + } + result.Canonical = canonical + } + result.Ref.Reference = tag + if ns, _, ok := strings.Cut(reference.Path(tag), "/"); ok { + result.Namespace = ns + } + results = append(results, result) + } return results, nil }