From 8c1b9db1d8282dc303ca7ee6f39170cdd18aac87 Mon Sep 17 00:00:00 2001 From: Dragos Gheorghioiu Date: Mon, 26 Oct 2026 11:29:31 +0200 Subject: [PATCH 1/7] feat(cmd): Add unikraft build --remote A build needed docker or a buildkitd on the caller's machine, since the CLI could only build with a local buildkit. --remote builds on the build service instead, which pushes the image. The project directory, the registry logins, --secret and --ssh reach the builder through a buildkit session. Signed-off-by: Dragos Gheorghioiu --- .github/workflows/test.yaml | 1 + cmd/unikraft/integration/build_test.go | 48 +++++ cmd/unikraft/testdata/TestHelp/build | 2 + cmd/unikraft/testdata/TestHelp/images | 2 + go.mod | 3 +- go.sum | 2 + internal/cmd/build.go | 2 + internal/cmd/build_native.go | 25 ++- internal/cmd/build_remote.go | 269 +++++++++++++++++++++++++ internal/cmd/build_remote_test.go | 156 ++++++++++++++ internal/integration/env.go | 7 + 11 files changed, 509 insertions(+), 8 deletions(-) create mode 100644 internal/cmd/build_remote.go create mode 100644 internal/cmd/build_remote_test.go 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/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/testdata/TestHelp/build b/cmd/unikraft/testdata/TestHelp/build index 21dc5b32..d9d0291b 100644 --- a/cmd/unikraft/testdata/TestHelp/build +++ b/cmd/unikraft/testdata/TestHelp/build @@ -48,6 +48,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/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..8d19c25a 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,6 +52,7 @@ 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/build v0.0.0-20261026110214-14d62e60ef3c unikraft.com/cloud/sdk/plugins/sandbox v0.0.0-20260925102313-8051b659e1ce unikraft.com/x/build v0.0.0-20261020121654-15b23b7bb4a5 unikraft.com/x/buildkit v0.0.0-20261016091434-6ed7891be538 @@ -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..55c0ecb4 100644 --- a/go.sum +++ b/go.sum @@ -479,6 +479,8 @@ 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/build v0.0.0-20261026110214-14d62e60ef3c h1:KicmYEb+XOxGen6qVWY+kLMpTuo1eEHFP1ryoUkfsoo= +unikraft.com/cloud/sdk/build v0.0.0-20261026110214-14d62e60ef3c/go.mod h1:OjYfwRrWPqedgEELmpFini+YtCUT8wrP7+oqxhbn07M= 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/x/build v0.0.0-20261020121654-15b23b7bb4a5 h1:j6VeahXa2Nl0UClVQ55E9hOYbhO8yGFlgNXvOY2Be9s= 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/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) } From 0337d71e3e47916330779724958444de7e0bef7a Mon Sep 17 00:00:00 2001 From: Dragos Gheorghioiu Date: Tue, 27 Oct 2026 12:53:56 +0200 Subject: [PATCH 2/7] feat(cmd): Add unikraft buildkit commands The build service creates the account's builder on the first remote build, and the CLI could not create it beforehand, nor inspect or free its cache without deleting it. Add unikraft buildkit create, du and prune. create makes the builder with the service's sizes through x/buildkit's Platform.Create, unless it exists, and works in js too. du and prune wake it to call buildkit's DiskUsage and Prune. Bump x/buildkit for Platform.Create. Signed-off-by: Dragos Gheorghioiu --- cmd/unikraft/help_test.go | 4 + cmd/unikraft/integration/buildkit_test.go | 36 ++++++ cmd/unikraft/testdata/TestHelp/build | 136 ++++++++++++++++++++++ cmd/unikraft/testdata/TestHelp/general | 10 +- go.mod | 2 +- go.sum | 4 +- internal/cmd/buildkit.go | 121 +++++++++++++++++++ internal/cmd/buildkit_native.go | 112 ++++++++++++++++++ internal/cmd/root.go | 7 +- internal/cmd/unavailable_js.go | 8 ++ internal/integration/remote.go | 1 + 11 files changed, 434 insertions(+), 7 deletions(-) create mode 100644 cmd/unikraft/integration/buildkit_test.go create mode 100644 internal/cmd/buildkit.go create mode 100644 internal/cmd/buildkit_native.go diff --git a/cmd/unikraft/help_test.go b/cmd/unikraft/help_test.go index 0c59c946..f2d6de3f 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", "buildkit", "--help"}, + []string{"unikraft", "buildkit", "create", "--help"}, + []string{"unikraft", "buildkit", "du", "--help"}, + []string{"unikraft", "buildkit", "prune", "--help"}, ) } diff --git a/cmd/unikraft/integration/buildkit_test.go b/cmd/unikraft/integration/buildkit_test.go new file mode 100644 index 00000000..230fe196 --- /dev/null +++ b/cmd/unikraft/integration/buildkit_test.go @@ -0,0 +1,36 @@ +// 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 ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBuildkit(t *testing.T) { + t.Run("create", func(t *testing.T) { + r := runner(t, true, []string{stable}) + + // The account's remote builds share the builder, so it is not deleted. + r.Run(t, []string{"unikraft", "buildkit", "create", "--metro", r.MetroName}) + + // Without --metro, the profile's only metro. + out := r.Run(t, []string{"unikraft", "buildkit", "create"}) + assert.Regexp(t, `builder exists`, out) + }) + + t.Run("du-prune", func(t *testing.T) { + r := runner(t, true, []string{stable}) + r.Run(t, []string{"unikraft", "buildkit", "create", "--metro", r.MetroName}) + + out := r.Run(t, []string{"unikraft", "buildkit", "du", "--metro", r.MetroName}) + assert.Regexp(t, `(?m)^Reclaimable: +\S+\nTotal: +\S+$`, out) + + out = r.Run(t, []string{"unikraft", "buildkit", "prune", "--metro", r.MetroName}) + assert.Regexp(t, `pruned the builder's cache freed=\S+`, out) + }) +} diff --git a/cmd/unikraft/testdata/TestHelp/build b/cmd/unikraft/testdata/TestHelp/build index d9d0291b..2723bb3e 100644 --- a/cmd/unikraft/testdata/TestHelp/build +++ b/cmd/unikraft/testdata/TestHelp/build @@ -69,3 +69,139 @@ Global flags: [default: true] --timeout= ($UNIKRAFT_TIMEOUT) Set a deadline for the command (e.g. 30s, 5m, 1h). + +$ unikraft buildkit --help + +Manage the builder that remote builds run on. + +Usage: + unikraft buildkit [flags] + +Commands: + create + Create the builder that remote builds run on. + du + Show the disk usage of the builder's cache. + prune + Free the 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 buildkit create --help + +Create the builder that remote builds run on. + +Usage: + unikraft buildkit create [flags] + +Examples: + # Create the builder of remote builds in fra + unikraft buildkit create --metro fra + +Flags: + --metro= + Metro to create the builder in, the build service's. Defaults to the profile's default metro. + +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 buildkit du --help + +Show the disk usage of the builder's cache. + +Usage: + unikraft buildkit du [flags] + +Examples: + # Show how much cache the builder of remote builds in fra holds + unikraft buildkit du --metro fra + +Flags: + --metro= + Metro of the builder, the build service's. Defaults to the profile's default metro. + +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 buildkit prune --help + +Free the builder's cache, keeping the builder. + +Usage: + unikraft buildkit prune [flags] + +Examples: + # Free the cache of the builder of remote builds in fra + unikraft buildkit prune --metro fra + +Flags: + --metro= + Metro of the builder, the build service's. Defaults to the profile's default metro. + +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). diff --git a/cmd/unikraft/testdata/TestHelp/general b/cmd/unikraft/testdata/TestHelp/general index 639579c5..74a2763a 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. + buildkit + Manage the builder 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", "buildkit", "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. + buildkit + Manage the builder 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. + buildkit + Manage the builder 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. + buildkit + Manage the builder that remote builds run on. tui Browse resources in a TUI. diff --git a/go.mod b/go.mod index 8d19c25a..ab7155fc 100644 --- a/go.mod +++ b/go.mod @@ -55,7 +55,7 @@ require ( unikraft.com/cloud/sdk/build v0.0.0-20261026110214-14d62e60ef3c unikraft.com/cloud/sdk/plugins/sandbox v0.0.0-20260925102313-8051b659e1ce 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 diff --git a/go.sum b/go.sum index 55c0ecb4..96cf4e62 100644 --- a/go.sum +++ b/go.sum @@ -485,8 +485,8 @@ unikraft.com/cloud/sdk/plugins/sandbox v0.0.0-20260925102313-8051b659e1ce h1:8Fy unikraft.com/cloud/sdk/plugins/sandbox v0.0.0-20260925102313-8051b659e1ce/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= diff --git a/internal/cmd/buildkit.go b/internal/cmd/buildkit.go new file mode 100644 index 00000000..dc5842f7 --- /dev/null +++ b/internal/cmd/buildkit.go @@ -0,0 +1,121 @@ +// 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" + + "unikraft.com/cloud/sdk/platform" + "unikraft.com/cloud/sdk/platform/group" + "unikraft.com/x/buildkit" + "unikraft.com/x/kingkong" + "unikraft.com/x/log" + "unikraft.com/x/ptr" + + "unikraft.com/cli/internal/multimetro" +) + +// The builder that the build service creates for a remote build. +const ( + builderName = "buildkit-default" + builderVCPUs = 4 + builderMemoryMB = 4096 + builderCacheMB = 20480 +) + +var errNoMetro = errors.New("the profile has no default metro: use --metro") + +type BuildkitCmd struct { + Create BuildkitCreateCmd `cmd:"" help:"Create the builder that remote builds run on."` + Du BuildkitDuCmd `cmd:"" help:"Show the disk usage of the builder's cache."` + Prune BuildkitPruneCmd `cmd:"" help:"Free the builder's cache, keeping the builder."` +} + +type BuildkitCreateCmd struct { + Metro string `help:"Metro to create the builder in, the build service's. Defaults to the profile's default metro." placeholder:"metro"` +} + +func (BuildkitCreateCmd) Examples() []kingkong.Example { + return []kingkong.Example{ + { + Description: "Create the builder of remote builds in fra", + Commands: []string{"unikraft buildkit create --metro fra"}, + }, + } +} + +// Run creates the build service's builder, unless it exists, which the build +// service wakes itself. +func (c *BuildkitCreateCmd) Run(ctx context.Context) error { + // NewClient reports a bad profile, which defaultMetro hides. + g, err := multimetro.NewClient(ctx) + if err != nil { + return err + } + metro := defaultMetro(ctx, c.Metro) + if metro == "" { + return errNoMetro + } + return group.DoMetro(ctx, g, metro, func(ctx context.Context, mc multimetro.MetroClient) error { + p, exists, err := builder(ctx, mc) + if err != nil { + return err + } + if exists { + log.G(ctx).Info().Str("name", builderName).Msg("builder exists") + return nil + } + + if err := p.Create(ctx, buildkit.Key{}); err != nil { + return err + } + log.G(ctx).Info().Str("name", builderName).Msg("created builder") + return nil + }) +} + +type BuildkitDuCmd struct { + Metro string `help:"Metro of the builder, the build service's. Defaults to the profile's default metro." placeholder:"metro"` +} + +func (BuildkitDuCmd) Examples() []kingkong.Example { + return []kingkong.Example{ + { + Description: "Show how much cache the builder of remote builds in fra holds", + Commands: []string{"unikraft buildkit du --metro fra"}, + }, + } +} + +type BuildkitPruneCmd struct { + Metro string `help:"Metro of the builder, the build service's. Defaults to the profile's default metro." placeholder:"metro"` +} + +func (BuildkitPruneCmd) Examples() []kingkong.Example { + return []kingkong.Example{ + { + Description: "Free the cache of the builder of remote builds in fra", + Commands: []string{"unikraft buildkit prune --metro fra"}, + }, + } +} + +// builder returns the platform of the build service's builder in mc's metro, +// and whether that builder exists. +func builder(ctx context.Context, mc multimetro.MetroClient) (*buildkit.Platform, bool, error) { + resp, err := mc.GetInstances(ctx, []platform.NameOrUUID{{Name: new(builderName)}}, platform.GetInstancesOpts{Details: new(false)}) + if err != nil && !platform.ErrorContainsOnly(err, platform.APIHTTPErrorNotFound) { + return nil, false, err + } + p := buildkit.NewPlatform(mc.Client, + buildkit.WithVCPUs(builderVCPUs), + buildkit.WithMemory(builderMemoryMB), + buildkit.WithCacheSize(builderCacheMB), + buildkit.WithInsecure(ptr.ZeroIfNil(mc.Metro.Insecure)), + ) + return p, err == nil && resp.Data != nil && len(resp.Data.Instances) > 0, nil +} diff --git a/internal/cmd/buildkit_native.go b/internal/cmd/buildkit_native.go new file mode 100644 index 00000000..b1010a6b --- /dev/null +++ b/internal/cmd/buildkit_native.go @@ -0,0 +1,112 @@ +// 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" + "fmt" + + "github.com/docker/go-units" + "github.com/moby/buildkit/client" + "unikraft.com/cloud/sdk/platform/group" + "unikraft.com/x/buildkit" + "unikraft.com/x/log" + + "unikraft.com/cli/internal/config" + "unikraft.com/cli/internal/multimetro" +) + +var errNoBuilder = fmt.Errorf("there is no builder %s: create it with unikraft buildkit create", builderName) + +func (c *BuildkitDuCmd) Run(ctx context.Context, stdio config.Stdio) error { + // NewClient reports a bad profile, which defaultMetro hides. + g, err := multimetro.NewClient(ctx) + if err != nil { + return err + } + metro := defaultMetro(ctx, c.Metro) + if metro == "" { + return errNoMetro + } + return group.DoMetro(ctx, g, metro, func(ctx context.Context, mc multimetro.MetroClient) error { + p, exists, err := builder(ctx, mc) + if err != nil { + return err + } + if !exists { + return errNoBuilder + } + d, err := p.Acquire(ctx, buildkit.Key{}) + if err != nil { + return err + } + defer func() { _ = d.Release() }() + + du, err := d.Client.DiskUsage(ctx) + if err != nil { + return err + } + var reclaimable, total int64 + for _, u := range du { + if u.Size <= 0 { + continue + } + total += u.Size + if !u.InUse { + reclaimable += u.Size + } + } + _, err = fmt.Fprintf(stdio.Stdout, "Reclaimable: %s\nTotal: %s\n", units.BytesSize(float64(reclaimable)), units.BytesSize(float64(total))) + return err + }) +} + +func (c *BuildkitPruneCmd) Run(ctx context.Context) error { + // NewClient reports a bad profile, which defaultMetro hides. + g, err := multimetro.NewClient(ctx) + if err != nil { + return err + } + metro := defaultMetro(ctx, c.Metro) + if metro == "" { + return errNoMetro + } + return group.DoMetro(ctx, g, metro, func(ctx context.Context, mc multimetro.MetroClient) error { + p, exists, err := builder(ctx, mc) + if err != nil { + return err + } + if !exists { + return errNoBuilder + } + d, err := p.Acquire(ctx, buildkit.Key{}) + if err != nil { + return err + } + defer func() { _ = d.Release() }() + + // Prune sends each record it frees and never closes ch. + ch := make(chan client.UsageInfo) + freed := make(chan int64) + go func() { + var n int64 + for u := range ch { + n += max(u.Size, 0) + } + freed <- n + }() + err = d.Client.Prune(ctx, ch) + close(ch) + n := <-freed + if err != nil { + return err + } + log.G(ctx).Info().Str("freed", units.BytesSize(float64(n))).Msg("pruned the builder's cache") + return nil + }) +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 8772ca85..32713c55 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."` + Buildkit BuildkitCmd `cmd:"" group:"cmd-commands" help:"Manage the builder 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/unavailable_js.go b/internal/cmd/unavailable_js.go index cd0def50..78d4dcc3 100644 --- a/internal/cmd/unavailable_js.go +++ b/internal/cmd/unavailable_js.go @@ -33,6 +33,14 @@ func (c *ImageBuildCmd) Run(ctx context.Context, cfg *config.Config, partition * return notAvailable("image build") } +func (c *BuildkitDuCmd) Run(ctx context.Context, stdio config.Stdio) error { + return notAvailable("buildkit du") +} + +func (c *BuildkitPruneCmd) Run(ctx context.Context) error { + return notAvailable("buildkit prune") +} + func (c *CompletionCmd) Run(ctx *kong.Context) error { return notAvailable("completion") } 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" From 4cf691f57fb0ed6b07cf654cbae435fa4feed687 Mon Sep 17 00:00:00 2001 From: Dragos Gheorghioiu Date: Tue, 27 Oct 2026 17:48:13 +0200 Subject: [PATCH 3/7] gomod(deps): Bump x/shell and sdk/plugins/sandbox Signed-off-by: Dragos Gheorghioiu --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index ab7155fc..b1afb876 100644 --- a/go.mod +++ b/go.mod @@ -53,7 +53,7 @@ require ( unikraft.com/cloud/plugins/sandbox v0.0.0-20260916204449-fe2a902ae04c unikraft.com/cloud/sdk v0.3.1-0.20261021152129-3431e7639ef3 unikraft.com/cloud/sdk/build v0.0.0-20261026110214-14d62e60ef3c - unikraft.com/cloud/sdk/plugins/sandbox v0.0.0-20260925102313-8051b659e1ce + 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-20261027124450-dfcf25878f57 unikraft.com/x/colors v0.0.0-20261007115714-ef931437a206 @@ -67,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 diff --git a/go.sum b/go.sum index 96cf4e62..78be73c3 100644 --- a/go.sum +++ b/go.sum @@ -481,8 +481,8 @@ unikraft.com/cloud/sdk v0.3.1-0.20261021152129-3431e7639ef3 h1:I3pw14Ia3LsXZvAiB unikraft.com/cloud/sdk v0.3.1-0.20261021152129-3431e7639ef3/go.mod h1:/wX3Ltbi+rSzpS6zn2v6qLSP/7OLQqDWDp5VJ5ZVHlg= unikraft.com/cloud/sdk/build v0.0.0-20261026110214-14d62e60ef3c h1:KicmYEb+XOxGen6qVWY+kLMpTuo1eEHFP1ryoUkfsoo= unikraft.com/cloud/sdk/build v0.0.0-20261026110214-14d62e60ef3c/go.mod h1:OjYfwRrWPqedgEELmpFini+YtCUT8wrP7+oqxhbn07M= -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/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-20261027124450-dfcf25878f57 h1:9MIeRql7SH/fNACV6ssGVFEmlW9M/bfmBFcPEVuYV3k= @@ -509,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= From 6d7a17b5c182ef1b805475201ca166d33218cf65 Mon Sep 17 00:00:00 2001 From: Dragos Gheorghioiu Date: Tue, 27 Oct 2026 12:50:04 +0200 Subject: [PATCH 4/7] fix(cmd): Exit exec with the signal's status instance exec exited with 130 for every signal that ended the command, since x/shell folded the plugin's negated signal into it. Exit with 128 plus the signal instead, as instance shell does. Signed-off-by: Dragos Gheorghioiu --- cmd/unikraft/integration/sandbox_test.go | 2 ++ internal/cmd/sandbox.go | 3 +++ 2 files changed, 5 insertions(+) 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/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) } From 620e68809e62a6fe1f079523c5d5e6f7c4005688 Mon Sep 17 00:00:00 2001 From: Justin Chadwell Date: Fri, 30 Oct 2026 12:59:41 +0000 Subject: [PATCH 5/7] ci: Use the shared promote workflow So that every push to prod-staging opens a PR to prod-stable. Signed-off-by: Justin Chadwell --- .github/workflows/promote.yaml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .github/workflows/promote.yaml 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 }} From d67c333fed7c5dcfb3bad4eec3badece0728d51c Mon Sep 17 00:00:00 2001 From: Justin Chadwell Date: Thu, 15 Oct 2026 16:03:51 +0100 Subject: [PATCH 6/7] test(integration): Inspect, copy and run http+oci tags The server now redirects a tag to the digest held in a file of that name, the way an origin pins one. Both the tag and the digest are inspected, copied and run. Signed-off-by: Justin Chadwell --- .../integration/instance_http_oci_test.go | 85 ++++++++++++++----- 1 file changed, 66 insertions(+), 19 deletions(-) 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 From 58caa6e83a4782fa249e12a2b06060be51cf563a Mon Sep 17 00:00:00 2001 From: Dragos Gheorghioiu Date: Fri, 30 Oct 2026 16:36:32 +0200 Subject: [PATCH 7/7] feat(cmd): Manage builders through the build API unikraft buildkit created and pruned the builder on the platform itself, so it had to know the build service's instance names and sizes, and it does not find the builders the service now names remote-builder-. Rename it to unikraft builder, which asks the build service at --remote to create a builder, or to report or prune its cache. Signed-off-by: Dragos Gheorghioiu --- cmd/unikraft/help_test.go | 8 +- cmd/unikraft/integration/builder_test.go | 42 ++++++++ cmd/unikraft/integration/buildkit_test.go | 36 ------- cmd/unikraft/testdata/TestHelp/build | 69 +++++++----- cmd/unikraft/testdata/TestHelp/general | 18 ++-- go.mod | 2 +- go.sum | 4 +- internal/cmd/builder.go | 123 ++++++++++++++++++++++ internal/cmd/buildkit.go | 121 --------------------- internal/cmd/buildkit_native.go | 112 -------------------- internal/cmd/root.go | 8 +- internal/cmd/unavailable_js.go | 8 -- 12 files changed, 227 insertions(+), 324 deletions(-) create mode 100644 cmd/unikraft/integration/builder_test.go delete mode 100644 cmd/unikraft/integration/buildkit_test.go create mode 100644 internal/cmd/builder.go delete mode 100644 internal/cmd/buildkit.go delete mode 100644 internal/cmd/buildkit_native.go diff --git a/cmd/unikraft/help_test.go b/cmd/unikraft/help_test.go index f2d6de3f..49fe713c 100644 --- a/cmd/unikraft/help_test.go +++ b/cmd/unikraft/help_test.go @@ -206,10 +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", "buildkit", "--help"}, - []string{"unikraft", "buildkit", "create", "--help"}, - []string{"unikraft", "buildkit", "du", "--help"}, - []string{"unikraft", "buildkit", "prune", "--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/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/buildkit_test.go b/cmd/unikraft/integration/buildkit_test.go deleted file mode 100644 index 230fe196..00000000 --- a/cmd/unikraft/integration/buildkit_test.go +++ /dev/null @@ -1,36 +0,0 @@ -// 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 ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestBuildkit(t *testing.T) { - t.Run("create", func(t *testing.T) { - r := runner(t, true, []string{stable}) - - // The account's remote builds share the builder, so it is not deleted. - r.Run(t, []string{"unikraft", "buildkit", "create", "--metro", r.MetroName}) - - // Without --metro, the profile's only metro. - out := r.Run(t, []string{"unikraft", "buildkit", "create"}) - assert.Regexp(t, `builder exists`, out) - }) - - t.Run("du-prune", func(t *testing.T) { - r := runner(t, true, []string{stable}) - r.Run(t, []string{"unikraft", "buildkit", "create", "--metro", r.MetroName}) - - out := r.Run(t, []string{"unikraft", "buildkit", "du", "--metro", r.MetroName}) - assert.Regexp(t, `(?m)^Reclaimable: +\S+\nTotal: +\S+$`, out) - - out = r.Run(t, []string{"unikraft", "buildkit", "prune", "--metro", r.MetroName}) - assert.Regexp(t, `pruned the builder's cache freed=\S+`, out) - }) -} diff --git a/cmd/unikraft/testdata/TestHelp/build b/cmd/unikraft/testdata/TestHelp/build index 2723bb3e..586ababb 100644 --- a/cmd/unikraft/testdata/TestHelp/build +++ b/cmd/unikraft/testdata/TestHelp/build @@ -70,20 +70,20 @@ Global flags: --timeout= ($UNIKRAFT_TIMEOUT) Set a deadline for the command (e.g. 30s, 5m, 1h). -$ unikraft buildkit --help +$ unikraft builder --help -Manage the builder that remote builds run on. +Manage the builders that remote builds run on. Usage: - unikraft buildkit [flags] + unikraft builder [flags] Commands: create - Create the builder that remote builds run on. + Create a builder that remote builds run on. du - Show the disk usage of the builder's cache. + Show the disk usage of a builder's cache. prune - Free the builder's cache, keeping the builder. + Free a builder's cache, keeping the builder. Global flags: -h, --help @@ -104,20 +104,25 @@ Global flags: --timeout= ($UNIKRAFT_TIMEOUT) Set a deadline for the command (e.g. 30s, 5m, 1h). -$ unikraft buildkit create --help +$ unikraft builder create --help -Create the builder that remote builds run on. +Create a builder that remote builds run on. Usage: - unikraft buildkit create [flags] + unikraft builder create --remote=url [] + +Arguments: + [] + Name of the builder. + [default: default] Examples: - # Create the builder of remote builds in fra - unikraft buildkit create --metro fra + # Create the builder of remote builds + unikraft builder create --remote https://build.example.com Flags: - --metro= - Metro to create the builder in, the build service's. Defaults to the profile's default metro. + --remote= ($UNIKRAFT_BUILD_URL) + URL of the build service that manages the builder. Global flags: -h, --help @@ -138,20 +143,25 @@ Global flags: --timeout= ($UNIKRAFT_TIMEOUT) Set a deadline for the command (e.g. 30s, 5m, 1h). -$ unikraft buildkit du --help +$ unikraft builder du --help -Show the disk usage of the builder's cache. +Show the disk usage of a builder's cache. Usage: - unikraft buildkit du [flags] + unikraft builder du --remote=url [] + +Arguments: + [] + Name of the builder. + [default: default] Examples: - # Show how much cache the builder of remote builds in fra holds - unikraft buildkit du --metro fra + # Show how much cache the builder of remote builds holds + unikraft builder du --remote https://build.example.com Flags: - --metro= - Metro of the builder, the build service's. Defaults to the profile's default metro. + --remote= ($UNIKRAFT_BUILD_URL) + URL of the build service that manages the builder. Global flags: -h, --help @@ -172,20 +182,25 @@ Global flags: --timeout= ($UNIKRAFT_TIMEOUT) Set a deadline for the command (e.g. 30s, 5m, 1h). -$ unikraft buildkit prune --help +$ unikraft builder prune --help -Free the builder's cache, keeping the builder. +Free a builder's cache, keeping the builder. Usage: - unikraft buildkit prune [flags] + unikraft builder prune --remote=url [] + +Arguments: + [] + Name of the builder. + [default: default] Examples: - # Free the cache of the builder of remote builds in fra - unikraft buildkit prune --metro fra + # Free the cache of the builder of remote builds + unikraft builder prune --remote https://build.example.com Flags: - --metro= - Metro of the builder, the build service's. Defaults to the profile's default metro. + --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 74a2763a..e1f0c49a 100644 --- a/cmd/unikraft/testdata/TestHelp/general +++ b/cmd/unikraft/testdata/TestHelp/general @@ -16,8 +16,8 @@ Commands: Run an image as an instance. build Build a Unikraft project into a container image. - buildkit - Manage the builder that remote builds run on. + builder + Manage the builders that remote builds run on. tui Browse resources in a TUI. @@ -97,7 +97,7 @@ Run "unikraft --help" for more information on a command. │ │ error: -│ parsing arguments: expected one of "run", "build", "buildkit", "tui", "metros", ... +│ parsing arguments: expected one of "run", "build", "builder", "tui", "metros", ... │ exit code: 1 @@ -120,8 +120,8 @@ Commands: Run an image as an instance. build Build a Unikraft project into a container image. - buildkit - Manage the builder that remote builds run on. + builder + Manage the builders that remote builds run on. tui Browse resources in a TUI. @@ -226,8 +226,8 @@ Commands: Run an image as an instance. build Build a Unikraft project into a container image. - buildkit - Manage the builder that remote builds run on. + builder + Manage the builders that remote builds run on. tui Browse resources in a TUI. @@ -329,8 +329,8 @@ Commands: Run an image as an instance. build Build a Unikraft project into a container image. - buildkit - Manage the builder that remote builds run on. + builder + Manage the builders that remote builds run on. tui Browse resources in a TUI. diff --git a/go.mod b/go.mod index b1afb876..cc72b4c5 100644 --- a/go.mod +++ b/go.mod @@ -52,7 +52,7 @@ 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/build v0.0.0-20261026110214-14d62e60ef3c + 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-20261027124450-dfcf25878f57 diff --git a/go.sum b/go.sum index 78be73c3..d0b05743 100644 --- a/go.sum +++ b/go.sum @@ -479,8 +479,8 @@ 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/build v0.0.0-20261026110214-14d62e60ef3c h1:KicmYEb+XOxGen6qVWY+kLMpTuo1eEHFP1ryoUkfsoo= -unikraft.com/cloud/sdk/build v0.0.0-20261026110214-14d62e60ef3c/go.mod h1:OjYfwRrWPqedgEELmpFini+YtCUT8wrP7+oqxhbn07M= +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= 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/buildkit.go b/internal/cmd/buildkit.go deleted file mode 100644 index dc5842f7..00000000 --- a/internal/cmd/buildkit.go +++ /dev/null @@ -1,121 +0,0 @@ -// 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" - - "unikraft.com/cloud/sdk/platform" - "unikraft.com/cloud/sdk/platform/group" - "unikraft.com/x/buildkit" - "unikraft.com/x/kingkong" - "unikraft.com/x/log" - "unikraft.com/x/ptr" - - "unikraft.com/cli/internal/multimetro" -) - -// The builder that the build service creates for a remote build. -const ( - builderName = "buildkit-default" - builderVCPUs = 4 - builderMemoryMB = 4096 - builderCacheMB = 20480 -) - -var errNoMetro = errors.New("the profile has no default metro: use --metro") - -type BuildkitCmd struct { - Create BuildkitCreateCmd `cmd:"" help:"Create the builder that remote builds run on."` - Du BuildkitDuCmd `cmd:"" help:"Show the disk usage of the builder's cache."` - Prune BuildkitPruneCmd `cmd:"" help:"Free the builder's cache, keeping the builder."` -} - -type BuildkitCreateCmd struct { - Metro string `help:"Metro to create the builder in, the build service's. Defaults to the profile's default metro." placeholder:"metro"` -} - -func (BuildkitCreateCmd) Examples() []kingkong.Example { - return []kingkong.Example{ - { - Description: "Create the builder of remote builds in fra", - Commands: []string{"unikraft buildkit create --metro fra"}, - }, - } -} - -// Run creates the build service's builder, unless it exists, which the build -// service wakes itself. -func (c *BuildkitCreateCmd) Run(ctx context.Context) error { - // NewClient reports a bad profile, which defaultMetro hides. - g, err := multimetro.NewClient(ctx) - if err != nil { - return err - } - metro := defaultMetro(ctx, c.Metro) - if metro == "" { - return errNoMetro - } - return group.DoMetro(ctx, g, metro, func(ctx context.Context, mc multimetro.MetroClient) error { - p, exists, err := builder(ctx, mc) - if err != nil { - return err - } - if exists { - log.G(ctx).Info().Str("name", builderName).Msg("builder exists") - return nil - } - - if err := p.Create(ctx, buildkit.Key{}); err != nil { - return err - } - log.G(ctx).Info().Str("name", builderName).Msg("created builder") - return nil - }) -} - -type BuildkitDuCmd struct { - Metro string `help:"Metro of the builder, the build service's. Defaults to the profile's default metro." placeholder:"metro"` -} - -func (BuildkitDuCmd) Examples() []kingkong.Example { - return []kingkong.Example{ - { - Description: "Show how much cache the builder of remote builds in fra holds", - Commands: []string{"unikraft buildkit du --metro fra"}, - }, - } -} - -type BuildkitPruneCmd struct { - Metro string `help:"Metro of the builder, the build service's. Defaults to the profile's default metro." placeholder:"metro"` -} - -func (BuildkitPruneCmd) Examples() []kingkong.Example { - return []kingkong.Example{ - { - Description: "Free the cache of the builder of remote builds in fra", - Commands: []string{"unikraft buildkit prune --metro fra"}, - }, - } -} - -// builder returns the platform of the build service's builder in mc's metro, -// and whether that builder exists. -func builder(ctx context.Context, mc multimetro.MetroClient) (*buildkit.Platform, bool, error) { - resp, err := mc.GetInstances(ctx, []platform.NameOrUUID{{Name: new(builderName)}}, platform.GetInstancesOpts{Details: new(false)}) - if err != nil && !platform.ErrorContainsOnly(err, platform.APIHTTPErrorNotFound) { - return nil, false, err - } - p := buildkit.NewPlatform(mc.Client, - buildkit.WithVCPUs(builderVCPUs), - buildkit.WithMemory(builderMemoryMB), - buildkit.WithCacheSize(builderCacheMB), - buildkit.WithInsecure(ptr.ZeroIfNil(mc.Metro.Insecure)), - ) - return p, err == nil && resp.Data != nil && len(resp.Data.Instances) > 0, nil -} diff --git a/internal/cmd/buildkit_native.go b/internal/cmd/buildkit_native.go deleted file mode 100644 index b1010a6b..00000000 --- a/internal/cmd/buildkit_native.go +++ /dev/null @@ -1,112 +0,0 @@ -// 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" - "fmt" - - "github.com/docker/go-units" - "github.com/moby/buildkit/client" - "unikraft.com/cloud/sdk/platform/group" - "unikraft.com/x/buildkit" - "unikraft.com/x/log" - - "unikraft.com/cli/internal/config" - "unikraft.com/cli/internal/multimetro" -) - -var errNoBuilder = fmt.Errorf("there is no builder %s: create it with unikraft buildkit create", builderName) - -func (c *BuildkitDuCmd) Run(ctx context.Context, stdio config.Stdio) error { - // NewClient reports a bad profile, which defaultMetro hides. - g, err := multimetro.NewClient(ctx) - if err != nil { - return err - } - metro := defaultMetro(ctx, c.Metro) - if metro == "" { - return errNoMetro - } - return group.DoMetro(ctx, g, metro, func(ctx context.Context, mc multimetro.MetroClient) error { - p, exists, err := builder(ctx, mc) - if err != nil { - return err - } - if !exists { - return errNoBuilder - } - d, err := p.Acquire(ctx, buildkit.Key{}) - if err != nil { - return err - } - defer func() { _ = d.Release() }() - - du, err := d.Client.DiskUsage(ctx) - if err != nil { - return err - } - var reclaimable, total int64 - for _, u := range du { - if u.Size <= 0 { - continue - } - total += u.Size - if !u.InUse { - reclaimable += u.Size - } - } - _, err = fmt.Fprintf(stdio.Stdout, "Reclaimable: %s\nTotal: %s\n", units.BytesSize(float64(reclaimable)), units.BytesSize(float64(total))) - return err - }) -} - -func (c *BuildkitPruneCmd) Run(ctx context.Context) error { - // NewClient reports a bad profile, which defaultMetro hides. - g, err := multimetro.NewClient(ctx) - if err != nil { - return err - } - metro := defaultMetro(ctx, c.Metro) - if metro == "" { - return errNoMetro - } - return group.DoMetro(ctx, g, metro, func(ctx context.Context, mc multimetro.MetroClient) error { - p, exists, err := builder(ctx, mc) - if err != nil { - return err - } - if !exists { - return errNoBuilder - } - d, err := p.Acquire(ctx, buildkit.Key{}) - if err != nil { - return err - } - defer func() { _ = d.Release() }() - - // Prune sends each record it frees and never closes ch. - ch := make(chan client.UsageInfo) - freed := make(chan int64) - go func() { - var n int64 - for u := range ch { - n += max(u.Size, 0) - } - freed <- n - }() - err = d.Client.Prune(ctx, ch) - close(ch) - n := <-freed - if err != nil { - return err - } - log.G(ctx).Info().Str("freed", units.BytesSize(float64(n))).Msg("pruned the builder's cache") - return nil - }) -} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 32713c55..ab28bd65 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -40,10 +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."` - Buildkit BuildkitCmd `cmd:"" group:"cmd-commands" help:"Manage the builder that remote builds run on."` - 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/unavailable_js.go b/internal/cmd/unavailable_js.go index 78d4dcc3..cd0def50 100644 --- a/internal/cmd/unavailable_js.go +++ b/internal/cmd/unavailable_js.go @@ -33,14 +33,6 @@ func (c *ImageBuildCmd) Run(ctx context.Context, cfg *config.Config, partition * return notAvailable("image build") } -func (c *BuildkitDuCmd) Run(ctx context.Context, stdio config.Stdio) error { - return notAvailable("buildkit du") -} - -func (c *BuildkitPruneCmd) Run(ctx context.Context) error { - return notAvailable("buildkit prune") -} - func (c *CompletionCmd) Run(ctx *kong.Context) error { return notAvailable("completion") }