diff --git a/.github/workflows/promote.yaml b/.github/workflows/promote.yaml new file mode 100644 index 00000000..42e7bb0f --- /dev/null +++ b/.github/workflows/promote.yaml @@ -0,0 +1,15 @@ +name: promote + +on: + push: + branches: [prod-staging] + workflow_dispatch: + +permissions: + contents: read + +jobs: + promote: + uses: unikraft-cloud/x/.github/workflows/promote.yaml@prod-staging + secrets: + gh_pat: ${{ secrets.GH_PAT }} diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index df3e6dc2..c6e25ca2 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -161,6 +161,7 @@ jobs: UNIKRAFT_TOKEN: ${{ secrets.UNIKRAFT_TOKEN }} UNIKRAFT_ENDPOINT: ${{ secrets[format('UNIKRAFT_ENDPOINT_{0}', matrix.target)] }} UNIKRAFT_X_TEST_SERVER: ${{ env[format('UNIKRAFT_SERVER_{0}', matrix.target)] }} + UNIKRAFT_X_TEST_BUILD_URL: ${{ matrix.target == 'STABLE' && secrets.UNIKRAFT_BUILD_URL_STABLE || '' }} # The hosted Windows runners reach only the public prod endpoint. The runners # take turns, so that both do not use the same organization at once. diff --git a/cmd/unikraft/help_test.go b/cmd/unikraft/help_test.go index 0c59c946..49fe713c 100644 --- a/cmd/unikraft/help_test.go +++ b/cmd/unikraft/help_test.go @@ -206,6 +206,10 @@ func buildHelpTests(t *testing.T, unikraftPath string) { r := integ.NewTestEnv(t, unikraftPath) integ.Gild(t, cli(r), []string{"unikraft", "build", "--help"}, + []string{"unikraft", "builder", "--help"}, + []string{"unikraft", "builder", "create", "--help"}, + []string{"unikraft", "builder", "du", "--help"}, + []string{"unikraft", "builder", "prune", "--help"}, ) } diff --git a/cmd/unikraft/integration/build_test.go b/cmd/unikraft/integration/build_test.go index 17739c5e..ec98a258 100644 --- a/cmd/unikraft/integration/build_test.go +++ b/cmd/unikraft/integration/build_test.go @@ -6,7 +6,10 @@ package integration import ( + "encoding/pem" "fmt" + "os" + "path/filepath" "testing" "github.com/containerd/continuity/fs/fstest" @@ -318,4 +321,49 @@ cmd: ["sh", "-c", "cat /etc/unikraft-e2e && echo KRAFTFILE_CMD_USED"] r.Run(t, []string{"unikraft", "instance", "delete", "test-" + instName}) }) + + t.Run("remote", func(t *testing.T) { + buildURL := os.Getenv("UNIKRAFT_X_TEST_BUILD_URL") + if buildURL == "" { + t.Skip("remote build test requires UNIKRAFT_X_TEST_BUILD_URL") + } + r := runner(t, true, []string{staging, stable}) + + image := r.Profile.Organization + "/remote-e2e:" + uniq() + instName := uniq() + + // The build service's certificate may be self-signed. + certFile := filepath.Join(t.TempDir(), "build-service.pem") + cert := integ.HTTPGetTLSCerts(t, buildURL, "")[0] + require.NoError(t, os.WriteFile(certFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}), 0o644)) + + dir := t.TempDir() + require.NoError(t, fstest.Apply( + fstest.CreateFile("Dockerfile", []byte(` +FROM busybox:latest +RUN --mount=type=secret,id=greeting cp /run/secrets/greeting /greeting +`), 0o644), + fstest.CreateFile("Kraftfile", []byte(` +spec: v0.7 +runtime: base-compat:latest +rootfs: + format: erofs + source: ./Dockerfile +cmd: ["sh", "-c", "cat /greeting"] +`), 0o644), + fstest.CreateFile("greeting", []byte("REMOTE_BUILD_OK\n"), 0o644), + ).Apply(dir)) + + // The service reads the project and the secret through the session. + r.Run(t, []string{"unikraft", "build", ".", "--remote", buildURL, "--secret", "id=greeting,src=" + filepath.Join(dir, "greeting"), "--output", image}, + integ.WithWorkDir(dir), integ.WithEnv("SSL_CERT_FILE="+certFile)) + + r.Run(t, []string{"unikraft", "run", "--name", "test-" + instName, "--metro", r.MetroName, "--output", "quiet", "--image", image}) + r.Run(t, []string{"unikraft", "--timeout", "10s", "instance", "wait", "--until", "state==stopped", "test-" + instName}) + + out := r.Run(t, []string{"unikraft", "instance", "logs", "test-" + instName}) + assert.Regexp(t, `REMOTE_BUILD_OK`, out) + + r.Run(t, []string{"unikraft", "instance", "delete", "test-" + instName}) + }) } diff --git a/cmd/unikraft/integration/builder_test.go b/cmd/unikraft/integration/builder_test.go new file mode 100644 index 00000000..31dcba38 --- /dev/null +++ b/cmd/unikraft/integration/builder_test.go @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2026, Unikraft GmbH and The Unikraft CLI Authors. +// Licensed under the BSD-3-Clause License (the "License"). +// You may not use this file except in compliance with the License. + +package integration + +import ( + "encoding/pem" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + integ "unikraft.com/cli/internal/integration" +) + +func TestBuilder(t *testing.T) { + buildURL := os.Getenv("UNIKRAFT_X_TEST_BUILD_URL") + if buildURL == "" { + t.Skip("builder test requires UNIKRAFT_X_TEST_BUILD_URL") + } + r := runner(t, true, []string{staging, stable}) + + // The build service's certificate may be self-signed. + certFile := filepath.Join(t.TempDir(), "build-service.pem") + cert := integ.HTTPGetTLSCerts(t, buildURL, "")[0] + require.NoError(t, os.WriteFile(certFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}), 0o644)) + env := integ.WithEnv("SSL_CERT_FILE="+certFile, "UNIKRAFT_BUILD_URL="+buildURL) + + // The account's remote builds share the builder, so it is not deleted. + out := r.Run(t, []string{"unikraft", "builder", "create"}, env) + assert.Regexp(t, `builder ready`, out) + + out = r.Run(t, []string{"unikraft", "builder", "du"}, env) + assert.Regexp(t, `(?m)^Reclaimable: +\S+\nTotal: +\S+$`, out) + + out = r.Run(t, []string{"unikraft", "builder", "prune"}, env) + assert.Regexp(t, `pruned the builder's cache freed=\S+`, out) +} diff --git a/cmd/unikraft/integration/instance_http_oci_test.go b/cmd/unikraft/integration/instance_http_oci_test.go index bdb57f00..ba727357 100644 --- a/cmd/unikraft/integration/instance_http_oci_test.go +++ b/cmd/unikraft/integration/instance_http_oci_test.go @@ -26,17 +26,40 @@ import ( ) // httpOCIServer serves a volume mounted at /data as http+oci layouts: the node -// only fetches a layout if it is served with the layout media type. +// only fetches a layout if it is served with the layout media type. A tag is a +// file holding the digest it redirects to. var httpOCIServer = &integ.SharedImage{ Name: "http-oci-server-e2e", Files: map[string]string{ "main.go": `package main -import "net/http" +import ( + "io" + "net/http" + "path" + "strings" +) func main() { - files := http.FileServer(http.Dir("/data")) + data := http.Dir("/data") + files := http.FileServer(data) http.ListenAndServe(":8080", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + dir, ref := path.Split(r.URL.Path) + if !strings.HasPrefix(ref, "@") { + f, err := data.Open(r.URL.Path) + if err != nil { + http.NotFound(w, r) + return + } + defer f.Close() + dgst, err := io.ReadAll(f) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + http.Redirect(w, r, dir+strings.TrimSpace(string(dgst)), http.StatusFound) + return + } w.Header().Set("Content-Type", "application/vnd.oci.layout.v1+tar") files.ServeHTTP(w, r) })) @@ -60,13 +83,12 @@ cmd: ["/server"] }, } -func TestInstancesHTTPOCI(t *testing.T) { +func TestHTTPOCI(t *testing.T) { r := runner(t, true, []string{staging, stable}) payload := integ.Busybox.Build(t, r) server := httpOCIServer.Build(t, r) volName := "test-" + uniq() serverName := "test-" + uniq() - instName := "test-" + uniq() layout := filepath.Join(t.TempDir(), "layout") r.Run(t, []string{"unikraft", "image", "copy", payload, "oci-layout://" + layout}) @@ -74,6 +96,7 @@ func TestInstancesHTTPOCI(t *testing.T) { repository := r.Profile.Organization + "/http-oci-e2e" content := t.TempDir() dgst := tarLayout(t, layout, filepath.Join(content, repository)) + require.NoError(t, os.WriteFile(filepath.Join(content, repository, "latest"), []byte("@"+dgst), 0o644)) r.Run(t, []string{ "unikraft", "volume", "create", "--output", "quiet", @@ -86,6 +109,9 @@ func TestInstancesHTTPOCI(t *testing.T) { "unikraft", "run", "--name", serverName, "--metro", r.MetroName, "--output", "quiet", "--image", server, "-p", "80:8080/http", "-v", volName + ":/data", }) + t.Cleanup(func() { + r.Run(t, []string{"unikraft", "instance", "delete", serverName}, integ.WithoutCancel()) + }) r.Run(t, []string{"unikraft", "--timeout", "30s", "instance", "wait", "--until", "state==running", serverName}) fqdn := strings.TrimSpace(r.Run(t, []string{ "unikraft", "instance", "inspect", serverName, @@ -93,20 +119,41 @@ func TestInstancesHTTPOCI(t *testing.T) { })) require.NotEmpty(t, fqdn) - image := "http+oci://" + fqdn + "/" + repository + "/@" + dgst - r.Run(t, []string{ - "unikraft", "run", "--name", instName, "--metro", r.MetroName, "--output", "quiet", - "--image", image, "--args", "echo UNIKRAFT_HTTP_OCI_OK", - }) - r.Run(t, []string{"unikraft", "--timeout", "60s", "instance", "wait", "--until", "state==stopped", instName}) - - out := r.Run(t, []string{"unikraft", "instance", "inspect", instName}) - assert.Contains(t, out, image) - out = r.Run(t, []string{"unikraft", "instance", "logs", instName}) - assert.Contains(t, out, "UNIKRAFT_HTTP_OCI_OK") - - r.Run(t, []string{"unikraft", "instance", "delete", instName}) - r.Run(t, []string{"unikraft", "instance", "delete", serverName}) + for name, image := range map[string]string{ + "tag": "http+oci://" + fqdn + "/" + repository + "/latest", + "digest": "http+oci://" + fqdn + "/" + repository + "/@" + dgst, + } { + t.Run(name, func(t *testing.T) { + t.Run("inspect", func(t *testing.T) { + out := r.Run(t, []string{"unikraft", "image", "inspect", image}) + assert.Contains(t, out, image) + assert.Regexp(t, `platform:\s+kraftcloud/x86_64`, out) + }) + + t.Run("copy", func(t *testing.T) { + archive := filepath.Join(t.TempDir(), "image.oci.tar") + r.Run(t, []string{"unikraft", "image", "copy", image, archive}) + out := r.Run(t, []string{"unikraft", "image", "inspect", archive}) + assert.Regexp(t, `platform:\s+kraftcloud/x86_64`, out) + }) + + t.Run("run", func(t *testing.T) { + instName := "test-" + uniq() + r.Run(t, []string{ + "unikraft", "run", "--name", instName, "--metro", r.MetroName, "--output", "quiet", + "--image", image, "--args", "echo UNIKRAFT_HTTP_OCI_OK", + }) + r.Run(t, []string{"unikraft", "--timeout", "60s", "instance", "wait", "--until", "state==stopped", instName}) + + out := r.Run(t, []string{"unikraft", "instance", "inspect", instName}) + assert.Contains(t, out, fqdn+"/"+repository) + out = r.Run(t, []string{"unikraft", "instance", "logs", instName}) + assert.Contains(t, out, "UNIKRAFT_HTTP_OCI_OK") + + r.Run(t, []string{"unikraft", "instance", "delete", instName}) + }) + }) + } } // tarLayout packs the layout at src into dir, named by its digest as http+oci diff --git a/cmd/unikraft/integration/sandbox_test.go b/cmd/unikraft/integration/sandbox_test.go index d1d9fda1..1f4c1c38 100644 --- a/cmd/unikraft/integration/sandbox_test.go +++ b/cmd/unikraft/integration/sandbox_test.go @@ -83,6 +83,8 @@ func TestSandbox(t *testing.T) { // whatever the command printed. r.Run(t, []string{"unikraft", "instance", "exec", instName, "--", "sh", "-c", "exit 3"}, integ.ExpectExitCode(3)) + r.Run(t, []string{"unikraft", "instance", "exec", instName, "--", "sh", "-c", "kill -KILL $$"}, + integ.ExpectExitCode(137)) // The command is a command line by the time it reaches the plugin, so // arguments the shell would otherwise split or expand are quoted first. diff --git a/cmd/unikraft/testdata/TestHelp/build b/cmd/unikraft/testdata/TestHelp/build index 21dc5b32..586ababb 100644 --- a/cmd/unikraft/testdata/TestHelp/build +++ b/cmd/unikraft/testdata/TestHelp/build @@ -48,6 +48,159 @@ Flags: SSH agent socket or keys to expose to the build (format: "default|[=|[,]]"). --insecure Allow insecure (HTTP/unverified TLS) connections to registries. Specify hostnames to restrict, or omit to apply to all. + --remote= ($UNIKRAFT_BUILD_URL) + Build on the build service at this URL and push the image from there, instead of building locally. + +Global flags: + -h, --help + Show context-sensitive help. + --config= ($UNIKRAFT_CONFIG) + Path to the configuration file. + --log-level= ($UNIKRAFT_LOG_LEVEL) + Set the logging level. + [default: info, choices: trace, debug, info, warn, error, fatal] + --log-type= ($UNIKRAFT_LOG_TYPE) + Set the log type. + [default: text, choices: text, json] + --profile= ($UNIKRAFT_PROFILE) + Set the current profile. + --[no-]telemetry ($UNIKRAFT_TELEMETRY) + Toggle usage analytics. + [default: true] + --timeout= ($UNIKRAFT_TIMEOUT) + Set a deadline for the command (e.g. 30s, 5m, 1h). + +$ unikraft builder --help + +Manage the builders that remote builds run on. + +Usage: + unikraft builder [flags] + +Commands: + create + Create a builder that remote builds run on. + du + Show the disk usage of a builder's cache. + prune + Free a builder's cache, keeping the builder. + +Global flags: + -h, --help + Show context-sensitive help. + --config= ($UNIKRAFT_CONFIG) + Path to the configuration file. + --log-level= ($UNIKRAFT_LOG_LEVEL) + Set the logging level. + [default: info, choices: trace, debug, info, warn, error, fatal] + --log-type= ($UNIKRAFT_LOG_TYPE) + Set the log type. + [default: text, choices: text, json] + --profile= ($UNIKRAFT_PROFILE) + Set the current profile. + --[no-]telemetry ($UNIKRAFT_TELEMETRY) + Toggle usage analytics. + [default: true] + --timeout= ($UNIKRAFT_TIMEOUT) + Set a deadline for the command (e.g. 30s, 5m, 1h). + +$ unikraft builder create --help + +Create a builder that remote builds run on. + +Usage: + unikraft builder create --remote=url [] + +Arguments: + [] + Name of the builder. + [default: default] + +Examples: + # Create the builder of remote builds + unikraft builder create --remote https://build.example.com + +Flags: + --remote= ($UNIKRAFT_BUILD_URL) + URL of the build service that manages the builder. + +Global flags: + -h, --help + Show context-sensitive help. + --config= ($UNIKRAFT_CONFIG) + Path to the configuration file. + --log-level= ($UNIKRAFT_LOG_LEVEL) + Set the logging level. + [default: info, choices: trace, debug, info, warn, error, fatal] + --log-type= ($UNIKRAFT_LOG_TYPE) + Set the log type. + [default: text, choices: text, json] + --profile= ($UNIKRAFT_PROFILE) + Set the current profile. + --[no-]telemetry ($UNIKRAFT_TELEMETRY) + Toggle usage analytics. + [default: true] + --timeout= ($UNIKRAFT_TIMEOUT) + Set a deadline for the command (e.g. 30s, 5m, 1h). + +$ unikraft builder du --help + +Show the disk usage of a builder's cache. + +Usage: + unikraft builder du --remote=url [] + +Arguments: + [] + Name of the builder. + [default: default] + +Examples: + # Show how much cache the builder of remote builds holds + unikraft builder du --remote https://build.example.com + +Flags: + --remote= ($UNIKRAFT_BUILD_URL) + URL of the build service that manages the builder. + +Global flags: + -h, --help + Show context-sensitive help. + --config= ($UNIKRAFT_CONFIG) + Path to the configuration file. + --log-level= ($UNIKRAFT_LOG_LEVEL) + Set the logging level. + [default: info, choices: trace, debug, info, warn, error, fatal] + --log-type= ($UNIKRAFT_LOG_TYPE) + Set the log type. + [default: text, choices: text, json] + --profile= ($UNIKRAFT_PROFILE) + Set the current profile. + --[no-]telemetry ($UNIKRAFT_TELEMETRY) + Toggle usage analytics. + [default: true] + --timeout= ($UNIKRAFT_TIMEOUT) + Set a deadline for the command (e.g. 30s, 5m, 1h). + +$ unikraft builder prune --help + +Free a builder's cache, keeping the builder. + +Usage: + unikraft builder prune --remote=url [] + +Arguments: + [] + Name of the builder. + [default: default] + +Examples: + # Free the cache of the builder of remote builds + unikraft builder prune --remote https://build.example.com + +Flags: + --remote= ($UNIKRAFT_BUILD_URL) + URL of the build service that manages the builder. Global flags: -h, --help diff --git a/cmd/unikraft/testdata/TestHelp/general b/cmd/unikraft/testdata/TestHelp/general index 639579c5..e1f0c49a 100644 --- a/cmd/unikraft/testdata/TestHelp/general +++ b/cmd/unikraft/testdata/TestHelp/general @@ -16,6 +16,8 @@ Commands: Run an image as an instance. build Build a Unikraft project into a container image. + builder + Manage the builders that remote builds run on. tui Browse resources in a TUI. @@ -95,7 +97,7 @@ Run "unikraft --help" for more information on a command. │ │ error: -│ parsing arguments: expected one of "run", "build", "tui", "metros", "quotas", ... +│ parsing arguments: expected one of "run", "build", "builder", "tui", "metros", ... │ exit code: 1 @@ -118,6 +120,8 @@ Commands: Run an image as an instance. build Build a Unikraft project into a container image. + builder + Manage the builders that remote builds run on. tui Browse resources in a TUI. @@ -222,6 +226,8 @@ Commands: Run an image as an instance. build Build a Unikraft project into a container image. + builder + Manage the builders that remote builds run on. tui Browse resources in a TUI. @@ -323,6 +329,8 @@ Commands: Run an image as an instance. build Build a Unikraft project into a container image. + builder + Manage the builders that remote builds run on. tui Browse resources in a TUI. diff --git a/cmd/unikraft/testdata/TestHelp/images b/cmd/unikraft/testdata/TestHelp/images index 80f014cf..9441ab58 100644 --- a/cmd/unikraft/testdata/TestHelp/images +++ b/cmd/unikraft/testdata/TestHelp/images @@ -92,6 +92,8 @@ Flags: SSH agent socket or keys to expose to the build (format: "default|[=|[,]]"). --insecure Allow insecure (HTTP/unverified TLS) connections to registries. Specify hostnames to restrict, or omit to apply to all. + --remote= ($UNIKRAFT_BUILD_URL) + Build on the build service at this URL and push the image from there, instead of building locally. Global flags: -h, --help diff --git a/go.mod b/go.mod index f034a863..cc72b4c5 100644 --- a/go.mod +++ b/go.mod @@ -39,6 +39,7 @@ require ( github.com/sirupsen/logrus v1.10.2 github.com/stretchr/testify v1.12.1 github.com/tidwall/gjson v1.19.0 + github.com/tonistiigi/fsutil v0.0.0-20260819142231-83cac42c1c52 github.com/unikraft/go-archivefs v0.0.0-20261019091157-8981da32f72d github.com/unikraft/go-cpio v0.0.0-20260415131742-4f1984ca41f3 golang.org/x/mod v0.41.0 @@ -51,9 +52,10 @@ require ( sigs.k8s.io/yaml v1.6.0 unikraft.com/cloud/plugins/sandbox v0.0.0-20260916204449-fe2a902ae04c unikraft.com/cloud/sdk v0.3.1-0.20261021152129-3431e7639ef3 - unikraft.com/cloud/sdk/plugins/sandbox v0.0.0-20260925102313-8051b659e1ce + unikraft.com/cloud/sdk/build v0.0.0-20261030150712-62563f400155 + unikraft.com/cloud/sdk/plugins/sandbox v0.0.0-20261027110526-41cac40eecd3 unikraft.com/x/build v0.0.0-20261020121654-15b23b7bb4a5 - unikraft.com/x/buildkit v0.0.0-20261016091434-6ed7891be538 + unikraft.com/x/buildkit v0.0.0-20261027124450-dfcf25878f57 unikraft.com/x/colors v0.0.0-20261007115714-ef931437a206 unikraft.com/x/filters v0.0.0-20260929150216-7e946e02e58b unikraft.com/x/fingerprint v0.0.0-20261001064809-1661668e8c87 @@ -65,7 +67,7 @@ require ( unikraft.com/x/kraftfile v0.0.0-20261020121654-15b23b7bb4a5 unikraft.com/x/log v0.0.0-20261007115714-ef931437a206 unikraft.com/x/ptr v0.0.0-20260126094137-ab6e717e5679 - unikraft.com/x/shell v0.0.0-20261022090906-56846b6bd1b6 + unikraft.com/x/shell v0.0.0-20261027110532-fc72bba849a7 unikraft.com/x/signal v0.0.0-20260917103502-010252eb5050 unikraft.com/x/stdio v0.0.0-20260917103502-010252eb5050 unikraft.com/x/version v0.0.0-20260819081122-82fdef94867a @@ -171,7 +173,6 @@ require ( github.com/tidwall/pretty v1.2.0 // indirect github.com/tklauser/go-sysconf v0.4.0 // indirect github.com/tklauser/numcpus v0.12.0 // indirect - github.com/tonistiigi/fsutil v0.0.0-20260819142231-83cac42c1c52 // indirect github.com/tonistiigi/go-csvvalue v0.0.0-20240814133006-030d3b2625d0 // indirect github.com/tonistiigi/units v0.0.0-20180711220420-6950e57a87ea // indirect github.com/tonistiigi/vt100 v0.0.0-20240514184818-90bafcd6abab // indirect diff --git a/go.sum b/go.sum index 0e5aef7b..d0b05743 100644 --- a/go.sum +++ b/go.sum @@ -479,12 +479,14 @@ unikraft.com/cloud/plugins/sandbox v0.0.0-20260916204449-fe2a902ae04c h1:ZNaDV2b unikraft.com/cloud/plugins/sandbox v0.0.0-20260916204449-fe2a902ae04c/go.mod h1:QFvqlzpb/oOceeLG9Rbcy/B8Q6VVVRZcLzH/TZCF2os= unikraft.com/cloud/sdk v0.3.1-0.20261021152129-3431e7639ef3 h1:I3pw14Ia3LsXZvAiB5ouh03JpIg73ekQF+c4mCn4sTM= unikraft.com/cloud/sdk v0.3.1-0.20261021152129-3431e7639ef3/go.mod h1:/wX3Ltbi+rSzpS6zn2v6qLSP/7OLQqDWDp5VJ5ZVHlg= -unikraft.com/cloud/sdk/plugins/sandbox v0.0.0-20260925102313-8051b659e1ce h1:8FyDc0aUycxmazS64bOzjCtMIeCgaodTxXUhQkmgDcY= -unikraft.com/cloud/sdk/plugins/sandbox v0.0.0-20260925102313-8051b659e1ce/go.mod h1:FmqgK+oA/ujOKGzcdZm3LeVS02rFkbt08DWY0BqKziM= +unikraft.com/cloud/sdk/build v0.0.0-20261030150712-62563f400155 h1:5zT2aaoqTF2QWYZfaELV56U6dlTAavWacBZL7ivy8Mw= +unikraft.com/cloud/sdk/build v0.0.0-20261030150712-62563f400155/go.mod h1:OjYfwRrWPqedgEELmpFini+YtCUT8wrP7+oqxhbn07M= +unikraft.com/cloud/sdk/plugins/sandbox v0.0.0-20261027110526-41cac40eecd3 h1:hNg3DuK7QTb2Ybx63qoJ+J0M2zDRETDZiDJbaDy0jPU= +unikraft.com/cloud/sdk/plugins/sandbox v0.0.0-20261027110526-41cac40eecd3/go.mod h1:FmqgK+oA/ujOKGzcdZm3LeVS02rFkbt08DWY0BqKziM= unikraft.com/x/build v0.0.0-20261020121654-15b23b7bb4a5 h1:j6VeahXa2Nl0UClVQ55E9hOYbhO8yGFlgNXvOY2Be9s= unikraft.com/x/build v0.0.0-20261020121654-15b23b7bb4a5/go.mod h1:bWH9EPozJIDfhaCyYD2l8BYIuiBYtZm2/uF8hXzovzY= -unikraft.com/x/buildkit v0.0.0-20261016091434-6ed7891be538 h1:hqwzD2mY8iDnSlEisVOka0BY681O0kj60cCUpmVh0PY= -unikraft.com/x/buildkit v0.0.0-20261016091434-6ed7891be538/go.mod h1:JHFqrP+N1B4Lu8VXmt0lKuhX6LCqZIEvIzM5NkOnAAw= +unikraft.com/x/buildkit v0.0.0-20261027124450-dfcf25878f57 h1:9MIeRql7SH/fNACV6ssGVFEmlW9M/bfmBFcPEVuYV3k= +unikraft.com/x/buildkit v0.0.0-20261027124450-dfcf25878f57/go.mod h1:dv0325XVI1G1IsJDY6qjfFJKemm2u4UWRnvCDg0rFrg= unikraft.com/x/colors v0.0.0-20261007115714-ef931437a206 h1:5ib843DltfcnYQZ+S2kpR1+atvum3Js/zE1nAz4f2Xs= unikraft.com/x/colors v0.0.0-20261007115714-ef931437a206/go.mod h1:21D4S0qGuffQi3xdL24P8AoHhXT/Oo6neAvkaYnD5/Q= unikraft.com/x/filters v0.0.0-20260929150216-7e946e02e58b h1:9ixkamFqXxVHrMTmYfu7Yf25klhCaoI5q7sq8PG5rY8= @@ -507,8 +509,8 @@ unikraft.com/x/log v0.0.0-20261007115714-ef931437a206 h1:A8mOWakAYfEPWcdXmoQabDE unikraft.com/x/log v0.0.0-20261007115714-ef931437a206/go.mod h1:LLyTMqp+RTeEx1wxuIFlOptMzUGa8y4BRo6c1LFFI1Q= unikraft.com/x/ptr v0.0.0-20260126094137-ab6e717e5679 h1:ohptI7loX492JCbbn6yk13hAxysZHyGIZWN8hTw3080= unikraft.com/x/ptr v0.0.0-20260126094137-ab6e717e5679/go.mod h1:une+KauhLNYkBFnT0R4bzVvD8bJ54xACjXMBPMnc5ig= -unikraft.com/x/shell v0.0.0-20261022090906-56846b6bd1b6 h1:fTl26Yn4i8pyzPipJewBYpnMfbtNmpO56XW26TGyKcs= -unikraft.com/x/shell v0.0.0-20261022090906-56846b6bd1b6/go.mod h1:iJkbKSCvVVaXRMJU5j5Supth1UxtEdC2Ozzdv7yP878= +unikraft.com/x/shell v0.0.0-20261027110532-fc72bba849a7 h1:9GoJpMmTB/Ut1ruEQxRofGkeWn2bKgtW1lNNmE1LwYg= +unikraft.com/x/shell v0.0.0-20261027110532-fc72bba849a7/go.mod h1:iJkbKSCvVVaXRMJU5j5Supth1UxtEdC2Ozzdv7yP878= unikraft.com/x/signal v0.0.0-20260917103502-010252eb5050 h1:hs1tg9QrQf+S0Xch8HRN3gNlOaLDlC7FAp+OIrZN8tM= unikraft.com/x/signal v0.0.0-20260917103502-010252eb5050/go.mod h1:S0cdyYEpi1FUKmxZPr4jQKeGQhNQvrppYTfyKdhHl98= unikraft.com/x/stdio v0.0.0-20260917103502-010252eb5050 h1:H5mvC13Yi8nCf7s9WEoMBL2MYE0qSWO9SWA7l6AFWPo= diff --git a/internal/cmd/build.go b/internal/cmd/build.go index 473beb6f..4a727312 100644 --- a/internal/cmd/build.go +++ b/internal/cmd/build.go @@ -23,6 +23,8 @@ type ImageBuildCmd struct { SSH []string `sep:"none" help:"SSH agent socket or keys to expose to the build (format: \"default|[=|[,]]\")."` Insecure []string `help:"Allow insecure (HTTP/unverified TLS) connections to registries. Specify hostnames to restrict, or omit to apply to all." type:"optional"` + + Remote string `env:"UNIKRAFT_BUILD_URL" placeholder:"url" help:"Build on the build service at this URL and push the image from there, instead of building locally."` } func (ImageBuildCmd) Examples() []kingkong.Example { diff --git a/internal/cmd/build_native.go b/internal/cmd/build_native.go index d14ffe61..8f31cc1a 100644 --- a/internal/cmd/build_native.go +++ b/internal/cmd/build_native.go @@ -55,21 +55,28 @@ func newBuilder(pw progress.Writer) build.Builder { return d.Client, func() { _ = d.Release() }, nil }, Session: func(ctx context.Context) ([]session.Attachable, error) { - profile, err := config.G(ctx).CurrentProfile() + auth, err := authAttachable(ctx) if err != nil { return nil, err } - dockerConfig := dockerconfig.LoadDefaultConfigFile(os.Stderr) - return []session.Attachable{ - authprovider.NewDockerAuthProvider(authprovider.DockerAuthProviderConfig{ - AuthConfigProvider: images.LoadBuildkitAuthConfig(dockerConfig, profile), - }), - }, nil + return []session.Attachable{auth}, nil }, Progress: pw, } } +// authAttachable answers registry logins from the docker config and the current profile. +func authAttachable(ctx context.Context) (session.Attachable, error) { + profile, err := config.G(ctx).CurrentProfile() + if err != nil { + return nil, err + } + dockerConfig := dockerconfig.LoadDefaultConfigFile(os.Stderr) + return authprovider.NewDockerAuthProvider(authprovider.DockerAuthProviderConfig{ + AuthConfigProvider: images.LoadBuildkitAuthConfig(dockerConfig, profile), + }), nil +} + // buildWithProgress runs fn on a builder that draws its progress to stderr, // and returns once the display has finished drawing. func buildWithProgress(ctx context.Context, fn func(build.Builder) error) error { @@ -85,6 +92,10 @@ func buildWithProgress(ctx context.Context, fn func(build.Builder) error) error } func (c *ImageBuildCmd) Run(ctx context.Context, cfg *config.Config, partition *resource.Partition) error { + if c.Remote != "" { + return c.runRemote(ctx, cfg, partition) + } + buildOpts, err := build.LoadBuildOpts(ctx, c.Input) if err != nil { return err diff --git a/internal/cmd/build_remote.go b/internal/cmd/build_remote.go new file mode 100644 index 00000000..3c88caf1 --- /dev/null +++ b/internal/cmd/build_remote.go @@ -0,0 +1,269 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2026, Unikraft GmbH and The Unikraft CLI Authors. +// Licensed under the BSD-3-Clause License (the "License"). +// You may not use this file except in compliance with the License. + +//go:build !js + +package cmd + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "net/url" + "os" + "path/filepath" + "slices" + "strings" + "time" + + "github.com/moby/buildkit/session" + "github.com/moby/buildkit/session/filesync" + "github.com/moby/buildkit/util/progress/progressui" + "github.com/tonistiigi/fsutil" + buildapi "unikraft.com/cloud/sdk/build" + "unikraft.com/x/build/buildflags" + "unikraft.com/x/build/progress" + imagespec "unikraft.com/x/image-spec" + "unikraft.com/x/image-spec/reference" + "unikraft.com/x/image-spec/schemes" + "unikraft.com/x/kraftfile" + "unikraft.com/x/log" + + "unikraft.com/cli/internal/config" + "unikraft.com/cli/internal/resource" +) + +// remoteContext is the name the session serves the build context under. +const remoteContext = "context" + +// runRemote builds on the build service at c.Remote, which pushes the image. +func (c *ImageBuildCmd) runRemote(ctx context.Context, cfg *config.Config, partition *resource.Partition) error { + secrets, err := buildflags.ParseSecretSpecs(c.Secret) + if err != nil { + return err + } + ssh, err := buildflags.ParseSSHSpecs(c.SSH) + if err != nil { + return err + } + req, dir, err := c.remoteRequest(secrets, ssh) + if err != nil { + return err + } + + profile, err := cfg.CurrentProfile() + if err != nil { + return err + } + client, err := buildapi.New(c.Remote, buildapi.WithToken(profile.Token)) + if err != nil { + return err + } + log.G(ctx).Info().Str("url", c.Remote).Msg("building remotely") + + s, err := remoteSession(ctx, dir, secrets, ssh) + if err != nil { + return err + } + defer s.Close() + + disp, err := progress.NewDisplay(ctx, os.Stderr, progressui.AutoMode) + if err != nil { + return err + } + err = followRemote(ctx, client, req, s, disp) + if cerr := disp.Close(); err == nil { + err = cerr + } + if err != nil { + return err + } + + if partition != nil { + if err := addImageToPartition(ctx, partition, *req.Output[0].Image); err != nil { + return fmt.Errorf("adding built image to partition: %w", err) + } + } + return nil +} + +// followRemote creates the build, attaches s to it and draws its progress until it ends. +func followRemote(ctx context.Context, client *buildapi.Client, req buildapi.CreateBuildRequest, s *session.Session, disp *progress.Display) error { + b, err := client.CreateBuild(ctx, req) + if err != nil { + return err + } + if b.Session != nil { + if err := client.Attach(ctx, b.ID, s); err != nil { + return errors.Join(err, cancelRemote(ctx, client, b.ID)) + } + } + + for ev, err := range client.Logs(ctx, b.ID) { + if err != nil { + if ctx.Err() != nil { + return errors.Join(err, cancelRemote(ctx, client, b.ID)) + } + return err + } + if ev.SolveStatus != nil { + disp.Write(ev.SolveStatus) + } + if ev.Error != nil { + return ev.Error + } + if ev.Result != nil { + for _, img := range ev.Result.Images { + log.G(ctx).Info().Str("output", img.Ref).Str("digest", img.Digest).Msg("pushed image") + } + } + } + return nil +} + +// cancelRemote cancels the remote build id, also once ctx has ended, waiting at most 10 seconds. +func cancelRemote(ctx context.Context, client *buildapi.Client, id string) error { + ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) + defer cancel() + _, err := client.CancelBuild(ctx, id) + return err +} + +// remoteRequest returns the request for a remote build of c, and the directory its session serves. +func (c *ImageBuildCmd) remoteRequest(secrets []*buildflags.Secret, ssh []*buildflags.SSH) (buildapi.CreateBuildRequest, string, error) { + if c.Insecure != nil { + return buildapi.CreateBuildRequest{}, "", errors.New("--insecure does not apply to a remote build") + } + u, err := url.Parse(c.Remote) + if err != nil { + return buildapi.CreateBuildRequest{}, "", err + } + // The service gets the profile's token, the project and its secrets. + if u.Scheme == "http" { + return buildapi.CreateBuildRequest{}, "", fmt.Errorf("the build service %s must be https", c.Remote) + } + if c.Output == "" { + return buildapi.CreateBuildRequest{}, "", errors.New("a remote build needs --output, the image to push") + } + output, err := imagespec.GuessLocation(c.Output) + if err != nil { + return buildapi.CreateBuildRequest{}, "", err + } + if output.Scheme != schemes.OCI { + return buildapi.CreateBuildRequest{}, "", fmt.Errorf("a remote build pushes to a registry, not to %s", output) + } + ref, err := reference.Parse(output.Path, reference.WithDefaultDomain("index.unikraft.io")) + if err != nil { + return buildapi.CreateBuildRequest{}, "", err + } + + fi, err := os.Stat(c.Input) + if err != nil { + return buildapi.CreateBuildRequest{}, "", err + } + dir, name := c.Input, "" + if fi.IsDir() { + for _, n := range kraftfile.DefaultFileNames { + if _, err := os.Stat(filepath.Join(dir, n)); err == nil { + name = n + break + } + } + } else { + dir, name = filepath.Dir(c.Input), filepath.Base(c.Input) + } + + src := buildapi.SessionSource(remoteContext) + req := buildapi.CreateBuildRequest{ + Contexts: buildapi.BuildContexts{Context: src}, + Platforms: c.Arch, + NoCache: new(c.NoCache), + Registries: map[string]buildapi.Registry{"*": buildapi.SessionRegistry()}, + Output: []buildapi.BuildOutput{buildapi.Image(ref.String())}, + } + switch { + case name == "": // a directory without a Kraftfile builds its Dockerfile + case slices.Contains(strings.Split(name, "."), "Dockerfile"): // as x/build tells one + + req.Contexts.Dockerfile = new(src.WithFilename(name)) + default: + req.Kraftfile = buildapi.KraftfileFrom(src.WithFilename(name)) + } + + for _, arg := range c.BuildArg { + key, val, ok := strings.Cut(arg, "=") + if key == "" { + return buildapi.CreateBuildRequest{}, "", fmt.Errorf("invalid build-arg %q", arg) + } + if !ok { + val, _ = os.LookupEnv(key) + } + if req.BuildArgs == nil { + req.BuildArgs = map[string]string{} + } + req.BuildArgs[key] = val + } + + for _, secret := range secrets { + if req.Secrets == nil { + req.Secrets = map[string]buildapi.Secret{} + } + req.Secrets[secret.ID] = buildapi.SessionSecret(secret.ID) + } + + for _, agent := range ssh { + if req.SSH == nil { + req.SSH = map[string]buildapi.SSHAgent{} + } + req.SSH[agent.ID] = buildapi.SSHAgent{Session: agent.ID} + } + + return req, dir, nil +} + +// remoteSession serves dir, the registry logins, secrets and ssh to a remote build. +func remoteSession(ctx context.Context, dir string, secrets []*buildflags.Secret, ssh []*buildflags.SSH) (*session.Session, error) { + abs, err := filepath.Abs(dir) + if err != nil { + return nil, err + } + // The key keeps filesync incremental across builds of dir without sending its path. + sum := sha256.Sum256([]byte(abs)) + s, err := session.NewSession(ctx, hex.EncodeToString(sum[:])) + if err != nil { + return nil, err + } + + fs, err := fsutil.NewFS(abs) + if err != nil { + return nil, err + } + s.Allow(filesync.NewFSSyncProvider(filesync.StaticDirSource{remoteContext: fs})) + + auth, err := authAttachable(ctx) + if err != nil { + return nil, err + } + s.Allow(auth) + + if len(secrets) > 0 { + a, err := buildflags.CreateSecrets(secrets) + if err != nil { + return nil, err + } + s.Allow(a) + } + if len(ssh) > 0 { + a, err := buildflags.CreateSSH(ssh) + if err != nil { + return nil, err + } + s.Allow(a) + } + + return s, nil +} diff --git a/internal/cmd/build_remote_test.go b/internal/cmd/build_remote_test.go new file mode 100644 index 00000000..c4c227ff --- /dev/null +++ b/internal/cmd/build_remote_test.go @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2026, Unikraft GmbH and The Unikraft CLI Authors. +// Licensed under the BSD-3-Clause License (the "License"). +// You may not use this file except in compliance with the License. + +//go:build !js + +package cmd + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/moby/buildkit/session" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + buildapi "unikraft.com/cloud/sdk/build" + "unikraft.com/x/build/buildflags" +) + +func TestRemoteRequest(t *testing.T) { + t.Parallel() + + project := func(t *testing.T, files ...string) string { + dir := t.TempDir() + for _, f := range files { + require.NoError(t, os.WriteFile(filepath.Join(dir, f), nil, 0o644)) + } + return dir + } + + for name, tc := range map[string]struct { + files []string + input string + cmd ImageBuildCmd + want string + }{ + "a project reads its Kraftfile from the session, with the flags as fields": { + files: []string{"kraft.yaml", "Dockerfile"}, + cmd: ImageBuildCmd{ + Output: "my-org/my-app:latest", + Arch: []string{"arm64"}, + BuildArg: []string{"VERSION=1.2.3"}, + NoCache: true, + Secret: []string{"id=npm,src=/home/me/.npmrc"}, + SSH: []string{"default"}, + }, + want: `{ + "kraftfile": {"session": "context", "filename": "kraft.yaml"}, + "contexts": {"context": {"session": "context"}}, + "platforms": ["arm64"], + "build_args": {"VERSION": "1.2.3"}, + "no_cache": true, + "secrets": {"npm": {"session": "npm"}}, + "ssh": {"default": {"session": "default"}}, + "registries": {"*": {"session": true}}, + "output": [{"image": "index.unikraft.io/my-org/my-app:latest"}] + }`, + }, + "a project without a Kraftfile builds its Dockerfile": { + files: []string{"Dockerfile"}, + cmd: ImageBuildCmd{Output: "ghcr.io/acme/app:1"}, + want: `{ + "contexts": {"context": {"session": "context"}}, + "no_cache": false, + "registries": {"*": {"session": true}}, + "output": [{"image": "ghcr.io/acme/app:1"}] + }`, + }, + "a Dockerfile is built from its directory": { + files: []string{"app.Dockerfile"}, + input: "app.Dockerfile", + cmd: ImageBuildCmd{Output: "ghcr.io/acme/app:1"}, + want: `{ + "contexts": { + "context": {"session": "context"}, + "dockerfile": {"session": "context", "filename": "app.Dockerfile"} + }, + "no_cache": false, + "registries": {"*": {"session": true}}, + "output": [{"image": "ghcr.io/acme/app:1"}] + }`, + }, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + dir := project(t, tc.files...) + tc.cmd.Input = filepath.Join(dir, tc.input) + secrets, err := buildflags.ParseSecretSpecs(tc.cmd.Secret) + require.NoError(t, err) + ssh, err := buildflags.ParseSSHSpecs(tc.cmd.SSH) + require.NoError(t, err) + + req, served, err := tc.cmd.remoteRequest(secrets, ssh) + require.NoError(t, err) + assert.Equal(t, dir, served) + + b, err := json.Marshal(req) + require.NoError(t, err) + assert.JSONEq(t, tc.want, string(b)) + }) + } + + for name, cmd := range map[string]ImageBuildCmd{ + "an output on disk": {Output: "oci-archive://app.tar"}, + "insecure registries": {Output: "my-org/my-app", Insecure: []string{}}, + "a plain http service": {Remote: "http://localhost:8080", Output: "my-org/my-app"}, + } { + t.Run("refuses "+name, func(t *testing.T) { + t.Parallel() + + cmd.Input = project(t, "Dockerfile") + _, _, err := cmd.remoteRequest(nil, nil) + require.Error(t, err) + }) + } +} + +func TestFollowRemoteCancelsARefusedSession(t *testing.T) { + t.Parallel() + + cancelled := make(chan struct{}, 1) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/v1/builds": + w.WriteHeader(http.StatusCreated) + fmt.Fprint(w, `{"id": "b1", "status": "waiting", "session": "/v1/builds/b1/session"}`) + case "/v1/builds/b1/session": + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, `{"error": {"code": "invalid", "message": "the session does not serve Secrets/GetSecret"}}`) + case "/v1/builds/b1/cancel": + cancelled <- struct{}{} + w.WriteHeader(http.StatusAccepted) + fmt.Fprint(w, `{"id": "b1", "status": "waiting"}`) + } + })) + t.Cleanup(srv.Close) + + client, err := buildapi.New(srv.URL) + require.NoError(t, err) + s, err := session.NewSession(t.Context(), "") + require.NoError(t, err) + + err = followRemote(t.Context(), client, buildapi.CreateBuildRequest{}, s, nil) + var apiErr *buildapi.APIError + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, buildapi.ErrorCodeInvalid, apiErr.Code) + assert.Len(t, cancelled, 1) +} diff --git a/internal/cmd/builder.go b/internal/cmd/builder.go new file mode 100644 index 00000000..e59bd9ba --- /dev/null +++ b/internal/cmd/builder.go @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2026, Unikraft GmbH and The Unikraft CLI Authors. +// Licensed under the BSD-3-Clause License (the "License"). +// You may not use this file except in compliance with the License. + +package cmd + +import ( + "context" + "errors" + "fmt" + + "github.com/docker/go-units" + buildapi "unikraft.com/cloud/sdk/build" + "unikraft.com/x/kingkong" + "unikraft.com/x/log" + + "unikraft.com/cli/internal/config" +) + +type BuilderCmd struct { + Create BuilderCreateCmd `cmd:"" help:"Create a builder that remote builds run on."` + Du BuilderDuCmd `cmd:"" help:"Show the disk usage of a builder's cache."` + Prune BuilderPruneCmd `cmd:"" help:"Free a builder's cache, keeping the builder."` +} + +type BuilderCreateCmd struct { + Name string `arg:"" optional:"" default:"default" help:"Name of the builder."` + Remote string `env:"UNIKRAFT_BUILD_URL" required:"" placeholder:"url" help:"URL of the build service that manages the builder."` +} + +func (BuilderCreateCmd) Examples() []kingkong.Example { + return []kingkong.Example{ + { + Description: "Create the builder of remote builds", + Commands: []string{"unikraft builder create --remote https://build.example.com"}, + }, + } +} + +type BuilderDuCmd struct { + Name string `arg:"" optional:"" default:"default" help:"Name of the builder."` + Remote string `env:"UNIKRAFT_BUILD_URL" required:"" placeholder:"url" help:"URL of the build service that manages the builder."` +} + +func (BuilderDuCmd) Examples() []kingkong.Example { + return []kingkong.Example{ + { + Description: "Show how much cache the builder of remote builds holds", + Commands: []string{"unikraft builder du --remote https://build.example.com"}, + }, + } +} + +type BuilderPruneCmd struct { + Name string `arg:"" optional:"" default:"default" help:"Name of the builder."` + Remote string `env:"UNIKRAFT_BUILD_URL" required:"" placeholder:"url" help:"URL of the build service that manages the builder."` +} + +func (BuilderPruneCmd) Examples() []kingkong.Example { + return []kingkong.Example{ + { + Description: "Free the cache of the builder of remote builds", + Commands: []string{"unikraft builder prune --remote https://build.example.com"}, + }, + } +} + +func (c *BuilderCreateCmd) Run(ctx context.Context, cfg *config.Config) error { + client, err := builderClient(cfg, c.Remote) + if err != nil { + return err + } + if _, err := client.CreateBuilder(ctx, buildapi.Builder{Name: &c.Name}); err != nil { + return err + } + log.G(ctx).Info().Str("name", c.Name).Msg("builder ready") + return nil +} + +func (c *BuilderDuCmd) Run(ctx context.Context, cfg *config.Config, stdio config.Stdio) error { + client, err := builderClient(cfg, c.Remote) + if err != nil { + return err + } + u, err := client.GetBuilderUsage(ctx, c.Name) + if err != nil { + return builderError(err, c.Name) + } + _, err = fmt.Fprintf(stdio.Stdout, "Reclaimable: %s\nTotal: %s\n", units.BytesSize(float64(u.ReclaimableBytes)), units.BytesSize(float64(u.TotalBytes))) + return err +} + +func (c *BuilderPruneCmd) Run(ctx context.Context, cfg *config.Config) error { + client, err := builderClient(cfg, c.Remote) + if err != nil { + return err + } + resp, err := client.PruneBuilder(ctx, c.Name) + if err != nil { + return builderError(err, c.Name) + } + log.G(ctx).Info().Str("freed", units.BytesSize(float64(resp.FreedBytes))).Msg("pruned the builder's cache") + return nil +} + +// builderClient returns the client of the build service at url, as the current profile. +func builderClient(cfg *config.Config, url string) (*buildapi.Client, error) { + profile, err := cfg.CurrentProfile() + if err != nil { + return nil, err + } + return buildapi.New(url, buildapi.WithToken(profile.Token)) +} + +// builderError tells how to create the named builder when the service has none. +func builderError(err error, name string) error { + var apiErr *buildapi.APIError + if errors.As(err, &apiErr) && apiErr.Code == buildapi.ErrorCodeNotFound { + return fmt.Errorf("there is no builder %s: create it with unikraft builder create %s", name, name) + } + return err +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 8772ca85..ab28bd65 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -40,9 +40,10 @@ import ( type UnikraftCLI struct { globalFlags - Run RunCmd `cmd:"" group:"cmd-commands" help:"Run an image as an instance." set:"name=instance" set:"names=instances"` - Build BuildCmd `cmd:"" group:"cmd-commands" help:"Build a Unikraft project into a container image."` - TUI TUICmd `cmd:"" group:"cmd-commands" help:"Browse resources in a TUI."` + Run RunCmd `cmd:"" group:"cmd-commands" help:"Run an image as an instance." set:"name=instance" set:"names=instances"` + Build BuildCmd `cmd:"" group:"cmd-commands" help:"Build a Unikraft project into a container image."` + Builder BuilderCmd `cmd:"" group:"cmd-commands" help:"Manage the builders that remote builds run on."` + TUI TUICmd `cmd:"" group:"cmd-commands" help:"Browse resources in a TUI."` Metros MetrosCmd `cmd:"" group:"cmd-resources" help:"Manage Unikraft Cloud metros." aliases:"metro,metros" set:"name=metro" set:"names=metros"` Quotas MetroQuotasCmd `cmd:"" group:"cmd-resources" help:"Display quota usage for metros." aliases:"quota"` diff --git a/internal/cmd/sandbox.go b/internal/cmd/sandbox.go index 96d1609e..d4340f72 100644 --- a/internal/cmd/sandbox.go +++ b/internal/cmd/sandbox.go @@ -104,6 +104,9 @@ func (c *ExecSandboxInstanceCmd) runOn(ctx context.Context, target sandbox.Targe if err != nil { return err } + if code < 0 { + code = 128 - code + } if code != 0 { return ExitStatus(code) } diff --git a/internal/integration/env.go b/internal/integration/env.go index 8a33feb0..849bbca7 100644 --- a/internal/integration/env.go +++ b/internal/integration/env.go @@ -66,6 +66,7 @@ type cmdConfig struct { timeout time.Duration withoutCancel bool exitCode *int + env []string } func WithWorkDir(dir string) CmdOption { @@ -101,6 +102,11 @@ func WithTimeout(d time.Duration) CmdOption { return func(c *cmdConfig) { c.timeout = d } } +// WithEnv adds the variables env, each as key=value, to the environment of the command. +func WithEnv(env ...string) CmdOption { + return func(c *cmdConfig) { c.env = append(c.env, env...) } +} + // WithNoPartition disables partition tracking for the command. The resource is not // registered in a test partition and stays after individual test cleanup. func WithNoPartition() CmdOption { @@ -147,6 +153,7 @@ func (env *TestEnv) RunRaw(t *testing.T, args []string, opts ...CmdOption) (stri c.Env = append(c.Env, "NO_COLOR=1") c.Env = append(c.Env, "UNIKRAFT_CONFIG="+env.configPath) c.Env = append(c.Env, "BUILDKIT_PROGRESS=quiet") + c.Env = append(c.Env, cfg.env...) if !cfg.noPartition { c.Env = append(c.Env, resource.UnikraftPartitionEnv+"="+env.partitionPath) } diff --git a/internal/integration/remote.go b/internal/integration/remote.go index 309c4380..c74988cd 100644 --- a/internal/integration/remote.go +++ b/internal/integration/remote.go @@ -62,6 +62,7 @@ func populate() (*Remote, error) { } profile.Type = config.ProfileTypeCloud profile.ControlPlane = "" + profile.MetroDefault = "" profile.Metros = profile.Metros[:1] profile.Metros[0].Name = "test" profile.Metros[0].Location = "xxx"