From 2edaf058b1097ec33f8e59381625d9c3d39aed1b Mon Sep 17 00:00:00 2001 From: jedevc <7352848+jedevc@users.noreply.github.com> Date: Mon, 26 Oct 2026 09:26:04 +0000 Subject: [PATCH 01/35] chore(deps): Bump Go to 1.27.3 Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index f034a863..e51e8cdb 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module unikraft.com/cli -go 1.27.2 +go 1.27.3 tool github.com/caarlos0/svu/v3 From 8c1b9db1d8282dc303ca7ee6f39170cdd18aac87 Mon Sep 17 00:00:00 2001 From: Dragos Gheorghioiu Date: Mon, 26 Oct 2026 11:29:31 +0200 Subject: [PATCH 02/35] 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 03/35] 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 04/35] 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 05/35] 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 eb09b0c7c8e721559b0897f22ad78ca485c6ce46 Mon Sep 17 00:00:00 2001 From: Dragos Gheorghioiu Date: Thu, 29 Oct 2026 10:30:35 +0200 Subject: [PATCH 06/35] test(cmd): Move the buildkit test to x/buildkit The test checks only x/buildkit, which now runs it in its own CI. Signed-off-by: Dragos Gheorghioiu --- internal/cmd/buildkit_integration_test.go | 127 ---------------------- 1 file changed, 127 deletions(-) delete mode 100644 internal/cmd/buildkit_integration_test.go diff --git a/internal/cmd/buildkit_integration_test.go b/internal/cmd/buildkit_integration_test.go deleted file mode 100644 index c78651ed..00000000 --- a/internal/cmd/buildkit_integration_test.go +++ /dev/null @@ -1,127 +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" - "crypto/rand" - "errors" - "os" - "strings" - "sync" - "testing" - "time" - - "github.com/moby/buildkit/client" - "github.com/moby/buildkit/client/llb" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "unikraft.com/cloud/sdk/platform" - xbuildkit "unikraft.com/x/buildkit" - "unikraft.com/x/ptr" - - "unikraft.com/cli/internal/httpclient" - "unikraft.com/cli/internal/integration" -) - -// TestBuildkitPlatformAcquire creates a builder in the integration profile's -// metro and deletes it afterwards. -func TestBuildkitPlatformAcquire(t *testing.T) { - integration.SkipUnlessIntegration(t) - cfg, err := integration.LoadRemote(t) - if errors.Is(err, os.ErrNotExist) { - t.Skip("integration config not found") - } - require.NoError(t, err) - if cfg == nil || cfg.Config == nil { - t.Skip("integration config unavailable") - } - - pc := platform.NewClient( - platform.WithHTTPClient(httpclient.GetClient(ptr.ZeroIfNil(cfg.Metro.Insecure))), - platform.WithToken(cfg.Profile.Token), - platform.WithDefaultMetro(cfg.Metro.URL()), - ) - p := xbuildkit.NewPlatform(pc, - xbuildkit.WithVCPUs(1), - xbuildkit.WithMemory(1024), - xbuildkit.WithCacheSize(2048), - xbuildkit.WithInsecure(ptr.ZeroIfNil(cfg.Metro.Insecure)), - ) - key := xbuildkit.Key{Name: "test-" + strings.ToLower(rand.Text()[:8])} - name := "buildkit-" + key.Name - t.Cleanup(func() { deleteBuilder(t, pc, name) }) - - // Two first builds race to create the builder. - var wg sync.WaitGroup - daemons := make([]*xbuildkit.Daemon, 2) - errs := make([]error, 2) - for i := range daemons { - wg.Go(func() { daemons[i], errs[i] = p.Acquire(t.Context(), key) }) - } - wg.Wait() - for i, d := range daemons { - require.NoError(t, errs[i]) - assert.Equal(t, daemons[0].Addr, d.Addr) - assert.Equal(t, daemons[0].TLS, d.TLS) - } - solve(t, daemons[0]) - for _, d := range daemons { - require.NoError(t, d.Release()) - } - - // A stopped builder does not wake on connect. - _, err = pc.StopInstances(t.Context(), []platform.StopInstancesRequestItem{{Name: &name}}) - require.NoError(t, err) - _, err = pc.WaitInstances(t.Context(), []platform.WaitInstancesRequestItem{{Name: &name, State: new(platform.InstanceStateStopped)}}, platform.WaitInstancesOpts{}) - require.NoError(t, err) - d, err := p.Acquire(t.Context(), key) - require.NoError(t, err) - solve(t, d) - require.NoError(t, d.Release()) - - // Nor does one that is still shutting down when Acquire finds it. - _, err = pc.StopInstances(t.Context(), []platform.StopInstancesRequestItem{{Name: &name, DrainTimeoutMs: new(uint64(10_000))}}) - require.NoError(t, err) - d, err = p.Acquire(t.Context(), key) - require.NoError(t, err) - solve(t, d) - require.NoError(t, d.Release()) -} - -func solve(t *testing.T, d *xbuildkit.Daemon) { - t.Helper() - def, err := llb.Image("docker.io/library/alpine:3.22", llb.LinuxAmd64).Run(llb.Shlex("true")).Root().Marshal(t.Context(), llb.LinuxAmd64) - require.NoError(t, err) - _, err = d.Client.Solve(t.Context(), def, client.SolveOpt{}, nil) - require.NoError(t, err) -} - -func deleteBuilder(t *testing.T, pc platform.Client, name string) { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) - defer cancel() - - _, err := pc.DeleteInstances(ctx, []platform.DeleteInstanceRequestItem{{Name: &name, DontRetain: new(true)}}) - if err != nil && !platform.ErrorContainsOnly(err, platform.APIHTTPErrorNotFound) { - t.Errorf("deleting builder %s: %v", name, err) - } - // The volume stays attached until the instance is gone. - vol := name + "-cache" - for { - _, err := pc.DeleteVolumes(ctx, []platform.NameOrUUID{{Name: &vol}}) - if err == nil || platform.ErrorContainsOnly(err, platform.APIHTTPErrorNotFound) { - return - } - select { - case <-ctx.Done(): - t.Errorf("deleting volume %s: %v", vol, err) - return - case <-time.After(3 * time.Second): - } - } -} From ab412c9e3a5846ff159619d808458ccf141d13b1 Mon Sep 17 00:00:00 2001 From: Dragos Gheorghioiu Date: Fri, 30 Oct 2026 10:45:22 +0200 Subject: [PATCH 07/35] fix(cmd): Keep exec's stderr apart from stdout `instance exec` wrote the command's stderr into stdout, so a pipe or a redirect of stdout also took the command's warnings and `2>/dev/null` could not silence them. Send each stream to its local counterpart, as `docker exec` and `kubectl exec` do without a TTY. Signed-off-by: Dragos Gheorghioiu --- internal/cmd/sandbox.go | 6 ++---- internal/cmd/shell_test.go | 22 ++++++++++++++++++++-- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/internal/cmd/sandbox.go b/internal/cmd/sandbox.go index d4340f72..c39fbcc9 100644 --- a/internal/cmd/sandbox.go +++ b/internal/cmd/sandbox.go @@ -91,14 +91,12 @@ func (c *ExecSandboxInstanceCmd) runOn(ctx context.Context, target sandbox.Targe in = strings.NewReader("") } - out := xio.Unwrap(stdio.Stdout) - cmd := target.CommandArgs(ctx, c.Cmd) cmd.Dir = c.Dir cmd.Env = sandbox.EnvMap(c.Env) cmd.Stdin = in - cmd.Stdout = out - cmd.Stderr = out + cmd.Stdout = xio.Unwrap(stdio.Stdout) + cmd.Stderr = xio.Unwrap(stdio.Stderr) code, err := shell.ExitStatus(cmd.Run()) if err != nil { diff --git a/internal/cmd/shell_test.go b/internal/cmd/shell_test.go index f3f89e96..9c5f2439 100644 --- a/internal/cmd/shell_test.go +++ b/internal/cmd/shell_test.go @@ -6,6 +6,7 @@ package cmd import ( + "bytes" "encoding/base64" "encoding/json" "errors" @@ -83,10 +84,11 @@ func TestShellEnvFlagKeepsCommas(t *testing.T) { require.ErrorContains(t, err, "--env", "a record without a value is refused, the same way exec refuses it") } -// onePlugin runs a single command: it delivers out on standard output, exits -// with code, and records the signals it was sent. +// onePlugin runs a single command: it delivers out on standard output and err +// on standard error, exits with code, and records the signals it was sent. type onePlugin struct { out string + err string code int32 hang chan struct{} @@ -122,6 +124,10 @@ func (f *onePlugin) ServeHTTP(w http.ResponseWriter, r *http.Request) { data["stdout"] = base64.StdEncoding.EncodeToString([]byte(f.out[req.Stdout.Offset:])) } data["stdout_available"] = len(f.out) + if req.Stderr.Offset < uint64(len(f.err)) { + data["stderr"] = base64.StdEncoding.EncodeToString([]byte(f.err[req.Stderr.Offset:])) + } + data["stderr_available"] = len(f.err) case path == "/commands/cmd-1" && r.Method == http.MethodGet: data["exitcode"] = f.code @@ -197,3 +203,15 @@ func TestExecSurvivesAClosedPipe(t *testing.T) { assert.Equal(t, []int{int(syscall.SIGPIPE)}, fake.sent(), "the instance is told the reader has gone, the way a local pipeline tells it") } + +func TestExecKeepsTheStreamsApart(t *testing.T) { + target := fakeSandboxTarget(t, &onePlugin{out: "out\n", err: "err\n"}) + + var stdout, stderr bytes.Buffer + err := (&ExecSandboxInstanceCmd{Cmd: []string{"sh"}}).runOn(t.Context(), target, + config.Stdio{Stdout: &stdout, Stderr: &stderr}) + + require.NoError(t, err) + assert.Equal(t, "out\n", stdout.String()) + assert.Equal(t, "err\n", stderr.String()) +} From 620e68809e62a6fe1f079523c5d5e6f7c4005688 Mon Sep 17 00:00:00 2001 From: Justin Chadwell Date: Fri, 30 Oct 2026 12:59:41 +0000 Subject: [PATCH 08/35] 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 09/35] 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 10/35] 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") } From 86cfcd0a49bc63709669039e248e83e8cb010509 Mon Sep 17 00:00:00 2001 From: Dragos Gheorghioiu Date: Wed, 7 Oct 2026 14:36:08 +0300 Subject: [PATCH 11/35] docs(cmd): Describe every resource field in help Most resource fields had no help text, so --help and the fields reference showed bare names such as state or vcpus, and tools that read the field list had nothing to explain a value with. Give each field of the resource structs a help tag, and keep the help and example tags on resource.Field so that help output, the fields reference and tool schemas share one description. Signed-off-by: Dragos Gheorghioiu --- cmd/unikraft/testdata/TestHelp/certificates | 10 +- cmd/unikraft/testdata/TestHelp/instances | 327 +++++++++++--------- cmd/unikraft/testdata/TestHelp/run | 90 +++--- cmd/unikraft/testdata/TestHelp/services | 42 ++- cmd/unikraft/testdata/TestHelp/volumes | 39 ++- internal/cmd/certificates.go | 30 +- internal/cmd/images.go | 40 +-- internal/cmd/instance_checkpoints.go | 68 ++-- internal/cmd/instance_templates.go | 58 ++-- internal/cmd/instances.go | 172 +++++----- internal/cmd/link.go | 4 +- internal/cmd/marshal_test.go | 2 +- internal/cmd/metros.go | 49 +-- internal/cmd/profile.go | 6 +- internal/cmd/services.go | 38 +-- internal/cmd/volume_templates.go | 24 +- internal/cmd/volumes.go | 44 +-- internal/config/config.go | 6 +- internal/config/profile.go | 32 +- internal/resource/resource.go | 5 + internal/resource/struct.go | 6 + internal/resource/struct_test.go | 20 ++ internal/resource/testing/resource.go | 3 + internal/types/quota.go | 8 +- 24 files changed, 618 insertions(+), 505 deletions(-) diff --git a/cmd/unikraft/testdata/TestHelp/certificates b/cmd/unikraft/testdata/TestHelp/certificates index 2a5a19ef..568e736e 100644 --- a/cmd/unikraft/testdata/TestHelp/certificates +++ b/cmd/unikraft/testdata/TestHelp/certificates @@ -295,17 +295,17 @@ Global flags: Create flags: --metro= - Metro to create in. + Metro to create in. Defaults to the profile's default metro. [examples: fra, sfo] --name= - Certificate name. + Certificate name. Generated when omitted. --common-name=, --cn - Certificate common name. + Common name: the fully qualified domain name the certificate is for, with a trailing dot. [example: demo.unikraft.dev.] --chain= - Certificate chain file. + PEM certificate chain: the certificate followed by any intermediates. --private-key=, --pkey - Certificate private key file. + PEM private key matching the certificate chain. $ unikraft certificate delete --help diff --git a/cmd/unikraft/testdata/TestHelp/instances b/cmd/unikraft/testdata/TestHelp/instances index a623cdae..adc62ebb 100644 --- a/cmd/unikraft/testdata/TestHelp/instances +++ b/cmd/unikraft/testdata/TestHelp/instances @@ -489,87 +489,101 @@ Global flags: Create flags: --metro= - Metro to deploy in. + Metro to deploy in. Defaults to the profile's default metro. [examples: fra, sfo] -n, --name= - Instance name. + Instance name, unique per metro. Generated when omitted. --tag= ... - Instance tag. + Tags for grouping and filtering: up to 16, each 1 to 256 characters of letters, digits and -+_.:=. Not visible to the guest. [example: env-prod] --annotation== ... - Instance annotation. + Key-value metadata: up to 256 entries with Kubernetes-style [prefix/]name keys. Unlike tags, annotations reach the guest in its start data. [examples: env=production, example.com/team=platform] --image=: - Image to deploy. + Image to deploy, as an OCI reference or a full registry URL. On create, required unless template, branch or checkpoint is given, and cannot be combined with them. [examples: nginx:latest, my-app:v1.2.3] --pull-policy= - Image pull policy. - [examples: always, never, if_not_present] + When to pull the image relative to the node's cache: always, if_not_present or never. + [examples: always, if_not_present, never] --type= - Type of virtual machine to run. "full" requires a plan with full VM support. + Virtual machine type. + micro: Firecracker microVM (default) + full: QEMU virtual machine with GPU support, needs a plan with full VM support and has no branching, checkpoints, vertical scaling, annotations or virtiofs volumes. [examples: micro, full] --args= - Arguments to pass to the instance. + Command-line arguments passed to the instance at start. + [example: --port=8080] -e, --env== ... - Environment variable. + Environment variable as KEY=value. [example: DEBUG=true] -m, --memory= - Memory allocation. + Memory for the instance, within the range the plan allows (see quotas.limits.memory on the metro). Defaults to 128MiB on create. [examples: 128MiB, 1GiB] --vcpus= - Number of vCPUs. + Number of vCPUs, within the range the plan allows (see quotas.limits.vcpus on the metro). Defaults to 1 on create. [examples: 1, 2, 4] --gpus= - Number of GPUs to attach. Requires type "full" and a plan with GPU support. Currently limited to 1. + Number of GPUs to attach, 0 or 1. Requires type full and a plan with GPU support. The instance holds a GPU only while it runs, and gets one of the same model on each start. Cannot be combined with template, branch or checkpoint. [examples: 0, 1] --service= - Service group name or key. + Service group to join, by name or UUID. Leave unset and give service.services or service.domains to create a group owned by the instance. Without any service the instance is reachable only privately. -p, --publish=:[/] ... - Publish port. - [example: 443:8080/http+tls] + Published port as SOURCE:DESTINATION[/HANDLERS], with handlers joined by +. Port 80 must use http, port 443 http+tls, and any other port tls only. + http: load balance per HTTP request instead of per TCP connection + tls: terminate TLS at the platform + redirect: redirect HTTP on port 80 to HTTPS, as in 80:443/http+redirect. + [examples: 443:8080/http+tls, 80:443/http+redirect] --domain= ... - Service domain. - [example: example.com] - -v, --volume=:[:] ... - Attach volume. - [examples: my-vol:/data, cache:/tmp:ro, data:/mnt:size=10GiB] + Domain for the group. + myapp: a bare label becomes a subdomain of the metro + example.com: a dotted name is a custom domain, with a certificate issued automatically unless given as fqdn=example.com,certificate=my-cert. + [examples: example.com, myapp] + -v, --volume=[]:[:] ... + Volume to attach as NAME:/MOUNT[:ro], where the mount path is absolute and outside /uk. On create, leave NAME empty and add :size=SIZE, as in :/data:size=1GiB, to make a new volume with a generated name. + [examples: my-vol:/data, cache:/tmp:ro, :/data:size=1GiB] --rom=name=,image=,at= ... - Attach ROM. + Read-only ROM device from an image or a local directory. The name uses letters, digits, - and _, and appears as /dev/ukp_rom_NAME in the guest. [examples: name=my-rom,image=myuser/my-rom:latest,at=/rom0, name=mydata,dir=./mydata,at=/rom] --plugin=name=,image=[,config=] ... - Load plugin into the instance. + Plugin loaded from an image, with an optional JSON config passed to it on start. Up to 8 per instance. [examples: name=sandbox,image=plugins/sandbox:latest, name=sandbox,image=plugins/sandbox:latest,config={"persist_path":"/data"}] --scale-to-zero== - Scale-to-zero options. - policy: on | idle | off - cooldown-time: cooldown in ms before scaling to zero - notify-time: notification time in ms before scaling to zero - stateful: true | false. + Scale-to-zero options. Traffic through a service or to the private IP wakes the instance. + policy: on | idle (also while open TCP connections are idle, needs stateful=true) | off + cooldown-time: ms of inactivity before scaling to zero + notify-time: ms the instance is warned beforehand, 0 disables and the value must be below cooldown-time + stateful: true keeps RAM in a snapshot and resumes from it, false restarts from scratch. [examples: on, policy=on,cooldown-time=300, policy=on,stateful=true,cooldown-time=500,notify-time=100] --autokill== Autokill options. - time: time after the instance stops before it is deleted - num-requests: max requests before the instance is deleted. + time: time after the instance stops before it is deleted, such as 5s or 24h, 0 disables + num-requests: delete the instance after it has served this many requests or connections. [examples: time=5s, num-requests=100, time=5s,num-requests=100] --restart= - Restart policy. + Restart policy. Restarts back off from immediate up to 5m. Cannot be combined with the delete-on-stop feature. + never: never restart (default) + always: restart whenever the instance exits or crashes + on-failure: restart only after a crash or a non-zero exit code. [examples: always, on-failure, never] --sched-priority= - Scheduling priority for the instance. + Scheduling priority: normal (default), medium, high or admin. Requires the override_vm_priority permission. [examples: normal, medium, high, admin] --autostart - Start instance automatically. + Start the instance right after creation. When false it stays stopped, except that scale-to-zero puts it in standby, where incoming traffic starts it. --replicas= - Number of replicas. + Additional identical instances to create, so replicas=2 creates three in total. [examples: 1, 3] --feature= ... - Instance feature. + Instance feature to enable. + delete-on-stop: delete the instance when it stops, not with a restart policy or delete-lock + nested-virt: expose virtualization extensions to the guest, needs the nested_virt permission. + [example: delete-on-stop] --template= - Create from instance template. + Create from an instance template, by name or UUID. The clone inherits its configuration and, when the template has a snapshot, its memory state. Cannot be combined with image, branch, checkpoint, type or gpus. --branch= - Branch from an existing instance. + Clone an existing instance, by name or UUID. A running source is snapshotted asynchronously and keeps running. Cannot be combined with image, args, env, memory, vcpus, type or gpus. --checkpoint= - Create from a checkpoint. + Create from a checkpoint, by name or UUID, restoring its memory and disk. Cannot be combined with image, args, env, memory, vcpus, type or gpus. --rm Automatically delete the instance when it stops. @@ -690,87 +704,101 @@ Global flags: Create flags: --metro= - Metro to deploy in. + Metro to deploy in. Defaults to the profile's default metro. [examples: fra, sfo] -n, --name= - Instance name. + Instance name, unique per metro. Generated when omitted. --tag= ... - Instance tag. + Tags for grouping and filtering: up to 16, each 1 to 256 characters of letters, digits and -+_.:=. Not visible to the guest. [example: env-prod] --annotation== ... - Instance annotation. + Key-value metadata: up to 256 entries with Kubernetes-style [prefix/]name keys. Unlike tags, annotations reach the guest in its start data. [examples: env=production, example.com/team=platform] --image=: - Image to deploy. + Image to deploy, as an OCI reference or a full registry URL. On create, required unless template, branch or checkpoint is given, and cannot be combined with them. [examples: nginx:latest, my-app:v1.2.3] --pull-policy= - Image pull policy. - [examples: always, never, if_not_present] + When to pull the image relative to the node's cache: always, if_not_present or never. + [examples: always, if_not_present, never] --type= - Type of virtual machine to run. "full" requires a plan with full VM support. + Virtual machine type. + micro: Firecracker microVM (default) + full: QEMU virtual machine with GPU support, needs a plan with full VM support and has no branching, checkpoints, vertical scaling, annotations or virtiofs volumes. [examples: micro, full] --args= - Arguments to pass to the instance. + Command-line arguments passed to the instance at start. + [example: --port=8080] -e, --env== ... - Environment variable. + Environment variable as KEY=value. [example: DEBUG=true] -m, --memory= - Memory allocation. + Memory for the instance, within the range the plan allows (see quotas.limits.memory on the metro). Defaults to 128MiB on create. [examples: 128MiB, 1GiB] --vcpus= - Number of vCPUs. + Number of vCPUs, within the range the plan allows (see quotas.limits.vcpus on the metro). Defaults to 1 on create. [examples: 1, 2, 4] --gpus= - Number of GPUs to attach. Requires type "full" and a plan with GPU support. Currently limited to 1. + Number of GPUs to attach, 0 or 1. Requires type full and a plan with GPU support. The instance holds a GPU only while it runs, and gets one of the same model on each start. Cannot be combined with template, branch or checkpoint. [examples: 0, 1] --service= - Service group name or key. + Service group to join, by name or UUID. Leave unset and give service.services or service.domains to create a group owned by the instance. Without any service the instance is reachable only privately. -p, --publish=:[/] ... - Publish port. - [example: 443:8080/http+tls] + Published port as SOURCE:DESTINATION[/HANDLERS], with handlers joined by +. Port 80 must use http, port 443 http+tls, and any other port tls only. + http: load balance per HTTP request instead of per TCP connection + tls: terminate TLS at the platform + redirect: redirect HTTP on port 80 to HTTPS, as in 80:443/http+redirect. + [examples: 443:8080/http+tls, 80:443/http+redirect] --domain= ... - Service domain. - [example: example.com] - -v, --volume=:[:] ... - Attach volume. - [examples: my-vol:/data, cache:/tmp:ro, data:/mnt:size=10GiB] + Domain for the group. + myapp: a bare label becomes a subdomain of the metro + example.com: a dotted name is a custom domain, with a certificate issued automatically unless given as fqdn=example.com,certificate=my-cert. + [examples: example.com, myapp] + -v, --volume=[]:[:] ... + Volume to attach as NAME:/MOUNT[:ro], where the mount path is absolute and outside /uk. On create, leave NAME empty and add :size=SIZE, as in :/data:size=1GiB, to make a new volume with a generated name. + [examples: my-vol:/data, cache:/tmp:ro, :/data:size=1GiB] --rom=name=,image=,at= ... - Attach ROM. + Read-only ROM device from an image or a local directory. The name uses letters, digits, - and _, and appears as /dev/ukp_rom_NAME in the guest. [examples: name=my-rom,image=myuser/my-rom:latest,at=/rom0, name=mydata,dir=./mydata,at=/rom] --plugin=name=,image=[,config=] ... - Load plugin into the instance. + Plugin loaded from an image, with an optional JSON config passed to it on start. Up to 8 per instance. [examples: name=sandbox,image=plugins/sandbox:latest, name=sandbox,image=plugins/sandbox:latest,config={"persist_path":"/data"}] --scale-to-zero== - Scale-to-zero options. - policy: on | idle | off - cooldown-time: cooldown in ms before scaling to zero - notify-time: notification time in ms before scaling to zero - stateful: true | false. + Scale-to-zero options. Traffic through a service or to the private IP wakes the instance. + policy: on | idle (also while open TCP connections are idle, needs stateful=true) | off + cooldown-time: ms of inactivity before scaling to zero + notify-time: ms the instance is warned beforehand, 0 disables and the value must be below cooldown-time + stateful: true keeps RAM in a snapshot and resumes from it, false restarts from scratch. [examples: on, policy=on,cooldown-time=300, policy=on,stateful=true,cooldown-time=500,notify-time=100] --autokill== Autokill options. - time: time after the instance stops before it is deleted - num-requests: max requests before the instance is deleted. + time: time after the instance stops before it is deleted, such as 5s or 24h, 0 disables + num-requests: delete the instance after it has served this many requests or connections. [examples: time=5s, num-requests=100, time=5s,num-requests=100] --restart= - Restart policy. + Restart policy. Restarts back off from immediate up to 5m. Cannot be combined with the delete-on-stop feature. + never: never restart (default) + always: restart whenever the instance exits or crashes + on-failure: restart only after a crash or a non-zero exit code. [examples: always, on-failure, never] --sched-priority= - Scheduling priority for the instance. + Scheduling priority: normal (default), medium, high or admin. Requires the override_vm_priority permission. [examples: normal, medium, high, admin] --autostart - Start instance automatically. + Start the instance right after creation. When false it stays stopped, except that scale-to-zero puts it in standby, where incoming traffic starts it. --replicas= - Number of replicas. + Additional identical instances to create, so replicas=2 creates three in total. [examples: 1, 3] --feature= ... - Instance feature. + Instance feature to enable. + delete-on-stop: delete the instance when it stops, not with a restart policy or delete-lock + nested-virt: expose virtualization extensions to the guest, needs the nested_virt permission. + [example: delete-on-stop] --template= - Create from instance template. + Create from an instance template, by name or UUID. The clone inherits its configuration and, when the template has a snapshot, its memory state. Cannot be combined with image, branch, checkpoint, type or gpus. --branch= - Branch from an existing instance. + Clone an existing instance, by name or UUID. A running source is snapshotted asynchronously and keeps running. Cannot be combined with image, args, env, memory, vcpus, type or gpus. --checkpoint= - Create from a checkpoint. + Create from a checkpoint, by name or UUID, restoring its memory and disk. Cannot be combined with image, args, env, memory, vcpus, type or gpus. --rm Automatically delete the instance when it stops. @@ -901,87 +929,101 @@ Global flags: Create flags: --metro= - Metro to deploy in. + Metro to deploy in. Defaults to the profile's default metro. [examples: fra, sfo] -n, --name= - Instance name. + Instance name, unique per metro. Generated when omitted. --tag= ... - Instance tag. + Tags for grouping and filtering: up to 16, each 1 to 256 characters of letters, digits and -+_.:=. Not visible to the guest. [example: env-prod] --annotation== ... - Instance annotation. + Key-value metadata: up to 256 entries with Kubernetes-style [prefix/]name keys. Unlike tags, annotations reach the guest in its start data. [examples: env=production, example.com/team=platform] --image=: - Image to deploy. + Image to deploy, as an OCI reference or a full registry URL. On create, required unless template, branch or checkpoint is given, and cannot be combined with them. [examples: nginx:latest, my-app:v1.2.3] --pull-policy= - Image pull policy. - [examples: always, never, if_not_present] + When to pull the image relative to the node's cache: always, if_not_present or never. + [examples: always, if_not_present, never] --type= - Type of virtual machine to run. "full" requires a plan with full VM support. + Virtual machine type. + micro: Firecracker microVM (default) + full: QEMU virtual machine with GPU support, needs a plan with full VM support and has no branching, checkpoints, vertical scaling, annotations or virtiofs volumes. [examples: micro, full] --args= - Arguments to pass to the instance. + Command-line arguments passed to the instance at start. + [example: --port=8080] -e, --env== ... - Environment variable. + Environment variable as KEY=value. [example: DEBUG=true] -m, --memory= - Memory allocation. + Memory for the instance, within the range the plan allows (see quotas.limits.memory on the metro). Defaults to 128MiB on create. [examples: 128MiB, 1GiB] --vcpus= - Number of vCPUs. + Number of vCPUs, within the range the plan allows (see quotas.limits.vcpus on the metro). Defaults to 1 on create. [examples: 1, 2, 4] --gpus= - Number of GPUs to attach. Requires type "full" and a plan with GPU support. Currently limited to 1. + Number of GPUs to attach, 0 or 1. Requires type full and a plan with GPU support. The instance holds a GPU only while it runs, and gets one of the same model on each start. Cannot be combined with template, branch or checkpoint. [examples: 0, 1] --service= - Service group name or key. + Service group to join, by name or UUID. Leave unset and give service.services or service.domains to create a group owned by the instance. Without any service the instance is reachable only privately. -p, --publish=:[/] ... - Publish port. - [example: 443:8080/http+tls] + Published port as SOURCE:DESTINATION[/HANDLERS], with handlers joined by +. Port 80 must use http, port 443 http+tls, and any other port tls only. + http: load balance per HTTP request instead of per TCP connection + tls: terminate TLS at the platform + redirect: redirect HTTP on port 80 to HTTPS, as in 80:443/http+redirect. + [examples: 443:8080/http+tls, 80:443/http+redirect] --domain= ... - Service domain. - [example: example.com] - -v, --volume=:[:] ... - Attach volume. - [examples: my-vol:/data, cache:/tmp:ro, data:/mnt:size=10GiB] + Domain for the group. + myapp: a bare label becomes a subdomain of the metro + example.com: a dotted name is a custom domain, with a certificate issued automatically unless given as fqdn=example.com,certificate=my-cert. + [examples: example.com, myapp] + -v, --volume=[]:[:] ... + Volume to attach as NAME:/MOUNT[:ro], where the mount path is absolute and outside /uk. On create, leave NAME empty and add :size=SIZE, as in :/data:size=1GiB, to make a new volume with a generated name. + [examples: my-vol:/data, cache:/tmp:ro, :/data:size=1GiB] --rom=name=,image=,at= ... - Attach ROM. + Read-only ROM device from an image or a local directory. The name uses letters, digits, - and _, and appears as /dev/ukp_rom_NAME in the guest. [examples: name=my-rom,image=myuser/my-rom:latest,at=/rom0, name=mydata,dir=./mydata,at=/rom] --plugin=name=,image=[,config=] ... - Load plugin into the instance. + Plugin loaded from an image, with an optional JSON config passed to it on start. Up to 8 per instance. [examples: name=sandbox,image=plugins/sandbox:latest, name=sandbox,image=plugins/sandbox:latest,config={"persist_path":"/data"}] --scale-to-zero== - Scale-to-zero options. - policy: on | idle | off - cooldown-time: cooldown in ms before scaling to zero - notify-time: notification time in ms before scaling to zero - stateful: true | false. + Scale-to-zero options. Traffic through a service or to the private IP wakes the instance. + policy: on | idle (also while open TCP connections are idle, needs stateful=true) | off + cooldown-time: ms of inactivity before scaling to zero + notify-time: ms the instance is warned beforehand, 0 disables and the value must be below cooldown-time + stateful: true keeps RAM in a snapshot and resumes from it, false restarts from scratch. [examples: on, policy=on,cooldown-time=300, policy=on,stateful=true,cooldown-time=500,notify-time=100] --autokill== Autokill options. - time: time after the instance stops before it is deleted - num-requests: max requests before the instance is deleted. + time: time after the instance stops before it is deleted, such as 5s or 24h, 0 disables + num-requests: delete the instance after it has served this many requests or connections. [examples: time=5s, num-requests=100, time=5s,num-requests=100] --restart= - Restart policy. + Restart policy. Restarts back off from immediate up to 5m. Cannot be combined with the delete-on-stop feature. + never: never restart (default) + always: restart whenever the instance exits or crashes + on-failure: restart only after a crash or a non-zero exit code. [examples: always, on-failure, never] --sched-priority= - Scheduling priority for the instance. + Scheduling priority: normal (default), medium, high or admin. Requires the override_vm_priority permission. [examples: normal, medium, high, admin] --autostart - Start instance automatically. + Start the instance right after creation. When false it stays stopped, except that scale-to-zero puts it in standby, where incoming traffic starts it. --replicas= - Number of replicas. + Additional identical instances to create, so replicas=2 creates three in total. [examples: 1, 3] --feature= ... - Instance feature. + Instance feature to enable. + delete-on-stop: delete the instance when it stops, not with a restart policy or delete-lock + nested-virt: expose virtualization extensions to the guest, needs the nested_virt permission. + [example: delete-on-stop] --template= - Create from instance template. + Create from an instance template, by name or UUID. The clone inherits its configuration and, when the template has a snapshot, its memory state. Cannot be combined with image, branch, checkpoint, type or gpus. --branch= - Branch from an existing instance. + Clone an existing instance, by name or UUID. A running source is snapshotted asynchronously and keeps running. Cannot be combined with image, args, env, memory, vcpus, type or gpus. --checkpoint= - Create from a checkpoint. + Create from a checkpoint, by name or UUID, restoring its memory and disk. Cannot be combined with image, args, env, memory, vcpus, type or gpus. --rm Automatically delete the instance when it stops. @@ -1100,48 +1142,49 @@ Global flags: Edit flags: --tag= ... - Instance tag. + Tags for grouping and filtering: up to 16, each 1 to 256 characters of letters, digits and -+_.:=. Not visible to the guest. [example: env-prod] --annotation== ... - Instance annotation. + Key-value metadata: up to 256 entries with Kubernetes-style [prefix/]name keys. Unlike tags, annotations reach the guest in its start data. [examples: env=production, example.com/team=platform] --image=: - Image to deploy. + Image to deploy, as an OCI reference or a full registry URL. On create, required unless template, branch or checkpoint is given, and cannot be combined with them. [examples: nginx:latest, my-app:v1.2.3] --args= - Arguments to pass to the instance. + Command-line arguments passed to the instance at start. + [example: --port=8080] -e, --env== ... - Environment variable. + Environment variable as KEY=value. [example: DEBUG=true] -m, --memory= - Memory allocation. + Memory for the instance, within the range the plan allows (see quotas.limits.memory on the metro). Defaults to 128MiB on create. [examples: 128MiB, 1GiB] --vcpus= - Number of vCPUs. + Number of vCPUs, within the range the plan allows (see quotas.limits.vcpus on the metro). Defaults to 1 on create. [examples: 1, 2, 4] --rom=name=,image=,at= ... - Attach ROM. + Read-only ROM device from an image or a local directory. The name uses letters, digits, - and _, and appears as /dev/ukp_rom_NAME in the guest. [examples: name=my-rom,image=myuser/my-rom:latest,at=/rom0, name=mydata,dir=./mydata,at=/rom] --plugin=name=,image=[,config=] ... - Load plugin into the instance. + Plugin loaded from an image, with an optional JSON config passed to it on start. Up to 8 per instance. [examples: name=sandbox,image=plugins/sandbox:latest, name=sandbox,image=plugins/sandbox:latest,config={"persist_path":"/data"}] --scale-to-zero== - Scale-to-zero options. - policy: on | idle | off - cooldown-time: cooldown in ms before scaling to zero - notify-time: notification time in ms before scaling to zero - stateful: true | false. + Scale-to-zero options. Traffic through a service or to the private IP wakes the instance. + policy: on | idle (also while open TCP connections are idle, needs stateful=true) | off + cooldown-time: ms of inactivity before scaling to zero + notify-time: ms the instance is warned beforehand, 0 disables and the value must be below cooldown-time + stateful: true keeps RAM in a snapshot and resumes from it, false restarts from scratch. [examples: on, policy=on,cooldown-time=300, policy=on,stateful=true,cooldown-time=500,notify-time=100] --autokill== Autokill options. - time: time after the instance stops before it is deleted - num-requests: max requests before the instance is deleted. + time: time after the instance stops before it is deleted, such as 5s or 24h, 0 disables + num-requests: delete the instance after it has served this many requests or connections. [examples: time=5s, num-requests=100, time=5s,num-requests=100] --sched-priority= - Scheduling priority for the instance. + Scheduling priority: normal (default), medium, high or admin. Requires the override_vm_priority permission. [examples: normal, medium, high, admin] --delete-lock - Prevent instance deletion until the lock is removed. + Prevent deletion until the lock is removed. Also pauses autokill. $ unikraft instance delete --help @@ -1438,7 +1481,7 @@ Usage: Arguments: [] - Instance to convert into a template. + Instance to turn into a template, by name or UUID. A running instance is suspended into a snapshot first and becomes the template itself. Examples: # Convert an instance into a template @@ -1505,7 +1548,7 @@ Global flags: Create flags: --autokill== Autokill options. - time: time without a clone before the template is deleted. + time: time since the template was last cloned before it is deleted, such as 24h, 0 disables. [example: time=24h] $ unikraft instance template edit --help @@ -1587,13 +1630,13 @@ Global flags: Edit flags: --tag= ... - Template tag. + Tags for grouping and filtering: up to 16, each 1 to 256 characters of letters, digits and -+_.:=. Not visible to the guest. [example: env-dev] --delete-lock - Prevent deletion of the template. + Prevent deletion until the lock is removed. Also pauses autokill. --autokill== Autokill options. - time: time without a clone before the template is deleted. + time: time since the template was last cloned before it is deleted, such as 24h, 0 disables. [example: time=24h] $ unikraft instance template delete --help @@ -1941,7 +1984,7 @@ Usage: Arguments: [] - Instance to create a checkpoint from. + Running or stopped instance to checkpoint, by name or UUID. The source keeps running and the checkpoint waits for its snapshot. Examples: # Create a checkpoint from a running instance @@ -2008,7 +2051,7 @@ Global flags: Create flags: --autokill== Autokill options. - time: time without a restore before the checkpoint is deleted. + time: time since the checkpoint was last restored before it is deleted, such as 24h, 0 disables. [example: time=24h] $ unikraft instance checkpoint edit --help @@ -2090,13 +2133,13 @@ Global flags: Edit flags: --tag= ... - Checkpoint tag. + Tags for grouping and filtering: up to 16, each 1 to 256 characters of letters, digits and -+_.:=. Not visible to the guest. [example: env-dev] --delete-lock - Prevent deletion of the checkpoint. + Prevent deletion until the lock is removed. Also pauses autokill. --autokill== Autokill options. - time: time without a restore before the checkpoint is deleted. + time: time since the checkpoint was last restored before it is deleted, such as 24h, 0 disables. [example: time=24h] $ unikraft instance checkpoint delete --help diff --git a/cmd/unikraft/testdata/TestHelp/run b/cmd/unikraft/testdata/TestHelp/run index 5cd1c324..8ffc65e9 100644 --- a/cmd/unikraft/testdata/TestHelp/run +++ b/cmd/unikraft/testdata/TestHelp/run @@ -124,86 +124,100 @@ Global flags: Create flags: --metro= - Metro to deploy in. + Metro to deploy in. Defaults to the profile's default metro. [examples: fra, sfo] -n, --name= - Instance name. + Instance name, unique per metro. Generated when omitted. --tag= ... - Instance tag. + Tags for grouping and filtering: up to 16, each 1 to 256 characters of letters, digits and -+_.:=. Not visible to the guest. [example: env-prod] --annotation== ... - Instance annotation. + Key-value metadata: up to 256 entries with Kubernetes-style [prefix/]name keys. Unlike tags, annotations reach the guest in its start data. [examples: env=production, example.com/team=platform] --image=: - Image to deploy. + Image to deploy, as an OCI reference or a full registry URL. On create, required unless template, branch or checkpoint is given, and cannot be combined with them. [examples: nginx:latest, my-app:v1.2.3] --pull-policy= - Image pull policy. - [examples: always, never, if_not_present] + When to pull the image relative to the node's cache: always, if_not_present or never. + [examples: always, if_not_present, never] --type= - Type of virtual machine to run. "full" requires a plan with full VM support. + Virtual machine type. + micro: Firecracker microVM (default) + full: QEMU virtual machine with GPU support, needs a plan with full VM support and has no branching, checkpoints, vertical scaling, annotations or virtiofs volumes. [examples: micro, full] --args= - Arguments to pass to the instance. + Command-line arguments passed to the instance at start. + [example: --port=8080] -e, --env== ... - Environment variable. + Environment variable as KEY=value. [example: DEBUG=true] -m, --memory= - Memory allocation. + Memory for the instance, within the range the plan allows (see quotas.limits.memory on the metro). Defaults to 128MiB on create. [examples: 128MiB, 1GiB] --vcpus= - Number of vCPUs. + Number of vCPUs, within the range the plan allows (see quotas.limits.vcpus on the metro). Defaults to 1 on create. [examples: 1, 2, 4] --gpus= - Number of GPUs to attach. Requires type "full" and a plan with GPU support. Currently limited to 1. + Number of GPUs to attach, 0 or 1. Requires type full and a plan with GPU support. The instance holds a GPU only while it runs, and gets one of the same model on each start. Cannot be combined with template, branch or checkpoint. [examples: 0, 1] --service= - Service group name or key. + Service group to join, by name or UUID. Leave unset and give service.services or service.domains to create a group owned by the instance. Without any service the instance is reachable only privately. -p, --publish=:[/] ... - Publish port. - [example: 443:8080/http+tls] + Published port as SOURCE:DESTINATION[/HANDLERS], with handlers joined by +. Port 80 must use http, port 443 http+tls, and any other port tls only. + http: load balance per HTTP request instead of per TCP connection + tls: terminate TLS at the platform + redirect: redirect HTTP on port 80 to HTTPS, as in 80:443/http+redirect. + [examples: 443:8080/http+tls, 80:443/http+redirect] --domain= ... - Service domain. - [example: example.com] - -v, --volume=:[:] ... - Attach volume. - [examples: my-vol:/data, cache:/tmp:ro, data:/mnt:size=10GiB] + Domain for the group. + myapp: a bare label becomes a subdomain of the metro + example.com: a dotted name is a custom domain, with a certificate issued automatically unless given as fqdn=example.com,certificate=my-cert. + [examples: example.com, myapp] + -v, --volume=[]:[:] ... + Volume to attach as NAME:/MOUNT[:ro], where the mount path is absolute and outside /uk. On create, leave NAME empty and add :size=SIZE, as in :/data:size=1GiB, to make a new volume with a generated name. + [examples: my-vol:/data, cache:/tmp:ro, :/data:size=1GiB] --rom=name=,image=,at= ... - Attach ROM. + Read-only ROM device from an image or a local directory. The name uses letters, digits, - and _, and appears as /dev/ukp_rom_NAME in the guest. [examples: name=my-rom,image=myuser/my-rom:latest,at=/rom0, name=mydata,dir=./mydata,at=/rom] --plugin=name=,image=[,config=] ... - Load plugin into the instance. + Plugin loaded from an image, with an optional JSON config passed to it on start. Up to 8 per instance. [examples: name=sandbox,image=plugins/sandbox:latest, name=sandbox,image=plugins/sandbox:latest,config={"persist_path":"/data"}] --scale-to-zero== - Scale-to-zero options. - policy: on | idle | off - cooldown-time: cooldown in ms before scaling to zero - notify-time: notification time in ms before scaling to zero - stateful: true | false. + Scale-to-zero options. Traffic through a service or to the private IP wakes the instance. + policy: on | idle (also while open TCP connections are idle, needs stateful=true) | off + cooldown-time: ms of inactivity before scaling to zero + notify-time: ms the instance is warned beforehand, 0 disables and the value must be below cooldown-time + stateful: true keeps RAM in a snapshot and resumes from it, false restarts from scratch. [examples: on, policy=on,cooldown-time=300, policy=on,stateful=true,cooldown-time=500,notify-time=100] --autokill== Autokill options. - time: time after the instance stops before it is deleted - num-requests: max requests before the instance is deleted. + time: time after the instance stops before it is deleted, such as 5s or 24h, 0 disables + num-requests: delete the instance after it has served this many requests or connections. [examples: time=5s, num-requests=100, time=5s,num-requests=100] --restart= - Restart policy. + Restart policy. Restarts back off from immediate up to 5m. Cannot be combined with the delete-on-stop feature. + never: never restart (default) + always: restart whenever the instance exits or crashes + on-failure: restart only after a crash or a non-zero exit code. [examples: always, on-failure, never] --sched-priority= - Scheduling priority for the instance. + Scheduling priority: normal (default), medium, high or admin. Requires the override_vm_priority permission. [examples: normal, medium, high, admin] --autostart - Start instance automatically. + Start the instance right after creation. When false it stays stopped, except that scale-to-zero puts it in standby, where incoming traffic starts it. --replicas= - Number of replicas. + Additional identical instances to create, so replicas=2 creates three in total. [examples: 1, 3] --feature= ... - Instance feature. + Instance feature to enable. + delete-on-stop: delete the instance when it stops, not with a restart policy or delete-lock + nested-virt: expose virtualization extensions to the guest, needs the nested_virt permission. + [example: delete-on-stop] --template= - Create from instance template. + Create from an instance template, by name or UUID. The clone inherits its configuration and, when the template has a snapshot, its memory state. Cannot be combined with image, branch, checkpoint, type or gpus. --branch= - Branch from an existing instance. + Clone an existing instance, by name or UUID. A running source is snapshotted asynchronously and keeps running. Cannot be combined with image, args, env, memory, vcpus, type or gpus. --checkpoint= - Create from a checkpoint. + Create from a checkpoint, by name or UUID, restoring its memory and disk. Cannot be combined with image, args, env, memory, vcpus, type or gpus. --rm Automatically delete the instance when it stops. diff --git a/cmd/unikraft/testdata/TestHelp/services b/cmd/unikraft/testdata/TestHelp/services index efd8aae3..1741e598 100644 --- a/cmd/unikraft/testdata/TestHelp/services +++ b/cmd/unikraft/testdata/TestHelp/services @@ -297,26 +297,31 @@ Global flags: Create flags: --metro= - Metro to create in. + Metro to create in. Defaults to the profile's default metro. [examples: fra, sfo] --name= - Service group name. + Service group name. Generated when omitted. --soft-limit= - Soft limit. + Concurrent requests per instance before the load balancer wakes a standby instance of the group. Between 1 and 65535, defaults to 1 on create. [examples: 1, 5] --hard-limit= - Hard limit. + Maximum concurrent requests per instance, at least the soft limit. Between 1 and 65535, defaults to 65535 on create. Excess requests go to another instance or fail. [examples: 10, 100] --autokill== Autokill options. - time: time the group must stay empty before it is deleted. + time: time the group must stay without instances before it is deleted, such as 5m, 0 disables. [example: time=5m] --domain= ... - Service domain. - [example: example.com] + Domain for the group. + myapp: a bare label becomes a subdomain of the metro + example.com: a dotted name is a custom domain, with a certificate issued automatically unless given as fqdn=example.com,certificate=my-cert. + [examples: example.com, myapp] --service=:[/] ... - Service port. - [example: 443:8080/http+tls] + Published port as SOURCE:DESTINATION[/HANDLERS], with handlers joined by +. Port 80 must use http, port 443 http+tls, and any other port tls only. + http: load balance per HTTP request instead of per TCP connection + tls: terminate TLS at the platform + redirect: redirect HTTP on port 80 to HTTPS, as in 80:443/http+redirect. + [examples: 443:8080/http+tls, 80:443/http+redirect] $ unikraft service edit --help @@ -389,21 +394,26 @@ Global flags: Edit flags: --soft-limit= - Soft limit. + Concurrent requests per instance before the load balancer wakes a standby instance of the group. Between 1 and 65535, defaults to 1 on create. [examples: 1, 5] --hard-limit= - Hard limit. + Maximum concurrent requests per instance, at least the soft limit. Between 1 and 65535, defaults to 65535 on create. Excess requests go to another instance or fail. [examples: 10, 100] --autokill== Autokill options. - time: time the group must stay empty before it is deleted. + time: time the group must stay without instances before it is deleted, such as 5m, 0 disables. [example: time=5m] --domain= ... - Service domain. - [example: example.com] + Domain for the group. + myapp: a bare label becomes a subdomain of the metro + example.com: a dotted name is a custom domain, with a certificate issued automatically unless given as fqdn=example.com,certificate=my-cert. + [examples: example.com, myapp] --service=:[/] ... - Service port. - [example: 443:8080/http+tls] + Published port as SOURCE:DESTINATION[/HANDLERS], with handlers joined by +. Port 80 must use http, port 443 http+tls, and any other port tls only. + http: load balance per HTTP request instead of per TCP connection + tls: terminate TLS at the platform + redirect: redirect HTTP on port 80 to HTTPS, as in 80:443/http+redirect. + [examples: 443:8080/http+tls, 80:443/http+redirect] $ unikraft service delete --help diff --git a/cmd/unikraft/testdata/TestHelp/volumes b/cmd/unikraft/testdata/TestHelp/volumes index d0cee107..12876d8a 100644 --- a/cmd/unikraft/testdata/TestHelp/volumes +++ b/cmd/unikraft/testdata/TestHelp/volumes @@ -338,27 +338,32 @@ Global flags: Create flags: --metro= - Metro to create in. + Metro to create in. Defaults to the profile's default metro. [examples: fra, sfo] -n, --name= - Volume name. + Volume name, unique per metro among volumes and volume templates. Generated when omitted. --tag= ... - Volume tag. + Tags for grouping and filtering: up to 16, each 1 to 256 characters of letters, digits and -+_.:=. Not visible to the guest. [example: env-prod] --size= - Volume size. + Volume size, within the range the plan allows (see quotas.limits.volume on the metro). Only virtiofs volumes can be resized, and not below their current usage. [examples: 10GiB, 100MiB] --filesystem= - Volume filesystem. - [example: ext4] + Filesystem for a new volume: ext4 (default) or virtiofs, depending on what the deployment offers. + [examples: ext4, virtiofs] --quota-policy= - Volume quota policy. + How the volume counts against your quota. On edit the volume must not be mounted. + static: reserve the full size (default) + dynamic: count only used space and allow overcommit, not for ext4 volumes. [examples: static, dynamic] --access-mode= - Volume access mode. + How instances can mount the volume. + rwo: one read-write mount or many read-only mounts (default) + rox: read-only mounts only + rwx: many read-write mounts, virtiofs only, with guest caching off. [examples: rwo, rox, rwx] --template= - Create from volume template. + Clone a volume template, by name or UUID, instead of creating an empty volume. $ unikraft volume clone --help @@ -627,16 +632,18 @@ Global flags: Edit flags: --tag= ... - Volume tag. + Tags for grouping and filtering: up to 16, each 1 to 256 characters of letters, digits and -+_.:=. Not visible to the guest. [example: env-prod] --size= - Volume size. + Volume size, within the range the plan allows (see quotas.limits.volume on the metro). Only virtiofs volumes can be resized, and not below their current usage. [examples: 10GiB, 100MiB] --quota-policy= - Volume quota policy. + How the volume counts against your quota. On edit the volume must not be mounted. + static: reserve the full size (default) + dynamic: count only used space and allow overcommit, not for ext4 volumes. [examples: static, dynamic] --delete-lock - Prevent volume deletion until the lock is removed. + Prevent deletion until the lock is removed. $ unikraft volume delete --help @@ -882,7 +889,7 @@ Usage: Arguments: [] - Volume to convert into a template. + Volume to turn into a template, by name or UUID. Examples: # Convert a volume into a template @@ -1005,10 +1012,10 @@ Global flags: Edit flags: --tag= ... - Template tag. + Tags for grouping and filtering: up to 16, each 1 to 256 characters of letters, digits and -+_.:=. Not visible to the guest. [example: env-dev] --delete-lock - Prevent deletion of the template. + Prevent deletion until the lock is removed. $ unikraft volume template delete --help diff --git a/internal/cmd/certificates.go b/internal/cmd/certificates.go index 27e92876..bb077b2e 100644 --- a/internal/cmd/certificates.go +++ b/internal/cmd/certificates.go @@ -35,26 +35,26 @@ type CertificatesCmd struct { } type Certificate struct { - Metro LinkName[Metro] `field:"metro,short" create:"set,required" flag:"metro" help:"Metro to create in." placeholder:"metro" example:"fra,sfo"` - Name string `mirror:"certificate.name" field:",short" create:"set" flag:"name" help:"Certificate name." placeholder:"name"` - UUID string `mirror:"certificate.uuid" field:",long"` + Metro LinkName[Metro] `field:"metro,short" create:"set,required" flag:"metro" help:"Metro to create in. Defaults to the profile's default metro." placeholder:"metro" example:"fra,sfo"` + Name string `mirror:"certificate.name" field:",short" create:"set" flag:"name" help:"Certificate name. Generated when omitted." placeholder:"name"` + UUID string `mirror:"certificate.uuid" field:",long" help:"Unique identifier assigned at creation."` - CommonName string `mirror:"certificate.common_name" field:",short"` - Subject string `mirror:"certificate.subject" field:",long"` - Issuer string `mirror:"certificate.issuer" field:",long"` - SerialNumber string `mirror:"certificate.serial_number" field:",long"` + CommonName string `mirror:"certificate.common_name" field:",short" help:"Common name: the fully qualified domain name the certificate is for."` + Subject string `mirror:"certificate.subject" field:",long" help:"Subject distinguished name, present once issued."` + Issuer string `mirror:"certificate.issuer" field:",long" help:"Issuing certificate authority."` + SerialNumber string `mirror:"certificate.serial_number" field:",long" help:"Serial number assigned by the issuer."` - State types.CertificateState `mirror:"certificate.state" field:",short"` + State types.CertificateState `mirror:"certificate.state" field:",short" help:"Lifecycle state: pending (being issued or validated), valid or error (issuing failed after several attempts)."` - CN string `field:"cn,invisible,valueless" create:"set,required" flag:"common-name" aliases:"cn" help:"Certificate common name." placeholder:"fqdn" example:"demo.unikraft.dev."` - Chain string `field:"chain,invisible,valueless" create:"set,required" flag-file:"chain" help:"Certificate chain file." placeholder:"file"` - Pkey string `field:"pkey,invisible,valueless" create:"set,required" flag-file:"private-key" aliases:"pkey" help:"Certificate private key file." placeholder:"file"` + CN string `field:"cn,invisible,valueless" create:"set,required" flag:"common-name" aliases:"cn" help:"Common name: the fully qualified domain name the certificate is for, with a trailing dot." placeholder:"fqdn" example:"demo.unikraft.dev."` + Chain string `field:"chain,invisible,valueless" create:"set,required" flag-file:"chain" help:"PEM certificate chain: the certificate followed by any intermediates." placeholder:"file"` + Pkey string `field:"pkey,invisible,valueless" create:"set,required" flag-file:"private-key" aliases:"pkey" help:"PEM private key matching the certificate chain." placeholder:"file"` Timestamps struct { - Created types.RelativeTime `mirror:"certificate.created_at" field:",short"` - NotBefore types.RelativeTime `mirror:"certificate.not_before" field:",long"` - NotAfter types.RelativeTime `mirror:"certificate.not_after" field:",short"` - } + Created types.RelativeTime `mirror:"certificate.created_at" field:",short" help:"Creation time."` + NotBefore types.RelativeTime `mirror:"certificate.not_before" field:",long" help:"Start of the validity period."` + NotAfter types.RelativeTime `mirror:"certificate.not_after" field:",short" help:"Expiry. Platform-issued certificates renew automatically, uploaded ones must be replaced before this time."` + } `help:"Lifecycle and validity timestamps."` Certificate platform.Certificate `field:"-" json:"certificate"` diff --git a/internal/cmd/images.go b/internal/cmd/images.go index 3bd9a5df..dfa3a85e 100644 --- a/internal/cmd/images.go +++ b/internal/cmd/images.go @@ -46,39 +46,39 @@ type ImagesCmd struct { } type Image struct { - Ref types.ImageRef `field:",short"` - Digest digest.Digest `field:",long"` + Ref types.ImageRef `field:",short" help:"Image reference, such as user/app:tag."` + Digest digest.Digest `field:",long" help:"Content digest of the image."` - Config ImageConfig `field:",embed"` - Metadata ImageMetadata `field:",long,embed"` + Config ImageConfig `field:",embed" help:"Runtime configuration of the image."` + Metadata ImageMetadata `field:",long,embed" help:"Image metadata."` - Kernel *ImageFile `field:",long,embed"` - KernelDebug *ImageFile `field:"kernel-dbg,long,embed"` - Initrd *ImageFile `field:",long,embed"` - Roms []ImageFile `field:",long,embed"` + Kernel *ImageFile `field:",long,embed" help:"Kernel of the image."` + KernelDebug *ImageFile `field:"kernel-dbg,long,embed" help:"Kernel of the image with debug symbols."` + Initrd *ImageFile `field:",long,embed" help:"Initial ramdisk of the image."` + Roms []ImageFile `field:",long,embed" help:"ROMs packaged with the image."` Image imagespec.Image `field:"-" json:"image"` } type ImageConfig struct { - Platform types.Platform `field:",short"` + Platform types.Platform `field:",short" help:"Platform the image is built for."` - Cmd []string `field:",long"` - Env map[string]string `field:",long"` + Cmd []string `field:",long" help:"Default command-line arguments of the image."` + Env map[string]string `field:",long" help:"Environment variables set by the image."` } type imageNamespacesKey struct{} type ImageMetadata struct { - Author string `field:",long"` - Created *time.Time `field:",long"` + Author string `field:",long" help:"Author of the image."` + Created *time.Time `field:",long" help:"Creation time."` } type ImageFile struct { - Digest digest.Digest `field:",long"` - MediaType string `field:",long"` - Annotations map[string]string `field:",long"` - Size types.SizeBytes `field:",long"` + Digest digest.Digest `field:",long" help:"Digest of the image layer holding the file."` + MediaType string `field:",long" help:"Media type of the image layer holding the file."` + Annotations map[string]string `field:",long" help:"Annotations of the image layer holding the file."` + Size types.SizeBytes `field:",long" help:"Size of the image layer holding the file."` } func (Image) Type() resource.Type { @@ -253,10 +253,10 @@ func (Image) Examples() map[cmd.CmdType][]kingkong.Example { } type ImageEntry struct { - Ref types.ImageRef `field:",short"` - Digest digest.Digest `field:",short"` + Ref types.ImageRef `field:",short" help:"Image reference, such as user/app:tag."` + Digest digest.Digest `field:",short" help:"Content digest of the image."` - Namespace string + Namespace string `help:"First path segment of the reference, usually the organization."` Canonical reference.Reference `field:"-"` diff --git a/internal/cmd/instance_checkpoints.go b/internal/cmd/instance_checkpoints.go index daba007c..0dfb35e3 100644 --- a/internal/cmd/instance_checkpoints.go +++ b/internal/cmd/instance_checkpoints.go @@ -40,53 +40,53 @@ type InstanceCheckpointsCmd struct { } type InstanceCheckpoint struct { - MetroName LinkName[Metro] `mirror:"metro.name" field:"metro,short"` - Name string `mirror:"instance.name" field:",short"` - UUID string `mirror:"instance.uuid" field:",long"` + MetroName LinkName[Metro] `mirror:"metro.name" field:"metro,short" help:"Metro the checkpoint is in."` + Name string `mirror:"instance.name" field:",short" help:"Checkpoint name, unique per metro."` + UUID string `mirror:"instance.uuid" field:",long" help:"Unique identifier assigned at creation."` - Tags []string `mirror:"instance.tags" field:",long" edit:"set,add,del" flag:"tag" sep:"none" help:"Checkpoint tag." placeholder:"tag" example:"env-dev"` - Annotations map[string]string `mirror:"instance.annotations" field:",long"` - DeleteLock bool `mirror:"instance.delete_lock" field:"delete-lock,long" edit:"set" flag:"delete-lock" help:"Prevent deletion of the checkpoint."` + Tags []string `mirror:"instance.tags" field:",long" edit:"set,add,del" flag:"tag" sep:"none" help:"Tags for grouping and filtering: up to 16, each 1 to 256 characters of letters, digits and -+_.:=. Not visible to the guest." placeholder:"tag" example:"env-dev"` + Annotations map[string]string `mirror:"instance.annotations" field:",long" help:"Key-value metadata: up to 256 entries with Kubernetes-style [prefix/]name keys. Unlike tags, annotations reach the guest in its start data."` + DeleteLock bool `mirror:"instance.delete_lock" field:"delete-lock,long" edit:"set" flag:"delete-lock" help:"Prevent deletion until the lock is removed. Also pauses autokill."` - Autokill Autokill `field:",embed" mirror:"instance.checkpoint_autokill" create:"set" edit:"set" flag:"autokill" help:"Autokill options.\n time: time without a restore before the checkpoint is deleted" placeholder:"=" example:"time=24h"` + Autokill Autokill `field:",embed" mirror:"instance.checkpoint_autokill" create:"set" edit:"set" flag:"autokill" help:"Autokill options.\n time: time since the checkpoint was last restored before it is deleted, such as 24h, 0 disables" placeholder:"=" example:"time=24h"` - State types.InstanceState `mirror:"instance.state" field:",short"` - Image types.ImageRef `mirror:"instance.image" field:",short"` - Type_ *platform.InstanceType `mirror:"instance.type" field:"type,long"` + State types.InstanceState `mirror:"instance.state" field:",short" help:"Lifecycle state: stopped, starting, running, draining (finishing requests before a stop), stopping, standby (scaled to zero, woken by traffic), template, checkpoint or deleted."` + Image types.ImageRef `mirror:"instance.image" field:",short" help:"Image of the checkpoint, as an OCI reference or a full registry URL."` + Type_ *platform.InstanceType `mirror:"instance.type" field:"type,long" help:"Virtual machine type.\n micro: Firecracker microVM (default)\n full: QEMU virtual machine with GPU support, needs a plan with full VM support and has no branching, checkpoints, vertical scaling, annotations or virtiofs volumes"` Runtime struct { - Args InstanceArgs `mirror:"instance.args" field:",short"` - Env map[string]string `mirror:"instance.env" field:",long"` - } + Args InstanceArgs `mirror:"instance.args" field:",short" help:"Command-line arguments passed to the instance at start."` + Env map[string]string `mirror:"instance.env" field:",long" help:"Environment variable as KEY=value."` + } `help:"Runtime configuration passed to the instance."` Resources struct { - Memory types.SizeMebibytes `mirror:"instance.memory_mb" field:",short"` - VCPUs int `mirror:"instance.vcpus" field:"vcpus,short"` - } + Memory types.SizeMebibytes `mirror:"instance.memory_mb" field:",short" help:"Memory for the instance."` + VCPUs int `mirror:"instance.vcpus" field:"vcpus,short" help:"Number of vCPUs."` + } `help:"Compute resources of the instance."` - Volumes []InstanceTemplateVolume `mirror:"instance.volumes" field:",embed"` + Volumes []InstanceTemplateVolume `mirror:"instance.volumes" field:",embed" help:"Volumes attached to the instance."` Snapshot struct { - UUID string `mirror:"instance.snapshot.uuid" field:",long"` - } + UUID string `mirror:"instance.snapshot.uuid" field:",long" help:"Unique identifier of the snapshot."` + } `help:"Snapshot holding the memory state."` Timestamps struct { - Created types.RelativeTime `mirror:"instance.created_at" field:",short"` - } + Created types.RelativeTime `mirror:"instance.created_at" field:",short" help:"Creation time."` + } `help:"Lifecycle timestamps."` Timing struct { - BootTime *types.DurationUS `mirror:"instance.boot_time_us" field:",long"` - NetTime *types.DurationUS `mirror:"instance.net_time_us"` - CheckpointTime *types.DurationUS `mirror:"instance.checkpoint_time_us" field:",long"` - } + BootTime *types.DurationUS `mirror:"instance.boot_time_us" field:",long" help:"Time the guest took to boot on the last start."` + NetTime *types.DurationUS `mirror:"instance.net_time_us" help:"Time from start until the application listened on a non-localhost port."` + CheckpointTime *types.DurationUS `mirror:"instance.checkpoint_time_us" field:",long" help:"Time the instance took to reach the checkpoint state after the checkpoint was requested."` + } `help:"Start-up and checkpoint time measurements."` - ScaleToZero InstanceScaleToZero `field:",embed" mirror:"instance.scale_to_zero"` + ScaleToZero InstanceScaleToZero `field:",embed" mirror:"instance.scale_to_zero" help:"Scale-to-zero options. Traffic through a service or to the private IP wakes the instance.\n policy: on | idle (also while open TCP connections are idle, needs stateful=true) | off\n cooldown-time: ms of inactivity before scaling to zero\n notify-time: ms the instance is warned beforehand, 0 disables and the value must be below cooldown-time\n stateful: true keeps RAM in a snapshot and resumes from it, false restarts from scratch"` Restart struct { - Policy string `mirror:"instance.restart_policy"` - } + Policy string `mirror:"instance.restart_policy" help:"Restart policy. Restarts back off from immediate up to 5m. Cannot be combined with the delete-on-stop feature.\n never: never restart (default)\n always: restart whenever the instance exits or crashes\n on-failure: restart only after a crash or a non-zero exit code"` + } `help:"Restart policy."` - InstanceRef string `field:"instance,invisible,valueless" create:"set,required" flag-arg:"instance" completion-predictor:"resource-key-instance" help:"Instance to create a checkpoint from."` + InstanceRef string `field:"instance,invisible,valueless" create:"set,required" flag-arg:"instance" completion-predictor:"resource-key-instance" help:"Running or stopped instance to checkpoint, by name or UUID. The source keeps running and the checkpoint waits for its snapshot."` Instance platform.Instance `field:"-" json:"instance"` Metro *config.Metro `field:"-" json:"metro"` @@ -480,11 +480,11 @@ func (InstanceCheckpoint) Examples() map[cmd.CmdType][]kingkong.Example { // instance or checkpoint. It models one row of history output as a resource so // that it can be rendered through the standard printer framework. type InstanceHistoryEntry struct { - Metro LinkName[Metro] `field:"metro,short" json:"metro"` - Target string `field:",short" json:"target"` - Name string `field:",short" json:"name"` - UUID string `field:",long" json:"uuid"` - Created types.RelativeTime `field:"created,short" json:"created"` + Metro LinkName[Metro] `field:"metro,short" json:"metro" help:"Metro the checkpoint is in."` + Target string `field:",short" json:"target" help:"Instance or checkpoint the history was requested for."` + Name string `field:",short" json:"name" help:"Checkpoint name."` + UUID string `field:",long" json:"uuid" help:"Unique identifier assigned at creation."` + Created types.RelativeTime `field:"created,short" json:"created" help:"Creation time."` key multimetro.Key } diff --git a/internal/cmd/instance_templates.go b/internal/cmd/instance_templates.go index 93158968..4db5a1c5 100644 --- a/internal/cmd/instance_templates.go +++ b/internal/cmd/instance_templates.go @@ -37,53 +37,53 @@ type InstanceTemplatesCmd struct { } type InstanceTemplate struct { - Metro LinkName[Metro] `field:"metro,short"` - Name string `mirror:"instance.name" field:",short"` - UUID string `mirror:"instance.uuid" field:",long"` + Metro LinkName[Metro] `field:"metro,short" help:"Metro the template is in."` + Name string `mirror:"instance.name" field:",short" help:"Template name, unique per metro."` + UUID string `mirror:"instance.uuid" field:",long" help:"Unique identifier assigned at creation."` - Tags []string `mirror:"instance.tags" field:",long" edit:"set,add,del" flag:"tag" sep:"none" help:"Template tag." placeholder:"tag" example:"env-dev"` - Annotations map[string]string `mirror:"instance.annotations" field:",long"` - DeleteLock bool `mirror:"instance.delete_lock" field:"delete-lock,long" edit:"set" flag:"delete-lock" help:"Prevent deletion of the template."` + Tags []string `mirror:"instance.tags" field:",long" edit:"set,add,del" flag:"tag" sep:"none" help:"Tags for grouping and filtering: up to 16, each 1 to 256 characters of letters, digits and -+_.:=. Not visible to the guest." placeholder:"tag" example:"env-dev"` + Annotations map[string]string `mirror:"instance.annotations" field:",long" help:"Key-value metadata: up to 256 entries with Kubernetes-style [prefix/]name keys. Unlike tags, annotations reach the guest in its start data."` + DeleteLock bool `mirror:"instance.delete_lock" field:"delete-lock,long" edit:"set" flag:"delete-lock" help:"Prevent deletion until the lock is removed. Also pauses autokill."` - Autokill Autokill `field:",embed" mirror:"instance.template_autokill" create:"set" edit:"set" flag:"autokill" help:"Autokill options.\n time: time without a clone before the template is deleted" placeholder:"=" example:"time=24h"` + Autokill Autokill `field:",embed" mirror:"instance.template_autokill" create:"set" edit:"set" flag:"autokill" help:"Autokill options.\n time: time since the template was last cloned before it is deleted, such as 24h, 0 disables" placeholder:"=" example:"time=24h"` - State types.InstanceState `mirror:"instance.state" field:",short"` - Image types.ImageRef `mirror:"instance.image" field:",short"` - Type_ *platform.InstanceType `mirror:"instance.type" field:"type,long"` + State types.InstanceState `mirror:"instance.state" field:",short" help:"Lifecycle state: stopped, starting, running, draining (finishing requests before a stop), stopping, standby (scaled to zero, woken by traffic), template, checkpoint or deleted."` + Image types.ImageRef `mirror:"instance.image" field:",short" help:"Image of the template, as an OCI reference or a full registry URL."` + Type_ *platform.InstanceType `mirror:"instance.type" field:"type,long" help:"Virtual machine type.\n micro: Firecracker microVM (default)\n full: QEMU virtual machine with GPU support, needs a plan with full VM support and has no branching, checkpoints, vertical scaling, annotations or virtiofs volumes"` Runtime struct { - Args InstanceArgs `mirror:"instance.args" field:",short"` - Env map[string]string `mirror:"instance.env" field:",long"` - } + Args InstanceArgs `mirror:"instance.args" field:",short" help:"Command-line arguments passed to the instance at start."` + Env map[string]string `mirror:"instance.env" field:",long" help:"Environment variable as KEY=value."` + } `help:"Runtime configuration passed to the instance."` Resources struct { - Memory types.SizeMebibytes `mirror:"instance.memory_mb" field:",short"` - VCPUs int `mirror:"instance.vcpus" field:"vcpus,short"` - } + Memory types.SizeMebibytes `mirror:"instance.memory_mb" field:",short" help:"Memory for the instance."` + VCPUs int `mirror:"instance.vcpus" field:"vcpus,short" help:"Number of vCPUs."` + } `help:"Compute resources of the instance."` - Volumes []InstanceTemplateVolume `mirror:"instance.volumes" field:",embed"` + Volumes []InstanceTemplateVolume `mirror:"instance.volumes" field:",embed" help:"Volumes attached to the instance."` Snapshot struct { - UUID string `mirror:"instance.snapshot.uuid" field:",long"` - } + UUID string `mirror:"instance.snapshot.uuid" field:",long" help:"Unique identifier of the snapshot."` + } `help:"Snapshot holding the memory state."` Timestamps struct { - Created types.RelativeTime `mirror:"instance.created_at" field:",short"` - } + Created types.RelativeTime `mirror:"instance.created_at" field:",short" help:"Creation time."` + } `help:"Lifecycle timestamps."` Timing struct { - BootTime *types.DurationUS `mirror:"instance.boot_time_us" field:",long"` - NetTime *types.DurationUS `mirror:"instance.net_time_us"` - TemplateTime *types.DurationUS `mirror:"instance.template_time_us" field:",long"` - } + BootTime *types.DurationUS `mirror:"instance.boot_time_us" field:",long" help:"Time the guest took to boot on the last start."` + NetTime *types.DurationUS `mirror:"instance.net_time_us" help:"Time from start until the application listened on a non-localhost port."` + TemplateTime *types.DurationUS `mirror:"instance.template_time_us" field:",long" help:"Time the instance took to reach the template state after the template was requested."` + } `help:"Start-up and template time measurements."` - ScaleToZero InstanceScaleToZero `field:",embed" mirror:"instance.scale_to_zero"` + ScaleToZero InstanceScaleToZero `field:",embed" mirror:"instance.scale_to_zero" help:"Scale-to-zero options. Traffic through a service or to the private IP wakes the instance.\n policy: on | idle (also while open TCP connections are idle, needs stateful=true) | off\n cooldown-time: ms of inactivity before scaling to zero\n notify-time: ms the instance is warned beforehand, 0 disables and the value must be below cooldown-time\n stateful: true keeps RAM in a snapshot and resumes from it, false restarts from scratch"` Restart struct { - Policy string `mirror:"instance.restart_policy"` - } + Policy string `mirror:"instance.restart_policy" help:"Restart policy. Restarts back off from immediate up to 5m. Cannot be combined with the delete-on-stop feature.\n never: never restart (default)\n always: restart whenever the instance exits or crashes\n on-failure: restart only after a crash or a non-zero exit code"` + } `help:"Restart policy."` - InstanceRef string `field:"instance,invisible,valueless" create:"set,required" flag-arg:"instance" completion-predictor:"resource-key-instance" help:"Instance to convert into a template."` + InstanceRef string `field:"instance,invisible,valueless" create:"set,required" flag-arg:"instance" completion-predictor:"resource-key-instance" help:"Instance to turn into a template, by name or UUID. A running instance is suspended into a snapshot first and becomes the template itself."` Instance platform.Instance `field:"-" json:"instance"` Profile *config.Profile `field:"-" json:"profile"` diff --git a/internal/cmd/instances.go b/internal/cmd/instances.go index bd85d931..4b7e5a43 100644 --- a/internal/cmd/instances.go +++ b/internal/cmd/instances.go @@ -101,111 +101,111 @@ func (c *InstanceCreateCmd) RunResources(ctx context.Context, stdio config.Stdio } type Instance struct { - Metro LinkName[Metro] `field:"metro,short" create:"set,required" flag:"metro" help:"Metro to deploy in." placeholder:"metro" example:"fra,sfo"` - Name string `mirror:"instance.name" field:",short" create:"set" flag:"name" short:"n" help:"Instance name." placeholder:"name"` - UUID string `mirror:"instance.uuid" field:",long"` + Metro LinkName[Metro] `field:"metro,short" create:"set,required" flag:"metro" help:"Metro to deploy in. Defaults to the profile's default metro." placeholder:"metro" example:"fra,sfo"` + Name string `mirror:"instance.name" field:",short" create:"set" flag:"name" short:"n" help:"Instance name, unique per metro. Generated when omitted." placeholder:"name"` + UUID string `mirror:"instance.uuid" field:",long" help:"Unique identifier assigned at creation."` - Tags []string `mirror:"instance.tags" field:",long" create:"set" edit:"set,add,del" flag:"tag" sep:"none" help:"Instance tag." placeholder:"tag" example:"env-prod"` - Annotations map[string]string `mirror:"instance.annotations" field:",long" create:"set" edit:"set,add,del=keys" flag:"annotation" sep:"none" mapsep:"none" help:"Instance annotation." placeholder:"=" example:"env=production,example.com/team=platform"` + Tags []string `mirror:"instance.tags" field:",long" create:"set" edit:"set,add,del" flag:"tag" sep:"none" help:"Tags for grouping and filtering: up to 16, each 1 to 256 characters of letters, digits and -+_.:=. Not visible to the guest." placeholder:"tag" example:"env-prod"` + Annotations map[string]string `mirror:"instance.annotations" field:",long" create:"set" edit:"set,add,del=keys" flag:"annotation" sep:"none" mapsep:"none" help:"Key-value metadata: up to 256 entries with Kubernetes-style [prefix/]name keys. Unlike tags, annotations reach the guest in its start data." placeholder:"=" example:"env=production,example.com/team=platform"` - State types.InstanceState `mirror:"instance.state" field:",short" edit:"set"` + State types.InstanceState `mirror:"instance.state" field:",short" edit:"set" help:"Lifecycle state: stopped, starting, running, draining (finishing requests before a stop), stopping, standby (scaled to zero, woken by traffic), template, checkpoint or deleted. Setting stopped on edit stops a running instance." example:"stopped"` - Image types.ImageRef `mirror:"instance.image" field:",short" create:"set" edit:"set" flag:"image" help:"Image to deploy." placeholder:":" example:"nginx:latest,my-app:v1.2.3"` - PullPolicy *platform.PullPolicy `field:"pull-policy,invisible,valueless" create:"set" flag:"pull-policy" help:"Image pull policy." placeholder:"policy" example:"always,never,if_not_present"` - Type_ *platform.InstanceType `mirror:"instance.type" field:"type,long" create:"set" flag:"type" help:"Type of virtual machine to run. \"full\" requires a plan with full VM support." placeholder:"type" example:"micro,full"` + Image types.ImageRef `mirror:"instance.image" field:",short" create:"set" edit:"set" flag:"image" help:"Image to deploy, as an OCI reference or a full registry URL. On create, required unless template, branch or checkpoint is given, and cannot be combined with them." placeholder:":" example:"nginx:latest,my-app:v1.2.3"` + PullPolicy *platform.PullPolicy `field:"pull-policy,invisible,valueless" create:"set" flag:"pull-policy" help:"When to pull the image relative to the node's cache: always, if_not_present or never." placeholder:"policy" example:"always,if_not_present,never"` + Type_ *platform.InstanceType `mirror:"instance.type" field:"type,long" create:"set" flag:"type" help:"Virtual machine type.\n micro: Firecracker microVM (default)\n full: QEMU virtual machine with GPU support, needs a plan with full VM support and has no branching, checkpoints, vertical scaling, annotations or virtiofs volumes" placeholder:"type" example:"micro,full"` Runtime struct { - Args InstanceArgs `mirror:"instance.args" field:",short" create:"set" edit:"set" flag:"args" help:"Arguments to pass to the instance." placeholder:"arg"` - Env map[string]string `mirror:"instance.env" field:",long" create:"set" edit:"set,add,del=keys" flag:"env" short:"e" sep:"none" mapsep:"none" help:"Environment variable." placeholder:"=" example:"DEBUG=true"` - } + Args InstanceArgs `mirror:"instance.args" field:",short" create:"set" edit:"set" flag:"args" help:"Command-line arguments passed to the instance at start." placeholder:"arg" example:"--port=8080"` + Env map[string]string `mirror:"instance.env" field:",long" create:"set" edit:"set,add,del=keys" flag:"env" short:"e" sep:"none" mapsep:"none" help:"Environment variable as KEY=value." placeholder:"=" example:"DEBUG=true"` + } `help:"Runtime configuration passed to the instance."` Resources struct { - Memory types.SizeMebibytes `mirror:"instance.memory_mb" field:",short" create:"set" edit:"set" flag:"memory" short:"m" help:"Memory allocation." placeholder:"size" example:"128MiB,1GiB"` - VCPUs int `mirror:"instance.vcpus" field:"vcpus,short" create:"set" edit:"set" flag:"vcpus" help:"Number of vCPUs." placeholder:"n" example:"1,2,4"` - GPUs int `field:"gpus,long" create:"set" flag:"gpus" help:"Number of GPUs to attach. Requires type \"full\" and a plan with GPU support. Currently limited to 1." placeholder:"n" example:"0,1"` - } + Memory types.SizeMebibytes `mirror:"instance.memory_mb" field:",short" create:"set" edit:"set" flag:"memory" short:"m" help:"Memory for the instance, within the range the plan allows (see quotas.limits.memory on the metro). Defaults to 128MiB on create." placeholder:"size" example:"128MiB,1GiB"` + VCPUs int `mirror:"instance.vcpus" field:"vcpus,short" create:"set" edit:"set" flag:"vcpus" help:"Number of vCPUs, within the range the plan allows (see quotas.limits.vcpus on the metro). Defaults to 1 on create." placeholder:"n" example:"1,2,4"` + GPUs int `field:"gpus,long" create:"set" flag:"gpus" help:"Number of GPUs to attach, 0 or 1. Requires type full and a plan with GPU support. The instance holds a GPU only while it runs, and gets one of the same model on each start. Cannot be combined with template, branch or checkpoint." placeholder:"n" example:"0,1"` + } `help:"Compute resources of the instance."` - Service *InstanceService `mirror:"instance.service_group" field:",embed" create:"set" flag:"service" help:"Service group name or key." placeholder:"name"` - Volumes []*InstanceVolume `mirror:"instance.volumes" field:",embed" create:"set" edit:"add,del=strings" flag:"volume" short:"v" sep:"none" help:"Attach volume." placeholder:":[:]" example:"my-vol:/data,cache:/tmp:ro,data:/mnt:size=10GiB"` - Roms []*InstanceRom `mirror:"instance.roms" field:",embed" create:"set" edit:"set,add,del=strings" flag:"rom" sep:"none" help:"Attach ROM." placeholder:"name=,image=,at=" example:"name=my-rom\\,image=myuser/my-rom:latest\\,at=/rom0,name=mydata\\,dir=./mydata\\,at=/rom"` - Plugins []*InstancePlugin `mirror:"instance.plugins" field:",embed" create:"set" edit:"set,add,del=strings" flag:"plugin" sep:"none" help:"Load plugin into the instance." placeholder:"name=,image=[,config=]" example:"name=sandbox\\,image=plugins/sandbox:latest,name=sandbox\\,image=plugins/sandbox:latest\\,config={\"persist_path\":\"/data\"}"` + Service *InstanceService `mirror:"instance.service_group" field:",embed" create:"set" flag:"service" help:"Service group to join, by name or UUID. Leave unset and give service.services or service.domains to create a group owned by the instance. Without any service the instance is reachable only privately." placeholder:"name"` + Volumes []*InstanceVolume `mirror:"instance.volumes" field:",embed" create:"set" edit:"add,del=strings" flag:"volume" short:"v" sep:"none" help:"Volume to attach as NAME:/MOUNT[:ro], where the mount path is absolute and outside /uk. On create, leave NAME empty and add :size=SIZE, as in :/data:size=1GiB, to make a new volume with a generated name." placeholder:"[]:[:]" example:"my-vol:/data,cache:/tmp:ro,:/data:size=1GiB"` + Roms []*InstanceRom `mirror:"instance.roms" field:",embed" create:"set" edit:"set,add,del=strings" flag:"rom" sep:"none" help:"Read-only ROM device from an image or a local directory. The name uses letters, digits, - and _, and appears as /dev/ukp_rom_NAME in the guest." placeholder:"name=,image=,at=" example:"name=my-rom\\,image=myuser/my-rom:latest\\,at=/rom0,name=mydata\\,dir=./mydata\\,at=/rom"` + Plugins []*InstancePlugin `mirror:"instance.plugins" field:",embed" create:"set" edit:"set,add,del=strings" flag:"plugin" sep:"none" help:"Plugin loaded from an image, with an optional JSON config passed to it on start. Up to 8 per instance." placeholder:"name=,image=[,config=]" example:"name=sandbox\\,image=plugins/sandbox:latest,name=sandbox\\,image=plugins/sandbox:latest\\,config={\"persist_path\":\"/data\"}"` - PrivateFQDN string `mirror:"instance.private_fqdn" field:"private-fqdn,long"` - Networks []InstanceNetwork `mirror:"instance.network_interfaces" field:",embed"` - Gpus []InstanceGpu `mirror:"instance.gpus" field:"gpus,embed"` + PrivateFQDN string `mirror:"instance.private_fqdn" field:"private-fqdn,long" help:"Private hostname, reachable only from your other instances in the metro."` + Networks []InstanceNetwork `mirror:"instance.network_interfaces" field:",embed" help:"Network interfaces of the instance."` + Gpus []InstanceGpu `mirror:"instance.gpus" field:"gpus,embed" help:"GPUs attached to the instance, only for type full."` Timestamps struct { - Created types.RelativeTime `mirror:"instance.created_at" field:",short"` - Started types.RelativeTime `mirror:"instance.started_at"` - Stopped types.RelativeTime `mirror:"instance.stopped_at"` - } + Created types.RelativeTime `mirror:"instance.created_at" field:",short" help:"Creation time."` + Started types.RelativeTime `mirror:"instance.started_at" help:"When the instance last started."` + Stopped types.RelativeTime `mirror:"instance.stopped_at" help:"When the instance last stopped. Empty while it is running."` + } `help:"Lifecycle timestamps."` - ScaleToZero InstanceScaleToZero `field:",embed" mirror:"instance.scale_to_zero" create:"set" edit:"set" flag:"scale-to-zero" help:"Scale-to-zero options.\n policy: on | idle | off\n cooldown-time: cooldown in ms before scaling to zero\n notify-time: notification time in ms before scaling to zero\n stateful: true | false" placeholder:"=" example:"on,policy=on\\,cooldown-time=300,policy=on\\,stateful=true\\,cooldown-time=500\\,notify-time=100"` - Autokill InstanceAutokill `field:",embed" mirror:"instance.autokill" create:"set" edit:"set" flag:"autokill" help:"Autokill options.\n time: time after the instance stops before it is deleted\n num-requests: max requests before the instance is deleted" placeholder:"=" example:"time=5s,num-requests=100,time=5s\\,num-requests=100"` + ScaleToZero InstanceScaleToZero `field:",embed" mirror:"instance.scale_to_zero" create:"set" edit:"set" flag:"scale-to-zero" help:"Scale-to-zero options. Traffic through a service or to the private IP wakes the instance.\n policy: on | idle (also while open TCP connections are idle, needs stateful=true) | off\n cooldown-time: ms of inactivity before scaling to zero\n notify-time: ms the instance is warned beforehand, 0 disables and the value must be below cooldown-time\n stateful: true keeps RAM in a snapshot and resumes from it, false restarts from scratch" placeholder:"=" example:"on,policy=on\\,cooldown-time=300,policy=on\\,stateful=true\\,cooldown-time=500\\,notify-time=100"` + Autokill InstanceAutokill `field:",embed" mirror:"instance.autokill" create:"set" edit:"set" flag:"autokill" help:"Autokill options.\n time: time after the instance stops before it is deleted, such as 5s or 24h, 0 disables\n num-requests: delete the instance after it has served this many requests or connections" placeholder:"=" example:"time=5s,num-requests=100,time=5s\\,num-requests=100"` Timing struct { - Uptime *types.DurationMS `mirror:"instance.uptime_ms"` + Uptime *types.DurationMS `mirror:"instance.uptime_ms" help:"Total time the instance has been running."` - StartPrereqTime *types.DurationUS `mirror:"instance.start_prereq_time_us" field:"start-prereq-time,long"` - VmmPrestartTime *types.DurationUS `mirror:"instance.vmm_prestart_time_us" field:"vmm-prestart-time,long"` - VmmStartTime *types.DurationUS `mirror:"instance.vmm_start_time_us" field:"vmm-start-time,long"` - VmmLoadTime *types.DurationUS `mirror:"instance.vmm_load_time_us" field:"vmm-load-time,long"` - VmmReadyTime *types.DurationUS `mirror:"instance.vmm_ready_time_us" field:"vmm-ready-time,long"` - BootTime *types.DurationUS `mirror:"instance.boot_time_us" field:",long"` - NetTime *types.DurationUS `mirror:"instance.net_time_us"` - } + StartPrereqTime *types.DurationUS `mirror:"instance.start_prereq_time_us" field:"start-prereq-time,long" help:"Time from the start request until the kernel, image, volumes and template the VM needs were ready."` + VmmPrestartTime *types.DurationUS `mirror:"instance.vmm_prestart_time_us" field:"vmm-prestart-time,long" help:"Time from the start request until the VMM process was spawned. For debugging."` + VmmStartTime *types.DurationUS `mirror:"instance.vmm_start_time_us" field:"vmm-start-time,long" help:"Time from the controller handing over until the VMM began executing. For debugging."` + VmmLoadTime *types.DurationUS `mirror:"instance.vmm_load_time_us" field:"vmm-load-time,long" help:"Time the VMM took to load the kernel and initramfs into memory. For debugging."` + VmmReadyTime *types.DurationUS `mirror:"instance.vmm_ready_time_us" field:"vmm-ready-time,long" help:"Time from the VMM starting until it was ready to run the instance. For debugging."` + BootTime *types.DurationUS `mirror:"instance.boot_time_us" field:",long" help:"Time the guest took to boot on the last start."` + NetTime *types.DurationUS `mirror:"instance.net_time_us" help:"Time from start until the application listened on a non-localhost port."` + } `help:"Start-up and run time measurements."` Restart struct { - Policy string `mirror:"instance.restart_policy" create:"set" flag:"restart" help:"Restart policy." placeholder:"policy" example:"always,on-failure,never"` - StartCount int `mirror:"instance.start_count"` - RestartCount int `mirror:"instance.restart_count"` - } - - SchedPriority *platform.SchedPriority `mirror:"instance.sched_priority" field:"sched-priority,long" create:"set" edit:"set" flag:"sched-priority" help:"Scheduling priority for the instance." placeholder:"priority" example:"normal,medium,high,admin"` - Autostart bool `field:"autostart,invisible,valueless" create:"set" flag:"autostart" help:"Start instance automatically."` - Replicas int64 `field:"replicas,invisible,valueless" create:"set" flag:"replicas" help:"Number of replicas." placeholder:"n" example:"1,3"` - Features []string `field:"features,invisible,valueless" create:"set" flag:"feature" sep:"none" help:"Instance feature." placeholder:"feature"` - Template string `field:"template,invisible,valueless" create:"set" flag:"template" help:"Create from instance template." placeholder:"name"` - Branch multimetro.Key `field:"branch,invisible,valueless" create:"set" flag:"branch" help:"Branch from an existing instance." placeholder:"instance"` - Checkpoint multimetro.Key `field:"checkpoint,invisible,valueless" create:"set" flag:"checkpoint" help:"Create from a checkpoint." placeholder:"checkpoint"` + Policy string `mirror:"instance.restart_policy" create:"set" flag:"restart" help:"Restart policy. Restarts back off from immediate up to 5m. Cannot be combined with the delete-on-stop feature.\n never: never restart (default)\n always: restart whenever the instance exits or crashes\n on-failure: restart only after a crash or a non-zero exit code" placeholder:"policy" example:"always,on-failure,never"` + StartCount int `mirror:"instance.start_count" help:"Number of times the instance has started."` + RestartCount int `mirror:"instance.restart_count" help:"Number of times the instance has been restarted automatically."` + } `help:"Restart policy and counters."` + + SchedPriority *platform.SchedPriority `mirror:"instance.sched_priority" field:"sched-priority,long" create:"set" edit:"set" flag:"sched-priority" help:"Scheduling priority: normal (default), medium, high or admin. Requires the override_vm_priority permission." placeholder:"priority" example:"normal,medium,high,admin"` + Autostart bool `field:"autostart,invisible,valueless" create:"set" flag:"autostart" help:"Start the instance right after creation. When false it stays stopped, except that scale-to-zero puts it in standby, where incoming traffic starts it."` + Replicas int64 `field:"replicas,invisible,valueless" create:"set" flag:"replicas" help:"Additional identical instances to create, so replicas=2 creates three in total." placeholder:"n" example:"1,3"` + Features []string `field:"features,invisible,valueless" create:"set" flag:"feature" sep:"none" help:"Instance feature to enable.\n delete-on-stop: delete the instance when it stops, not with a restart policy or delete-lock\n nested-virt: expose virtualization extensions to the guest, needs the nested_virt permission" placeholder:"feature" example:"delete-on-stop"` + Template string `field:"template,invisible,valueless" create:"set" flag:"template" help:"Create from an instance template, by name or UUID. The clone inherits its configuration and, when the template has a snapshot, its memory state. Cannot be combined with image, branch, checkpoint, type or gpus." placeholder:"name"` + Branch multimetro.Key `field:"branch,invisible,valueless" create:"set" flag:"branch" help:"Clone an existing instance, by name or UUID. A running source is snapshotted asynchronously and keeps running. Cannot be combined with image, args, env, memory, vcpus, type or gpus." placeholder:"instance"` + Checkpoint multimetro.Key `field:"checkpoint,invisible,valueless" create:"set" flag:"checkpoint" help:"Create from a checkpoint, by name or UUID, restoring its memory and disk. Cannot be combined with image, args, env, memory, vcpus, type or gpus." placeholder:"checkpoint"` // NOTE: not supported on prod-staging/prod-stable // this needs a custom platform build - Vsock bool `field:"vsock,invisible,valueless" create:"set" edit:"set"` + Vsock bool `field:"vsock,invisible,valueless" create:"set" edit:"set" help:"Enable the virtio socket (vsock) device for the instance."` Stop struct { - Reason string `field:",long"` - Origin string `field:"origin,hidden"` - Errno stop.Errno `field:"errno,hidden"` + Reason string `field:",long" help:"Why the instance last stopped."` + Origin string `field:"origin,hidden" help:"Who stopped it: user, platform (such as scale-to-zero), app (the application exited), kernel (the guest kernel stopped, usually a crash) or unknown."` + Errno stop.Errno `field:"errno,hidden" help:"Linux errno from the guest kernel's stop code at the last stop, such as ENOMEM when it ran out of memory."` - ExitCode *uint32 `mirror:"instance.exit_code" field:"exit-code,long"` - } `field:",long"` + ExitCode *uint32 `mirror:"instance.exit_code" field:"exit-code,long" help:"Exit code of the guest's last run."` + } `field:",long" help:"Details of the last stop."` Instance platform.Instance `field:"-" json:"instance"` Profile *config.Profile `field:"-" json:"profile"` key multimetro.Key - DeleteLock bool `mirror:"instance.delete_lock" field:"delete-lock,long" edit:"set" flag:"delete-lock" help:"Prevent instance deletion until the lock is removed."` + DeleteLock bool `mirror:"instance.delete_lock" field:"delete-lock,long" edit:"set" flag:"delete-lock" help:"Prevent deletion until the lock is removed. Also pauses autokill."` } type InstanceNetwork struct { - UUID string `mirror:"uuid" field:",long"` - PrivateIP string `mirror:"private_ip" field:",long"` - MAC string `mirror:"mac" field:",long"` + UUID string `mirror:"uuid" field:",long" help:"Unique identifier of the network interface."` + PrivateIP string `mirror:"private_ip" field:",long" help:"Private IP address, reachable only from your other instances in the metro."` + MAC string `mirror:"mac" field:",long" help:"MAC address of the network interface."` } type InstanceGpu struct { - UUID string `mirror:"uuid" field:",long"` - Model string `mirror:"model" field:",long"` + UUID string `mirror:"uuid" field:",long" help:"Unique identifier of the GPU."` + Model string `mirror:"model" field:",long" help:"GPU model, as its PCI vendor:device ID."` } type InstanceService struct { Link[ServiceGroup] - Services []*Service `mirror:"services" json:"services,omitempty" field:",invisible,valueless" create:"set" flag:"publish" short:"p" sep:"none" help:"Publish port." placeholder:":[/]" example:"443:8080/http+tls"` - Domains []Domain `mirror:"domains" json:"domains,omitempty" field:",short,embed" create:"set" flag:"domain" sep:"none" help:"Service domain." placeholder:"fqdn" example:"example.com"` - SoftLimit uint32 `json:"soft-limit,omitempty" field:"soft-limit,invisible,valueless" create:"set"` - HardLimit uint32 `json:"hard-limit,omitempty" field:"hard-limit,invisible,valueless" create:"set"` + Services []*Service `mirror:"services" json:"services,omitempty" field:",invisible,valueless" create:"set" flag:"publish" short:"p" sep:"none" help:"Published port as SOURCE:DESTINATION[/HANDLERS], with handlers joined by +. Port 80 must use http, port 443 http+tls, and any other port tls only.\n http: load balance per HTTP request instead of per TCP connection\n tls: terminate TLS at the platform\n redirect: redirect HTTP on port 80 to HTTPS, as in 80:443/http+redirect" placeholder:":[/]" example:"443:8080/http+tls,80:443/http+redirect"` + Domains []Domain `mirror:"domains" json:"domains,omitempty" field:",short,embed" create:"set" flag:"domain" sep:"none" help:"Domain for the group.\n myapp: a bare label becomes a subdomain of the metro\n example.com: a dotted name is a custom domain, with a certificate issued automatically unless given as fqdn=example.com,certificate=my-cert" placeholder:"fqdn" example:"example.com,myapp"` + SoftLimit uint32 `json:"soft-limit,omitempty" field:"soft-limit,invisible,valueless" create:"set" help:"Concurrent requests per instance before the load balancer wakes a standby instance of the group. Between 1 and 65535, defaults to 1."` + HardLimit uint32 `json:"hard-limit,omitempty" field:"hard-limit,invisible,valueless" create:"set" help:"Maximum concurrent requests per instance. Between 1 and 65535, defaults to 65535, and at least the soft limit. Excess requests go to another instance or fail."` } // UnmarshalText only links to a service group by name/key - use @@ -252,16 +252,16 @@ func (i *InstanceService) UnmarshalJSON(data []byte) error { // into volume templates, so the link targets that type rather than Volume. type InstanceTemplateVolume struct { Link[VolumeTemplate] - At string `name:"at" mirror:"at" json:"at" field:",long"` - Readonly bool `name:"readonly" mirror:"readonly" json:"readonly,omitempty" field:",long"` + At string `name:"at" mirror:"at" json:"at" field:",long" help:"Mount path inside the instance."` + Readonly bool `name:"readonly" mirror:"readonly" json:"readonly,omitempty" field:",long" help:"Mounted read-only."` } type InstanceVolume struct { Link[Volume] - At string `name:"at" mirror:"at" json:"at" field:",long"` - Readonly bool `name:"readonly" mirror:"readonly" json:"readonly,omitempty" field:",long"` + At string `name:"at" mirror:"at" json:"at" field:",long" help:"Mount path inside the instance."` + Readonly bool `name:"readonly" mirror:"readonly" json:"readonly,omitempty" field:",long" help:"Mounted read-only."` - Size types.SizeMebibytes `name:"size" json:"size,omitempty" field:"size,invisible,valueless" create:"set"` + Size types.SizeMebibytes `name:"size" json:"size,omitempty" field:"size,invisible,valueless" create:"set" help:"Size of a new volume created together with the instance."` } func (v *InstanceVolume) MarshalText() ([]byte, error) { @@ -361,10 +361,10 @@ func (v *InstanceVolume) UnmarshalText(data []byte) error { // // name= may be omitted when at= is given; it is then derived from the mount path. type InstanceRom struct { - Name string `name:"name" mirror:"name" json:"name,omitempty" field:",long"` - Image string `name:"image" mirror:"image" json:"image,omitempty" field:",long"` - Dir string `name:"dir" json:"-" field:"dir,invisible,long"` - At string `name:"at" mirror:"at" json:"at,omitempty" field:",long"` + Name string `name:"name" mirror:"name" json:"name,omitempty" field:",long" help:"ROM name of letters, digits, - and _, which appears as /dev/ukp_rom_NAME in the guest."` + Image string `name:"image" mirror:"image" json:"image,omitempty" field:",long" help:"Image the ROM is loaded from. Cannot be combined with dir."` + Dir string `name:"dir" json:"-" field:"dir,invisible,long" help:"Local directory packed into the ROM instead of an image."` + At string `name:"at" mirror:"at" json:"at,omitempty" field:",long" help:"Path the ROM is mounted at inside the instance. When omitted, the guest mounts the raw device itself."` } func (r *InstanceRom) UnmarshalText(data []byte) error { @@ -449,10 +449,10 @@ func (c PluginConfig) MarshalJSON() ([]byte, error) { // // rom= is accepted as a deprecated alias for image=. type InstancePlugin struct { - Name string `name:"name" mirror:"name" json:"name" field:",long"` - Image string `name:"image" mirror:"image" json:"image" field:",long"` + Name string `name:"name" mirror:"name" json:"name" field:",long" help:"Plugin name: up to 63 letters, digits, - and _. The plugin is mounted at /uk/plugins/NAME."` + Image string `name:"image" mirror:"image" json:"image" field:",long" help:"Image the plugin is loaded from. Its init program runs when the plugin starts."` Rom string `name:"rom" mirror:"-" json:"rom,omitempty" field:"-"` - Config PluginConfig `name:"config" mirror:"config" json:"config,omitempty" field:",long"` + Config PluginConfig `name:"config" mirror:"config" json:"config,omitempty" field:",long" help:"JSON configuration passed to the plugin init program on stdin."` } func (p *InstancePlugin) Validate() error { @@ -539,11 +539,11 @@ func inlineFilesFromDir(dir string) ([]platform.InlineFile, error) { } type InstanceScaleToZero struct { - Enabled bool `name:"-" json:"-" mirror:"enabled" field:",long"` - Policy string `name:"policy" json:"policy,omitempty" mirror:"policy" field:",long"` - Stateful bool `name:"stateful" json:"stateful,omitempty" mirror:"stateful" field:",long"` - CooldownTime types.DurationMS `name:"cooldown-time" json:"cooldown-time,omitempty" mirror:"cooldown_time_ms" field:",long"` - NotifyTime types.DurationMS `name:"notify-time" json:"notify-time,omitempty" mirror:"notify_time_ms" field:",long"` + Enabled bool `name:"-" json:"-" mirror:"enabled" field:",long" help:"Whether scale-to-zero is enabled."` + Policy string `name:"policy" json:"policy,omitempty" mirror:"policy" field:",long" help:"Scale-to-zero policy: on, idle (also while open TCP connections are idle, needs stateful) or off."` + Stateful bool `name:"stateful" json:"stateful,omitempty" mirror:"stateful" field:",long" help:"Keep RAM in a snapshot and resume from it, instead of restarting from scratch."` + CooldownTime types.DurationMS `name:"cooldown-time" json:"cooldown-time,omitempty" mirror:"cooldown_time_ms" field:",long" help:"Inactivity before scaling to zero."` + NotifyTime types.DurationMS `name:"notify-time" json:"notify-time,omitempty" mirror:"notify_time_ms" field:",long" help:"How long the instance is warned before scaling to zero. 0 disables, and it must be below cooldown-time."` } func (s *InstanceScaleToZero) UnmarshalText(data []byte) error { @@ -602,8 +602,8 @@ func (s InstanceScaleToZero) MarshalJSON() ([]byte, error) { } type InstanceAutokill struct { - TimeMs types.DurationMS `name:"time" json:"time,omitempty" mirror:"time_ms" field:"time,long"` - NumRequests uint32 `name:"num-requests" json:"num-requests,omitempty" mirror:"num_requests" field:"num-requests,long"` + TimeMs types.DurationMS `name:"time" json:"time,omitempty" mirror:"time_ms" field:"time,long" help:"Time after the instance stops before it is deleted. 0 disables."` + NumRequests uint32 `name:"num-requests" json:"num-requests,omitempty" mirror:"num_requests" field:"num-requests,long" help:"Requests or connections the instance serves before it is deleted. 0 disables."` } func (a *InstanceAutokill) UnmarshalText(data []byte) error { @@ -647,7 +647,7 @@ func (a InstanceAutokill) MarshalJSON() ([]byte, error) { // Autokill is the time-only form of autokill used by service groups and // instance templates, whose endpoints have no request-count trigger. type Autokill struct { - TimeMs types.DurationMS `name:"time" json:"time,omitempty" mirror:"time_ms" field:"time,long"` + TimeMs types.DurationMS `name:"time" json:"time,omitempty" mirror:"time_ms" field:"time,long" help:"Time without use before deletion. 0 disables."` } func (a *Autokill) UnmarshalText(data []byte) error { diff --git a/internal/cmd/link.go b/internal/cmd/link.go index 5f930578..de9de959 100644 --- a/internal/cmd/link.go +++ b/internal/cmd/link.go @@ -18,8 +18,8 @@ import ( // Link models a resource reference using group.Ref-compatible fields. type Link[T resource.Resource] struct { Metro string `name:"metro" json:"metro,omitempty" mirror:"metro" field:"-"` - Name string `name:"name" json:"name,omitempty" mirror:"name" field:",long"` - UUID string `name:"uuid" json:"uuid,omitempty" mirror:"uuid" field:",long"` + Name string `name:"name" json:"name,omitempty" mirror:"name" field:",long" help:"Name of the linked resource."` + UUID string `name:"uuid" json:"uuid,omitempty" mirror:"uuid" field:",long" help:"Unique identifier of the linked resource."` } func (l Link[T]) Ref() group.Ref { diff --git a/internal/cmd/marshal_test.go b/internal/cmd/marshal_test.go index 2ef87b01..a86ea5dd 100644 --- a/internal/cmd/marshal_test.go +++ b/internal/cmd/marshal_test.go @@ -411,7 +411,7 @@ func TestLinkCollection(t *testing.T) { }{Name: "attached-inst"}) vol.MountedBy = append(vol.MountedBy, struct { cmd.Link[cmd.Instance] - ReadOnly bool `mirror:"readonly" field:",long"` + ReadOnly bool `mirror:"readonly" field:",long" help:"Whether the instance mounts the volume read-only."` }{Name: "mounted-inst"}) sg := cmd.ServiceGroup{} diff --git a/internal/cmd/metros.go b/internal/cmd/metros.go index bb79d56c..c1cee42c 100644 --- a/internal/cmd/metros.go +++ b/internal/cmd/metros.go @@ -31,10 +31,10 @@ type MetrosCmd struct { } type Metro struct { - Name string `field:",short" json:"name"` - Location string `field:",short" json:"location"` - Endpoint string `field:",short" json:"endpoint"` - Insecure *bool `field:",long" json:"insecure"` + Name string `field:",short" json:"name" help:"Metro name, such as fra."` + Location string `field:",short" json:"location" help:"IATA code of the metro location."` + Endpoint string `field:",short" json:"endpoint" help:"API endpoint URL of the metro."` + Insecure *bool `field:",long" json:"insecure" help:"Skip TLS verification when connecting to the metro's API and image index."` url string } @@ -73,6 +73,7 @@ func (i Metro) Fields(ctx context.Context) ([]resource.Field, error) { } fields = append(fields, resource.Field{ Name: "quotas", + Help: "Usage and limits of your quota in the metro.", Verbosity: resource.FieldVerbosityLong, Subfields: quotaFields, }) @@ -188,20 +189,24 @@ func (i Metro) Fields(ctx context.Context) ([]resource.Field, error) { // Add a grouped "status" field with lazy-computed subfields fields = append(fields, resource.Field{ Name: "status", + Help: "Reachability of the metro endpoint.", Verbosity: resource.FieldVerbosityLong, Subfields: []resource.Field{ { Name: "ip", + Help: "IP addresses the endpoint resolves to.", Verbosity: resource.FieldVerbosityLong, ValueCallback: ip, }, { Name: "ping", + Help: "TCP connection latency to the endpoint.", Verbosity: resource.FieldVerbosityLong, ValueCallback: ping, }, { Name: "online", + Help: "Whether the endpoint answers an HTTP request without a server error: online or offline.", Verbosity: resource.FieldVerbosityLong, ValueCallback: online, }, @@ -262,29 +267,29 @@ func (Metro) Examples() map[cmd.CmdType][]kingkong.Example { type metroQuotas struct { Instances struct { - Active types.Usage[int64] `field:",long,embed"` - Total types.Usage[int64] `field:",long,embed"` - } `field:",long"` + Active types.Usage[int64] `field:",long,embed" help:"Instances not in the stopped state. The platform reports the vCPU limit as its limit."` + Total types.Usage[int64] `field:",long,embed" help:"All instances."` + } `field:",long" help:"Instance counts."` Vcpus struct { - Active types.Usage[int64] `field:",long,embed"` - } `field:",long"` + Active types.Usage[int64] `field:",long,embed" help:"vCPUs of instances not in the stopped state."` + } `field:",long" help:"vCPU usage."` Memory struct { - Active types.Usage[types.SizeMebibytes] `field:",long,embed"` - } `field:",long"` + Active types.Usage[types.SizeMebibytes] `field:",long,embed" help:"Memory assigned to instances not in the stopped state."` + } `field:",long" help:"Memory usage."` Services struct { - Groups types.Usage[int64] `field:",long,embed"` - Exposed types.Usage[int64] `field:",long,embed"` - } `field:",long"` + Groups types.Usage[int64] `field:",long,embed" help:"Service groups."` + Exposed types.Usage[int64] `field:",long,embed" help:"Published ports across all service groups."` + } `field:",long" help:"Service usage."` Volumes struct { - Count types.Usage[int64] `field:",long,embed"` - Total types.Usage[types.SizeMebibytes] `field:",long,embed"` - } `field:",long"` + Count types.Usage[int64] `field:",long,embed" help:"Number of volumes."` + Total types.Usage[types.SizeMebibytes] `field:",long,embed" help:"Volume space counted against your quota: the full size of static volumes plus the used space of dynamic ones."` + } `field:",long" help:"Volume usage."` Limits struct { - Vcpus types.Range[int64] `field:",long,embed"` - Memory types.Range[types.SizeMebibytes] `field:",long,embed"` - Volume types.Range[types.SizeMebibytes] `field:",long,embed"` - Autoscale types.Range[int64] `field:",long,embed"` - } `field:",long"` + Vcpus types.Range[int64] `field:",long,embed" help:"vCPUs per instance."` + Memory types.Range[types.SizeMebibytes] `field:",long,embed" help:"Memory per instance."` + Volume types.Range[types.SizeMebibytes] `field:",long,embed" help:"Size of a volume."` + Autoscale types.Range[int64] `field:",long,embed" help:"Size of an autoscale group."` + } `field:",long" help:"Allowed range for a single resource."` httpClient *http.Client endpoint string diff --git a/internal/cmd/profile.go b/internal/cmd/profile.go index 8bfc5c46..62b02cd7 100644 --- a/internal/cmd/profile.go +++ b/internal/cmd/profile.go @@ -31,10 +31,10 @@ type ProfileCmd struct { } type Profile struct { - Name string `field:",short" json:"name"` - Active bool `field:",short" json:"active"` + Name string `field:",short" json:"name" help:"Profile name."` + Active bool `field:",short" json:"active" help:"Whether this is the default profile, which --profile overrides."` - Metros []string `field:",short" json:"metros"` + Metros []string `field:",short" json:"metros" help:"Metros of the profile."` } func (Profile) Type() resource.Type { diff --git a/internal/cmd/services.go b/internal/cmd/services.go index 057c96b1..55e45610 100644 --- a/internal/cmd/services.go +++ b/internal/cmd/services.go @@ -40,31 +40,31 @@ type ServicesCmd struct { } type ServiceGroup struct { - Metro LinkName[Metro] `field:"metro,short" create:"set,required" flag:"metro" help:"Metro to create in." placeholder:"metro" example:"fra,sfo"` - Name string `mirror:"service_group.name" field:",short" create:"set" flag:"name" help:"Service group name." placeholder:"name"` - UUID string `mirror:"service_group.uuid" field:",long"` + Metro LinkName[Metro] `field:"metro,short" create:"set,required" flag:"metro" help:"Metro to create in. Defaults to the profile's default metro." placeholder:"metro" example:"fra,sfo"` + Name string `mirror:"service_group.name" field:",short" create:"set" flag:"name" help:"Service group name. Generated when omitted." placeholder:"name"` + UUID string `mirror:"service_group.uuid" field:",long" help:"Unique identifier assigned at creation."` - Persistent bool `mirror:"service_group.persistent" field:",long"` - Autoscale bool `mirror:"service_group.autoscale" field:",short"` + Persistent bool `mirror:"service_group.persistent" field:",long" help:"False while the group belongs to the instance that created it inline and is deleted with it. Turns true once another instance joins or autoscale is enabled."` + Autoscale bool `mirror:"service_group.autoscale" field:",short" help:"Whether autoscale is enabled for the group."` Limits struct { - Soft uint64 `mirror:"service_group.soft_limit" field:",long" create:"set" edit:"set" flag:"soft-limit" help:"Soft limit." placeholder:"n" example:"1,5"` - Hard uint64 `mirror:"service_group.hard_limit" field:",long" create:"set" edit:"set" flag:"hard-limit" help:"Hard limit." placeholder:"n" example:"10,100"` - } + Soft uint64 `mirror:"service_group.soft_limit" field:",long" create:"set" edit:"set" flag:"soft-limit" help:"Concurrent requests per instance before the load balancer wakes a standby instance of the group. Between 1 and 65535, defaults to 1 on create." placeholder:"n" example:"1,5"` + Hard uint64 `mirror:"service_group.hard_limit" field:",long" create:"set" edit:"set" flag:"hard-limit" help:"Maximum concurrent requests per instance, at least the soft limit. Between 1 and 65535, defaults to 65535 on create. Excess requests go to another instance or fail." placeholder:"n" example:"10,100"` + } `help:"Concurrent request limits per instance, used for load balancing."` - Autokill Autokill `field:",embed" mirror:"service_group.autokill" create:"set" edit:"set" flag:"autokill" help:"Autokill options.\n time: time the group must stay empty before it is deleted" placeholder:"=" example:"time=5m"` + Autokill Autokill `field:",embed" mirror:"service_group.autokill" create:"set" edit:"set" flag:"autokill" help:"Autokill options.\n time: time the group must stay without instances before it is deleted, such as 5m, 0 disables" placeholder:"=" example:"time=5m"` Timestamps struct { - Created types.RelativeTime `mirror:"service_group.created_at" field:",short"` - } + Created types.RelativeTime `mirror:"service_group.created_at" field:",short" help:"Creation time."` + } `help:"Lifecycle timestamps."` - Domains []Domain `mirror:"service_group.domains" field:",embed" create:"set" edit:"set,add,del" flag:"domain" sep:"none" help:"Service domain." placeholder:"fqdn" example:"example.com"` + Domains []Domain `mirror:"service_group.domains" field:",embed" create:"set" edit:"set,add,del" flag:"domain" sep:"none" help:"Domain for the group.\n myapp: a bare label becomes a subdomain of the metro\n example.com: a dotted name is a custom domain, with a certificate issued automatically unless given as fqdn=example.com,certificate=my-cert" placeholder:"fqdn" example:"example.com,myapp"` Instances []struct { Link[Instance] - } `mirror:"service_group.instances"` + } `mirror:"service_group.instances" help:"Instances in the group."` - Services []*Service `mirror:"service_group.services" field:",embed" create:"set,required" edit:"set,add,del" flag:"service" sep:"none" help:"Service port." placeholder:":[/]" example:"443:8080/http+tls"` + Services []*Service `mirror:"service_group.services" field:",embed" create:"set,required" edit:"set,add,del" flag:"service" sep:"none" help:"Published port as SOURCE:DESTINATION[/HANDLERS], with handlers joined by +. Port 80 must use http, port 443 http+tls, and any other port tls only.\n http: load balance per HTTP request instead of per TCP connection\n tls: terminate TLS at the platform\n redirect: redirect HTTP on port 80 to HTTPS, as in 80:443/http+redirect" placeholder:":[/]" example:"443:8080/http+tls,80:443/http+redirect"` ServiceGroup platform.ServiceGroup `field:"-" json:"service_group"` @@ -72,9 +72,9 @@ type ServiceGroup struct { } type Service struct { - Source uint32 `mirror:"port" json:"source" field:",short"` - Destination uint32 `mirror:"destination_port" json:"destination" field:",short"` - Handlers []platform.ConnectionHandler `mirror:"handlers" json:"handlers" field:",short"` + Source uint32 `mirror:"port" json:"source" field:",short" help:"Public port."` + Destination uint32 `mirror:"destination_port" json:"destination" field:",short" help:"Port the instance listens on."` + Handlers []platform.ConnectionHandler `mirror:"handlers" json:"handlers" field:",short" help:"Connection handlers: http, tls, redirect."` } func (s *Service) MarshalText() ([]byte, error) { @@ -142,10 +142,10 @@ func (s *Service) UnmarshalText(text []byte) error { type Domain struct { // TODO: consolidate these and make easier to parse - FQDN string `name:"fqdn" json:"fqdn" mirror:"fqdn" field:",short"` + FQDN string `name:"fqdn" json:"fqdn" mirror:"fqdn" field:",short" help:"Fully qualified domain name."` Name string `name:"name" json:"name,omitempty" field:"-"` // field:"-" excludes from field system, name:"name" allows --set parsing - Certificate TextLink[Certificate] `name:"certificate" json:"certificate,omitzero" mirror:"certificate" field:",embed"` + Certificate TextLink[Certificate] `name:"certificate" json:"certificate,omitzero" mirror:"certificate" field:",embed" help:"Certificate serving the domain."` } func (d *Domain) UnmarshalText(text []byte) error { diff --git a/internal/cmd/volume_templates.go b/internal/cmd/volume_templates.go index 9dc1fa0c..b9f9e69d 100644 --- a/internal/cmd/volume_templates.go +++ b/internal/cmd/volume_templates.go @@ -36,23 +36,23 @@ type VolumeTemplatesCmd struct { } type VolumeTemplate struct { - Metro LinkName[Metro] `field:"metro,short"` - Name string `mirror:"volume.name" field:",short"` - UUID string `mirror:"volume.uuid" field:",long"` + Metro LinkName[Metro] `field:"metro,short" help:"Metro the template is in."` + Name string `mirror:"volume.name" field:",short" help:"Template name, unique per metro among volumes and volume templates."` + UUID string `mirror:"volume.uuid" field:",long" help:"Unique identifier assigned at creation."` - Tags []string `mirror:"volume.tags" field:",long" edit:"set,add,del" flag:"tag" sep:"none" help:"Template tag." placeholder:"tag" example:"env-dev"` - DeleteLock bool `mirror:"volume.delete_lock" field:"delete-lock,long" edit:"set" flag:"delete-lock" help:"Prevent deletion of the template."` + Tags []string `mirror:"volume.tags" field:",long" edit:"set,add,del" flag:"tag" sep:"none" help:"Tags for grouping and filtering: up to 16, each 1 to 256 characters of letters, digits and -+_.:=. Not visible to the guest." placeholder:"tag" example:"env-dev"` + DeleteLock bool `mirror:"volume.delete_lock" field:"delete-lock,long" edit:"set" flag:"delete-lock" help:"Prevent deletion until the lock is removed."` - State types.VolumeState `mirror:"volume.state" field:",short"` - Size types.SizeMebibytes `mirror:"volume.size_mb" field:",short"` - Filesystem string `mirror:"volume.filesystem" field:",long"` - Persistent bool `mirror:"volume.persistent" field:",long"` + State types.VolumeState `mirror:"volume.state" field:",short" help:"Lifecycle state, always template for a volume template."` + Size types.SizeMebibytes `mirror:"volume.size_mb" field:",short" help:"Volume size."` + Filesystem string `mirror:"volume.filesystem" field:",long" help:"Filesystem of the volume: ext4 or virtiofs."` + Persistent bool `mirror:"volume.persistent" field:",long" help:"False when the template belongs to an instance template, in which case it is deleted with it."` Timestamps struct { - Created types.RelativeTime `mirror:"volume.created_at" field:",short"` - } + Created types.RelativeTime `mirror:"volume.created_at" field:",short" help:"Creation time."` + } `help:"Lifecycle timestamps."` - VolumeRef string `field:"volume,invisible,valueless" create:"set,required" flag-arg:"volume" completion-predictor:"resource-key-volume" help:"Volume to convert into a template."` + VolumeRef string `field:"volume,invisible,valueless" create:"set,required" flag-arg:"volume" completion-predictor:"resource-key-volume" help:"Volume to turn into a template, by name or UUID."` Volume platform.Volume `field:"-" json:"volume"` Profile *config.Profile `field:"-" json:"profile"` diff --git a/internal/cmd/volumes.go b/internal/cmd/volumes.go index cc4d489e..14280ba6 100644 --- a/internal/cmd/volumes.go +++ b/internal/cmd/volumes.go @@ -190,41 +190,41 @@ func (c *VolumesCloneCmd) Run(ctx context.Context, stdio config.Stdio, partition } type Volume struct { - Metro LinkName[Metro] `field:"metro,short" create:"set,required" flag:"metro" help:"Metro to create in." placeholder:"metro" example:"fra,sfo"` - Name string `mirror:"volume.name" field:",short" create:"set" flag:"name" short:"n" help:"Volume name." placeholder:"name"` - UUID string `mirror:"volume.uuid" field:",long"` - - Tags []string `mirror:"volume.tags" field:",long" create:"set" edit:"set,add,del" flag:"tag" sep:"none" help:"Volume tag." placeholder:"tag" example:"env-prod"` - - State types.VolumeState `mirror:"volume.state" field:",short"` - Usage types.MeterUsage[types.SizeMebibytes] `field:"usage,short"` - Free *types.SizeMebibytes `mirror:"volume.free_mb" field:"free,short"` - Size types.SizeMebibytes `mirror:"volume.size_mb" field:",short" create:"set" edit:"set" flag:"size" help:"Volume size." placeholder:"size" example:"10GiB,100MiB"` - Filesystem string `mirror:"volume.filesystem" field:",long" create:"set" flag:"filesystem" help:"Volume filesystem." placeholder:"filesystem" example:"ext4"` - QuotaPolicy string `mirror:"volume.quota_policy" field:"quota-policy,long" create:"set" edit:"set" flag:"quota-policy" help:"Volume quota policy." placeholder:"quota-policy" example:"static,dynamic"` - Persistent bool `mirror:"volume.persistent" field:",long"` - AccessMode *types.AccessMode `mirror:"volume.access_mode" field:",long" create:"set" flag:"access-mode" help:"Volume access mode." placeholder:"access-mode" example:"rwo,rox,rwx"` - HostPath *string `mirror:"volume.host_path" field:"host-path,long"` - Template string `field:"template,invisible,valueless" create:"set" flag:"template" help:"Create from volume template." placeholder:"name"` + Metro LinkName[Metro] `field:"metro,short" create:"set,required" flag:"metro" help:"Metro to create in. Defaults to the profile's default metro." placeholder:"metro" example:"fra,sfo"` + Name string `mirror:"volume.name" field:",short" create:"set" flag:"name" short:"n" help:"Volume name, unique per metro among volumes and volume templates. Generated when omitted." placeholder:"name"` + UUID string `mirror:"volume.uuid" field:",long" help:"Unique identifier assigned at creation."` + + Tags []string `mirror:"volume.tags" field:",long" create:"set" edit:"set,add,del" flag:"tag" sep:"none" help:"Tags for grouping and filtering: up to 16, each 1 to 256 characters of letters, digits and -+_.:=. Not visible to the guest." placeholder:"tag" example:"env-prod"` + + State types.VolumeState `mirror:"volume.state" field:",short" help:"Lifecycle state: uninitialized (not yet formatted), initializing (being formatted or cloned), available (not attached), idle (attached, not mounted), mounted (mounted by a running or standby instance), busy (mount, unmount, check, quota change or delete in progress), error (an operation failed) or template."` + Usage types.MeterUsage[types.SizeMebibytes] `field:"usage,short" help:"Used space out of the volume size."` + Free *types.SizeMebibytes `mirror:"volume.free_mb" field:"free,short" help:"Free space."` + Size types.SizeMebibytes `mirror:"volume.size_mb" field:",short" create:"set" edit:"set" flag:"size" help:"Volume size, within the range the plan allows (see quotas.limits.volume on the metro). Only virtiofs volumes can be resized, and not below their current usage." placeholder:"size" example:"10GiB,100MiB"` + Filesystem string `mirror:"volume.filesystem" field:",long" create:"set" flag:"filesystem" help:"Filesystem for a new volume: ext4 (default) or virtiofs, depending on what the deployment offers." placeholder:"filesystem" example:"ext4,virtiofs"` + QuotaPolicy string `mirror:"volume.quota_policy" field:"quota-policy,long" create:"set" edit:"set" flag:"quota-policy" help:"How the volume counts against your quota. On edit the volume must not be mounted.\n static: reserve the full size (default)\n dynamic: count only used space and allow overcommit, not for ext4 volumes" placeholder:"quota-policy" example:"static,dynamic"` + Persistent bool `mirror:"volume.persistent" field:",long" help:"False when the volume belongs to an instance, created inline or cloned with it, in which case it is deleted with that instance. Setting delete-lock makes it persistent."` + AccessMode *types.AccessMode `mirror:"volume.access_mode" field:",long" create:"set" flag:"access-mode" help:"How instances can mount the volume.\n rwo: one read-write mount or many read-only mounts (default)\n rox: read-only mounts only\n rwx: many read-write mounts, virtiofs only, with guest caching off" placeholder:"access-mode" example:"rwo,rox,rwx"` + HostPath *string `mirror:"volume.host_path" field:"host-path,long" help:"Host path backing a managed volume, shown only with the right permissions."` + Template string `field:"template,invisible,valueless" create:"set" flag:"template" help:"Clone a volume template, by name or UUID, instead of creating an empty volume." placeholder:"name"` Timestamps struct { - Created types.RelativeTime `mirror:"volume.created_at" field:",short"` - } + Created types.RelativeTime `mirror:"volume.created_at" field:",short" help:"Creation time."` + } `help:"Lifecycle timestamps."` AttachedTo []struct { Link[Instance] - } `mirror:"volume.attached_to"` + } `mirror:"volume.attached_to" help:"Instances the volume is configured on."` MountedBy []struct { Link[Instance] - ReadOnly bool `mirror:"readonly" field:",long"` - } `mirror:"volume.mounted_by"` + ReadOnly bool `mirror:"readonly" field:",long" help:"Whether the instance mounts the volume read-only."` + } `mirror:"volume.mounted_by" help:"Instances currently mounting the volume, with their read-only flag."` Volume platform.Volume `field:"-" json:"volume"` key multimetro.Key - DeleteLock bool `mirror:"volume.delete_lock" field:"delete-lock,long" edit:"set" flag:"delete-lock" help:"Prevent volume deletion until the lock is removed."` + DeleteLock bool `mirror:"volume.delete_lock" field:"delete-lock,long" edit:"set" flag:"delete-lock" help:"Prevent deletion until the lock is removed."` } func (Volume) Type() resource.Type { diff --git a/internal/config/config.go b/internal/config/config.go index 54738eb8..72170dd6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -21,10 +21,10 @@ import ( // Config represents the global configuration for the Unikraft CLI. type Config struct { - Path string `json:"-" field:"path,short"` + Path string `json:"-" field:"path,short" help:"Path of the configuration file."` - DefaultProfile string `json:"profile" field:"profile,short"` - Profiles map[string]Profile `json:"profiles" field:",short,embed"` + DefaultProfile string `json:"profile" field:"profile,short" help:"Profile used when neither --profile nor UNIKRAFT_PROFILE is set."` + Profiles map[string]Profile `json:"profiles" field:",short,embed" help:"Configured profiles, by name."` selectedProfile string } diff --git a/internal/config/profile.go b/internal/config/profile.go index 3fab2373..51ac8ad9 100644 --- a/internal/config/profile.go +++ b/internal/config/profile.go @@ -65,28 +65,28 @@ const ( // Profile represents a user profile configuration for the Unikraft CLI. type Profile struct { // Name of the profile - Name string `json:"-" field:",short"` + Name string `json:"-" field:",short" help:"Profile name."` // Type of the profile - Type ProfileType `json:"type" field:",long"` + Type ProfileType `json:"type" field:",long" help:"Profile type: cloud or legacy (token and metro from UKC_TOKEN and UKC_METRO). The local type is not supported yet."` // Token is the authentication token associated with the profile, used for // authenticating with Unikraft Cloud services. - Token string `json:"token,omitempty" field:",long"` + Token string `json:"token,omitempty" field:",long" help:"Authentication token for Unikraft Cloud."` // Organization is the organization associated with the profile. - Organization string `json:"organization,omitempty" field:",short"` + Organization string `json:"organization,omitempty" field:",short" help:"Organization of the profile."` // UserUUID is the UUID of the user who approved the login, if known. - UserUUID string `json:"user_uuid,omitempty" field:",long"` + UserUUID string `json:"user_uuid,omitempty" field:",long" help:"UUID of the user who approved the login, if known."` // OrganizationUUID is the UUID of the organization of the token. - OrganizationUUID string `json:"organization_uuid,omitempty" field:",long"` + OrganizationUUID string `json:"organization_uuid,omitempty" field:",long" help:"UUID of the organization of the token."` // ControlPlane is the endpoint for the control plane associated with the profile. - ControlPlane string `json:"controlplane,omitempty" field:",long"` + ControlPlane string `json:"controlplane,omitempty" field:",long" help:"Control plane endpoint."` // Insecure indicates whether to allow insecure connections to the control plane, skipping TLS verification. - Insecure bool `json:"controlplane_insecure,omitempty" field:",long"` + Insecure bool `json:"controlplane_insecure,omitempty" field:",long" help:"Skip TLS verification when connecting to the control plane."` // RegistryMirror is the endpoint that gets the requests for the Unikraft image registry. - RegistryMirror string `json:"registry_mirror,omitempty" field:",long"` + RegistryMirror string `json:"registry_mirror,omitempty" field:",long" help:"Endpoint that serves requests for the Unikraft image registry."` // Metros is a static list of metros. - Metros []Metro `json:"metros,omitempty" field:",long,embed"` + Metros []Metro `json:"metros,omitempty" field:",long,embed" help:"Metros of the profile."` // MetroDefault is the default metro to use when creating resources. - MetroDefault string `json:"metro_default,omitempty"` + MetroDefault string `json:"metro_default,omitempty" help:"Metro that create commands and unikraft api use when none is given. Without it, a profile with a single metro uses that one."` } func (p Profile) GetDefaultMetro() string { @@ -175,15 +175,15 @@ func (p Profile) Validate() error { // Metro represents a metro configuration for a profile in the Unikraft CLI. type Metro struct { // Name of the metro. - Name string `json:"name" field:",short"` + Name string `json:"name" field:",short" help:"Metro name, such as fra."` // Endpoint for the metro. - Endpoint string `json:"endpoint" field:",long"` + Endpoint string `json:"endpoint" field:",long" help:"API endpoint URL of the metro."` // Proxy is an optional URL that the CLI connects through instead of Endpoint. - Proxy string `json:"proxy,omitempty" field:",long"` + Proxy string `json:"proxy,omitempty" field:",long" help:"Proxy URL the CLI connects through instead of the endpoint."` // Location is the IATA code for where the metro is located. - Location string `json:"location" field:",short"` + Location string `json:"location" field:",short" help:"IATA code of the metro location."` // Allows insecure connections to the metro, skipping TLS verification. - Insecure *bool `json:"insecure,omitempty" field:",long"` + Insecure *bool `json:"insecure,omitempty" field:",long" help:"Skip TLS verification when connecting to the metro's API and image index."` } type Index struct { diff --git a/internal/resource/resource.go b/internal/resource/resource.go index 9d616e15..f41b2f45 100644 --- a/internal/resource/resource.go +++ b/internal/resource/resource.go @@ -103,6 +103,11 @@ type Field struct { // unset value normally is. KeepZero bool `json:"keep_zero,omitempty"` + // Help and Example describe the field for people and tools, taken from + // the help and example struct tags. + Help string `json:"-"` + Example string `json:"-"` + // settings for creating or patching resources Create *Patch `json:"create,omitempty"` Edit *Patch `json:"edit,omitempty"` diff --git a/internal/resource/struct.go b/internal/resource/struct.go index 6071e3c3..6b4d7275 100644 --- a/internal/resource/struct.go +++ b/internal/resource/struct.go @@ -137,6 +137,8 @@ func fieldFromValue(pf *ParsedField, v reflect.Value) (*Field, error) { result := Field{ Name: pf.Name, Verbosity: pf.Verbosity, + Help: pf.Help, + Example: pf.Example, Value: v.Interface(), KeepZero: pf.KeepZero, Create: createPatch, @@ -292,6 +294,8 @@ type ParsedField struct { Name string Type reflect.Type Verbosity FieldVerbosity + Help string + Example string Embed bool Valueless bool @@ -352,6 +356,8 @@ func ParseField(field reflect.StructField, value reflect.Value) (*ParsedField, e return &ParsedField{ Name: name, Verbosity: verbosity, + Help: field.Tag.Get("help"), + Example: field.Tag.Get("example"), Type: field.Type, Embed: embed, Valueless: valueless, diff --git a/internal/resource/struct_test.go b/internal/resource/struct_test.go index 3b39770b..c3044aec 100644 --- a/internal/resource/struct_test.go +++ b/internal/resource/struct_test.go @@ -683,3 +683,23 @@ func TestGetFieldRefByPathString(t *testing.T) { assert.Equal(t, "link", GetFieldByPathString(fields, "name")[0].Hyperlink) }) } + +func TestFieldsFromStruct_HelpAndExample(t *testing.T) { + type helped struct { + Alpha string `field:"alpha" help:"Alpha." example:"x,y"` + Bees []string `field:"bees" help:"Bees."` + Plain string `field:"plain"` + } + fields, err := FieldsFromStruct(helped{Bees: []string{"b"}}) + require.NoError(t, err) + + byName := make(map[string]Field, len(fields)) + for _, field := range fields { + byName[field.Name] = field + } + assert.Equal(t, "Alpha.", byName["alpha"].Help) + assert.Equal(t, "x,y", byName["alpha"].Example) + assert.Equal(t, "Bees.", byName["bees"].Help) + assert.Empty(t, byName["plain"].Help) + assert.Empty(t, byName["plain"].Example) +} diff --git a/internal/resource/testing/resource.go b/internal/resource/testing/resource.go index 6538e42a..46a19bbc 100644 --- a/internal/resource/testing/resource.go +++ b/internal/resource/testing/resource.go @@ -145,6 +145,7 @@ func (t TestResource) Fields(ctx context.Context) ([]resource.Field, error) { Name: "state", Value: t.State, Verbosity: resource.FieldVerbosityShort, + Help: "Lifecycle state: running or stopped.", }, { Name: "url", @@ -204,6 +205,7 @@ func (t TestResource) Fields(ctx context.Context) ([]resource.Field, error) { Name: "foo", Value: t.Settings.Foo, Verbosity: resource.FieldVerbosityLong, + Help: "Foo setting.", Flag: &resource.FlagSpec{Name: "foo", Tag: `help:"Foo setting."`}, Create: &resource.Patch{ Set: t.Settings.Foo, // Use actual value @@ -216,6 +218,7 @@ func (t TestResource) Fields(ctx context.Context) ([]resource.Field, error) { Name: "bar", Value: t.Settings.Bar, Verbosity: resource.FieldVerbosityLong, + Help: "Bar setting.", Flag: &resource.FlagSpec{Name: "bar", Tag: `help:"Bar setting."`}, Create: &resource.Patch{ Set: t.Settings.Bar, // Use actual value diff --git a/internal/types/quota.go b/internal/types/quota.go index 38831dbf..77478785 100644 --- a/internal/types/quota.go +++ b/internal/types/quota.go @@ -19,8 +19,8 @@ import ( // Usage represents a used/limit pair for quota tracking. // It marshals to text as "used/limit". type Usage[T any] struct { - Used T `field:",long"` - Limit T `field:",long"` + Used T `field:",long" help:"Amount in use."` + Limit T `field:",long" help:"Maximum allowed."` } func (u Usage[T]) MarshalText() ([]byte, error) { @@ -38,8 +38,8 @@ func (u Usage[T]) Value() any { // Range represents a min/max range for quota limits. // It marshals to text as "min...max". type Range[T any] struct { - Min T `field:",long"` - Max T `field:",long"` + Min T `field:",long" help:"Smallest allowed value."` + Max T `field:",long" help:"Largest allowed value."` } func (r Range[T]) MarshalText() ([]byte, error) { From 868521692f953cb6d38ec58f28d0aff6b8576299 Mon Sep 17 00:00:00 2001 From: Justin Chadwell Date: Mon, 2 Nov 2026 16:12:21 +0000 Subject: [PATCH 12/35] fix: Build and copy images without a profile Saving to a local archive failed with "profile not setup", even though the profile only supplies registry credentials, which nothing needed. Add Config.OptionalProfile, which gives a nil profile when none is set up, and use it for registry auth in builds and the image accessor. So pushing without a profile now fails at the registry instead. Signed-off-by: Justin Chadwell --- internal/cmd/build_native.go | 2 +- internal/config/config_test.go | 27 +++++++++++++++++++++++++++ internal/config/profile.go | 11 +++++++++++ internal/images/images.go | 2 +- internal/multimetro/control.go | 5 ++--- 5 files changed, 42 insertions(+), 5 deletions(-) diff --git a/internal/cmd/build_native.go b/internal/cmd/build_native.go index 8f31cc1a..19437a74 100644 --- a/internal/cmd/build_native.go +++ b/internal/cmd/build_native.go @@ -67,7 +67,7 @@ func newBuilder(pw progress.Writer) build.Builder { // 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() + profile, err := config.G(ctx).OptionalProfile() if err != nil { return nil, err } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 21c667c0..cd796e4d 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -315,6 +315,33 @@ func TestEnvProfileDisabled(t *testing.T) { } } +func TestOptionalProfile(t *testing.T) { + t.Run("not setup", func(t *testing.T) { + for _, config := range []*Config{nil, {}} { + profile, err := config.OptionalProfile() + require.NoError(t, err) + assert.Nil(t, profile) + } + }) + + t.Run("not found", func(t *testing.T) { + config := &Config{DefaultProfile: "missing"} + _, err := config.OptionalProfile() + assert.ErrorIs(t, err, ErrProfileNotFound{Name: "missing"}) + }) + + t.Run("current", func(t *testing.T) { + config := &Config{ + DefaultProfile: "default", + Profiles: map[string]Profile{"default": {Name: "default", Token: "token"}}, + } + profile, err := config.OptionalProfile() + require.NoError(t, err) + require.NotNil(t, profile) + assert.Equal(t, "token", profile.Token) + }) +} + func TestEnvProfileBypassesConfigFile(t *testing.T) { // When enabled, the env-derived profile takes precedence over on-disk profiles. t.Setenv("UNIKRAFT_PROFILE_ENV", "true") diff --git a/internal/config/profile.go b/internal/config/profile.go index 3fab2373..5acf9f25 100644 --- a/internal/config/profile.go +++ b/internal/config/profile.go @@ -7,6 +7,7 @@ package config import ( "cmp" + "errors" "fmt" "net/url" "os" @@ -251,6 +252,16 @@ func (config *Config) CurrentProfile() (*Profile, error) { return &profile, nil } +// OptionalProfile is like CurrentProfile, but returns a nil profile instead of +// ErrNotSetup when no profile has been set up. +func (config *Config) OptionalProfile() (*Profile, error) { + profile, err := config.CurrentProfile() + if errors.Is(err, ErrNotSetup) { + return nil, nil + } + return profile, err +} + func (config *Config) CurrentProfileName() string { if b, _ := strconv.ParseBool(os.Getenv("UNIKRAFT_PROFILE_ENV")); b { return EnvProfile diff --git a/internal/images/images.go b/internal/images/images.go index 4a17dc95..663e2d09 100644 --- a/internal/images/images.go +++ b/internal/images/images.go @@ -69,7 +69,7 @@ func resolverOptionsFor(ctx context.Context, opts ...AccessorOpt) (docker.Resolv opt(&o) } - profile, err := config.FromContextOrDefault(ctx).CurrentProfile() + profile, err := config.FromContextOrDefault(ctx).OptionalProfile() if err != nil { return docker.ResolverOptions{}, err } diff --git a/internal/multimetro/control.go b/internal/multimetro/control.go index a21bd3e6..72fe3273 100644 --- a/internal/multimetro/control.go +++ b/internal/multimetro/control.go @@ -7,7 +7,6 @@ package multimetro import ( "context" - "errors" "unikraft.com/cli/internal/config" "unikraft.com/cli/internal/httpclient" @@ -15,8 +14,8 @@ import ( ) func NewControlClient(ctx context.Context, opts ...controlplane.ClientOption) (controlplane.Client, error) { - profile, err := config.G(ctx).CurrentProfile() - if err != nil && !errors.Is(err, config.ErrNotSetup) { + profile, err := config.G(ctx).OptionalProfile() + if err != nil { return nil, err } return NewControlClientFromProfile(profile, opts...) From 36808a2c01db0b28548e6381b50e753d3dbcf624 Mon Sep 17 00:00:00 2001 From: Justin Chadwell Date: Mon, 2 Nov 2026 17:14:03 +0000 Subject: [PATCH 13/35] feat(cmd): Link metros to the console A metro's console page has no metro segment before the name, unlike the other resources, so linkToConsole now takes the path segments directly. Signed-off-by: Justin Chadwell --- internal/cmd/instance_templates.go | 2 +- internal/cmd/instances.go | 2 +- internal/cmd/metros.go | 2 ++ internal/cmd/services.go | 2 +- internal/cmd/util.go | 24 +++++++++--------------- internal/cmd/util_test.go | 1 + internal/cmd/volumes.go | 2 +- 7 files changed, 16 insertions(+), 19 deletions(-) diff --git a/internal/cmd/instance_templates.go b/internal/cmd/instance_templates.go index 93158968..7302f77c 100644 --- a/internal/cmd/instance_templates.go +++ b/internal/cmd/instance_templates.go @@ -111,7 +111,7 @@ func (i InstanceTemplate) Fields(ctx context.Context) ([]resource.Field, error) if err != nil { return nil, err } - linkToConsole(ctx, result, i.Profile, "instances/templates", i.Metro, i.Name) + linkToConsole(ctx, result, i.Profile, "instances", "templates", string(i.Metro), i.Name) return result, nil } diff --git a/internal/cmd/instances.go b/internal/cmd/instances.go index bd85d931..fe88c1ba 100644 --- a/internal/cmd/instances.go +++ b/internal/cmd/instances.go @@ -746,7 +746,7 @@ func (i Instance) Fields(ctx context.Context) ([]resource.Field, error) { if err != nil { return nil, err } - linkToConsole(ctx, result, i.Profile, "instances", i.Metro, i.Name) + linkToConsole(ctx, result, i.Profile, "instances", string(i.Metro), i.Name) return result, nil } diff --git a/internal/cmd/metros.go b/internal/cmd/metros.go index bb79d56c..dbf4a5be 100644 --- a/internal/cmd/metros.go +++ b/internal/cmd/metros.go @@ -59,6 +59,8 @@ func (i Metro) Fields(ctx context.Context) ([]resource.Field, error) { if err != nil { return nil, err } + profile, _ := config.G(ctx).CurrentProfile() + linkToConsole(ctx, fields, profile, "metros", i.Name) baseClient := httpclient.GetClient(ptr.ZeroIfNil(i.Insecure)) diff --git a/internal/cmd/services.go b/internal/cmd/services.go index 057c96b1..925d670f 100644 --- a/internal/cmd/services.go +++ b/internal/cmd/services.go @@ -212,7 +212,7 @@ func (s ServiceGroup) Fields(ctx context.Context) ([]resource.Field, error) { return nil, err } profile, _ := config.G(ctx).CurrentProfile() - linkToConsole(ctx, result, profile, "services", s.Metro, s.Name) + linkToConsole(ctx, result, profile, "services", string(s.Metro), s.Name) return result, nil } diff --git a/internal/cmd/util.go b/internal/cmd/util.go index 9bbabd4b..b933a8bc 100644 --- a/internal/cmd/util.go +++ b/internal/cmd/util.go @@ -9,6 +9,8 @@ import ( "context" "fmt" "maps" + "slices" + "strings" "unikraft.com/cloud/sdk/platform/group" "unikraft.com/x/log" @@ -31,31 +33,23 @@ func defaultMetro(ctx context.Context, metro string) string { return profile.GetDefaultMetro() } -// linkToConsole links the name field to the console page of the resource. +// linkToConsole links the name field to the console page at path. // It does nothing when profile is not an organization on Unikraft Cloud. -func linkToConsole(ctx context.Context, fields []resource.Field, profile *config.Profile, section string, metro LinkName[Metro], name string) { +func linkToConsole(ctx context.Context, fields []resource.Field, profile *config.Profile, path ...string) { if profile == nil || profile.ControlPlane == "" { log.G(ctx).Trace(). - Str("section", section). - Str("name", name). + Strs("path", path). Msg("skipping console link: profile is not on Unikraft Cloud") return } - if name == "" || profile.Organization == "" { + if slices.Contains(path, "") || profile.Organization == "" { log.G(ctx).Trace(). - Str("section", section). - Str("name", name). + Strs("path", path). Str("organization", profile.Organization). - Msg("skipping console link: name or organization is missing") + Msg("skipping console link: path or organization is missing") return } - link := fmt.Sprintf( - "https://console.unikraft.cloud/org/%s/%s/%s/%s", - profile.Organization, - section, - metro, - name, - ) + link := "https://console.unikraft.cloud/org/" + profile.Organization + "/" + strings.Join(path, "/") for _, field := range resource.GetFieldRefByPathString(fields, "name") { field.Hyperlink = link } diff --git a/internal/cmd/util_test.go b/internal/cmd/util_test.go index fab79f48..aab0e0f9 100644 --- a/internal/cmd/util_test.go +++ b/internal/cmd/util_test.go @@ -108,6 +108,7 @@ func TestResourcesLinkToTheConsole(t *testing.T) { {InstanceTemplate{Metro: "fra", Name: "base", Profile: &profile}, "https://console.unikraft.cloud/org/acme/instances/templates/fra/base"}, {Volume{Metro: "fra", Name: "data"}, "https://console.unikraft.cloud/org/acme/volumes/fra/data"}, {ServiceGroup{Metro: "fra", Name: "api"}, "https://console.unikraft.cloud/org/acme/services/fra/api"}, + {Metro{Name: "fra"}, "https://console.unikraft.cloud/org/acme/metros/fra"}, } { fields, err := tc.resource.Fields(ctx) require.NoError(t, err) diff --git a/internal/cmd/volumes.go b/internal/cmd/volumes.go index cc4d489e..e78792d0 100644 --- a/internal/cmd/volumes.go +++ b/internal/cmd/volumes.go @@ -249,7 +249,7 @@ func (i Volume) Fields(ctx context.Context) ([]resource.Field, error) { return nil, err } profile, _ := config.G(ctx).CurrentProfile() - linkToConsole(ctx, result, profile, "volumes", i.Metro, i.Name) + linkToConsole(ctx, result, profile, "volumes", string(i.Metro), i.Name) return result, nil } From 35e9d18fb8d28124f881892b0b6e2b6190916bf5 Mon Sep 17 00:00:00 2001 From: Justin Chadwell Date: Tue, 3 Nov 2026 10:51:35 +0000 Subject: [PATCH 14/35] chore: Drop the remote Taskfiles experiment Remote Taskfiles are enabled by default since Task v3.53.1. The Task pinned in the Makefile gets bumped to v3.54.0 along with it, since older versions still need the experiment. Signed-off-by: Justin Chadwell --- .github/workflows/build.yaml | 2 -- .github/workflows/check-static.yaml | 2 -- .github/workflows/test.yaml | 4 ---- .taskrc.yml | 3 --- AGENTS.md | 2 +- CONTRIBUTING.md | 8 +------- Makefile | 4 +--- 7 files changed, 3 insertions(+), 22 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 00fa0b9c..1f55e721 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -80,8 +80,6 @@ jobs: - name: Build shell: bash - env: - TASK_X_REMOTE_TASKFILES: 1 run: | VERSION=pr-${{ github.event.number }} GOOS=${{ matrix.GOOS }} GOARCH=${{ matrix.GOARCH }} task --yes ${{ matrix.task || 'cli' }} diff --git a/.github/workflows/check-static.yaml b/.github/workflows/check-static.yaml index fa09821b..fa97e4b5 100644 --- a/.github/workflows/check-static.yaml +++ b/.github/workflows/check-static.yaml @@ -23,8 +23,6 @@ permissions: jobs: go: runs-on: ubuntu-latest - env: - TASK_X_REMOTE_TASKFILES: 1 steps: - uses: actions/checkout@v7 diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index c6e25ca2..5b0935e4 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -23,8 +23,6 @@ jobs: matrix: os: [ubuntu-latest, windows-latest] runs-on: ${{ matrix.os }} - env: - TASK_X_REMOTE_TASKFILES: 1 defaults: run: shell: bash @@ -86,7 +84,6 @@ jobs: matrix: target: [STAGING, STABLE] env: - TASK_X_REMOTE_TASKFILES: 1 UNIKRAFT_SERVER_STAGING: staging UNIKRAFT_SERVER_STABLE: stable UNIKRAFT_SERVER_PROD: prod @@ -176,7 +173,6 @@ jobs: runs-on: ${{ matrix.runner }} needs: integration env: - TASK_X_REMOTE_TASKFILES: 1 UNIKRAFT_SERVER_PROD: prod defaults: run: diff --git a/.taskrc.yml b/.taskrc.yml index 42633bb3..dc000618 100644 --- a/.taskrc.yml +++ b/.taskrc.yml @@ -1,6 +1,3 @@ -experiments: - REMOTE_TASKFILES: 1 - remote: insecure: false offline: false diff --git a/AGENTS.md b/AGENTS.md index c158216d..5a12d890 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ This repo contains the Go-based Unikraft CLI. Use this file for contributor-faci ## Development practices -- Use `task` for builds and workflows; enable Remote Taskfiles with `TASK_X_REMOTE_TASKFILES=1`. +- Use `task` for builds and workflows. - Build the CLI with `task cli`; the binary is placed in `dist/`, and called `unikraft`. - Build documentation with `task docs`; outputs include Markdown docs in `dist/docs/` and man pages in `dist/man/`. - Quality gates: `task lint`, `task test`. Run these after making changes to ensure code quality. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3914a447..533a52fc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,13 +4,7 @@ - [Go](https://golang.org/dl/) 1.25+ - Git -- [Task](https://taskfile.dev/) (or GNU Make, which wraps Task transparently) - -Enable Remote Taskfiles: - -```sh -export TASK_X_REMOTE_TASKFILES=1 -``` +- [Task](https://taskfile.dev/) v3.53.1+ (or GNU Make, which wraps Task transparently) ## Building diff --git a/Makefile b/Makefile index 74562af2..f764dd9d 100644 --- a/Makefile +++ b/Makefile @@ -12,7 +12,7 @@ Q ?= @ GO ?= go ifeq ($(shell command -v task >/dev/null 2>&1 && echo yes),) -TASK ?= $(GO) run -v github.com/go-task/task/v3/cmd/task@v3.48.0 --yes +TASK ?= $(GO) run -v github.com/go-task/task/v3/cmd/task@v3.54.0 --yes else TASK ?= task --yes endif @@ -20,8 +20,6 @@ ifeq ($(Q),) TASK := $(TASK) --verbose endif -export TASK_X_REMOTE_TASKFILES=1 - .DEFAULT_GOAL := help .PHONY: help From 77004758b9ab1b7b8dbb6632994a0c87754f7c7b Mon Sep 17 00:00:00 2001 From: Justin Chadwell Date: Tue, 3 Nov 2026 11:00:47 +0000 Subject: [PATCH 15/35] docs(cmd): Drop the vCPU limit note from active instances It's a platform quirk the SDK doesn't document, and it isn't something a user can act on. Signed-off-by: Justin Chadwell --- internal/cmd/metros.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cmd/metros.go b/internal/cmd/metros.go index c1cee42c..3215de7e 100644 --- a/internal/cmd/metros.go +++ b/internal/cmd/metros.go @@ -267,7 +267,7 @@ func (Metro) Examples() map[cmd.CmdType][]kingkong.Example { type metroQuotas struct { Instances struct { - Active types.Usage[int64] `field:",long,embed" help:"Instances not in the stopped state. The platform reports the vCPU limit as its limit."` + Active types.Usage[int64] `field:",long,embed" help:"Instances not in the stopped state."` Total types.Usage[int64] `field:",long,embed" help:"All instances."` } `field:",long" help:"Instance counts."` Vcpus struct { From b57c922f0954fdfacd122dbfdb657e717ee04ed1 Mon Sep 17 00:00:00 2001 From: Dragos Gheorghioiu Date: Fri, 23 Oct 2026 17:54:08 +0300 Subject: [PATCH 16/35] refactor(model): Rename resources to models The framework that models API objects was called resource, the same word as the API objects themselves and as MCP resources, which made code and reviews around the upcoming MCP server ambiguous. Move internal/resource to internal/model and rename its Resource identifiers to Model. User-facing names, help and output are kept. Signed-off-by: Dragos Gheorghioiu --- AGENTS.md | 8 +- CONTRIBUTING.md | 4 +- cmd/unikraft/integration/helpers_test.go | 4 +- internal/cmd/any.go | 76 +-- internal/cmd/build_native.go | 6 +- internal/cmd/build_remote.go | 4 +- internal/cmd/certificates.go | 42 +- internal/cmd/config.go | 26 +- internal/cmd/images.go | 64 +-- internal/cmd/instance_checkpoints.go | 68 +-- internal/cmd/instance_templates.go | 44 +- internal/cmd/instances.go | 86 ++-- internal/cmd/instances_tui.go | 4 +- internal/cmd/link.go | 16 +- internal/cmd/marshal_test.go | 16 +- internal/cmd/metros.go | 44 +- internal/cmd/output_test.go | 38 +- internal/cmd/profile.go | 28 +- internal/cmd/root.go | 30 +- internal/cmd/run.go | 8 +- internal/cmd/sandbox.go | 16 +- internal/cmd/sandbox_plugin.go | 12 +- internal/cmd/services.go | 50 +- internal/cmd/shell.go | 14 +- internal/cmd/tui_native.go | 16 +- internal/cmd/unavailable_js.go | 8 +- internal/cmd/util.go | 20 +- internal/cmd/util_test.go | 8 +- internal/cmd/volume_templates.go | 44 +- internal/cmd/volumes.go | 62 +-- internal/cmd/volumes_import_native.go | 4 +- internal/integration/env.go | 6 +- .../cmd/LICENSE.fragment.txt | 0 internal/{resource => model}/cmd/cmd.go | 210 ++++---- internal/{resource => model}/cmd/cmd_test.go | 474 +++++++++--------- internal/{resource => model}/cmd/complete.go | 12 +- .../{resource => model}/cmd/complete_args.go | 0 internal/{resource => model}/cmd/diff.go | 10 +- internal/{resource => model}/cmd/examples.go | 2 +- internal/model/cmd/fields.go | 86 ++++ internal/{resource => model}/cmd/filter.go | 18 +- internal/{resource => model}/cmd/flags.go | 12 +- .../{resource => model}/cmd/flags_test.go | 30 +- internal/{resource => model}/cmd/patches.go | 2 +- internal/{resource => model}/cmd/printers.go | 76 +-- internal/model/cmd/resolve.go | 114 +++++ internal/{resource => model}/cmd/sort.go | 26 +- internal/{resource => model}/ctx.go | 2 +- .../{resource/resource.go => model/model.go} | 44 +- .../model_helpers.go} | 2 +- .../resource_path.go => model/model_path.go} | 2 +- internal/{resource => model}/partition.go | 130 ++--- .../patch/editor_exec_js.go | 0 .../patch/editor_exec_native.go | 0 .../patch/editor_exec_test.go | 0 .../{resource => model}/patch/editor_other.go | 0 .../patch/editor_windows.go | 0 internal/{resource => model}/patch/filter.go | 42 +- internal/{resource => model}/patch/merge.go | 26 +- .../{resource => model}/patch/merge_test.go | 24 +- internal/{resource => model}/patch/patch.go | 38 +- internal/{resource => model}/patch/visual.go | 40 +- .../{resource => model}/patch/visual_test.go | 256 +++++----- internal/{resource => model}/registry.go | 22 +- internal/{resource => model}/struct.go | 2 +- internal/{resource => model}/struct_test.go | 2 +- .../resource.go => model/testing/model.go} | 160 +++--- .../{resource => model}/testing/store_seed.go | 4 +- internal/{resource => model}/tui/actions.go | 0 internal/{resource => model}/tui/helpers.go | 0 .../{resource => model}/tui/panel_detail.go | 32 +- .../{resource => model}/tui/panel_home.go | 16 +- .../{resource => model}/tui/panel_list.go | 34 +- .../{resource => model}/tui/panel_reader.go | 0 internal/{resource => model}/tui/subpanels.go | 0 .../tui/testdata/TestTUIBasic | 0 .../tui/testdata/TestTUIDetailWithSubpanel | 0 .../tui/testdata/TestTUIHomePanel | 0 .../tui/testdata/TestTUIWidePanels | 0 internal/{resource => model}/tui/tui_test.go | 52 +- internal/{resource => model}/value/compare.go | 0 internal/{resource => model}/value/format.go | 0 .../{resource => model}/value/format_test.go | 0 internal/{resource => model}/value/parse.go | 0 .../{resource => model}/value/parse_test.go | 0 internal/multimetro/filter.go | 4 +- internal/resource/cmd/fields.go | 86 ---- internal/resource/cmd/resolve.go | 114 ----- internal/types/image.go | 6 +- internal/types/quota.go | 2 +- pkg/shellbuiltins/shellbuiltins.go | 2 +- pkg/shellbuiltins/shellbuiltins_test.go | 2 +- tools/gendocs/man.go | 26 +- tools/gendocs/mdx.go | 12 +- 94 files changed, 1566 insertions(+), 1566 deletions(-) rename internal/{resource => model}/cmd/LICENSE.fragment.txt (100%) rename internal/{resource => model}/cmd/cmd.go (74%) rename internal/{resource => model}/cmd/cmd_test.go (81%) rename internal/{resource => model}/cmd/complete.go (89%) rename internal/{resource => model}/cmd/complete_args.go (100%) rename internal/{resource => model}/cmd/diff.go (89%) rename internal/{resource => model}/cmd/examples.go (94%) create mode 100644 internal/model/cmd/fields.go rename internal/{resource => model}/cmd/filter.go (91%) rename internal/{resource => model}/cmd/flags.go (94%) rename internal/{resource => model}/cmd/flags_test.go (90%) rename internal/{resource => model}/cmd/patches.go (98%) rename internal/{resource => model}/cmd/printers.go (85%) create mode 100644 internal/model/cmd/resolve.go rename internal/{resource => model}/cmd/sort.go (80%) rename internal/{resource => model}/ctx.go (97%) rename internal/{resource/resource.go => model/model.go} (90%) rename internal/{resource/resource_helpers.go => model/model_helpers.go} (99%) rename internal/{resource/resource_path.go => model/model_path.go} (99%) rename internal/{resource => model}/partition.go (67%) rename internal/{resource => model}/patch/editor_exec_js.go (100%) rename internal/{resource => model}/patch/editor_exec_native.go (100%) rename internal/{resource => model}/patch/editor_exec_test.go (100%) rename internal/{resource => model}/patch/editor_other.go (100%) rename internal/{resource => model}/patch/editor_windows.go (100%) rename internal/{resource => model}/patch/filter.go (52%) rename internal/{resource => model}/patch/merge.go (75%) rename internal/{resource => model}/patch/merge_test.go (79%) rename internal/{resource => model}/patch/patch.go (87%) rename internal/{resource => model}/patch/visual.go (88%) rename internal/{resource => model}/patch/visual_test.go (83%) rename internal/{resource => model}/registry.go (57%) rename internal/{resource => model}/struct.go (99%) rename internal/{resource => model}/struct_test.go (99%) rename internal/{resource/testing/resource.go => model/testing/model.go} (61%) rename internal/{resource => model}/testing/store_seed.go (93%) rename internal/{resource => model}/tui/actions.go (100%) rename internal/{resource => model}/tui/helpers.go (100%) rename internal/{resource => model}/tui/panel_detail.go (90%) rename internal/{resource => model}/tui/panel_home.go (92%) rename internal/{resource => model}/tui/panel_list.go (84%) rename internal/{resource => model}/tui/panel_reader.go (100%) rename internal/{resource => model}/tui/subpanels.go (100%) rename internal/{resource => model}/tui/testdata/TestTUIBasic (100%) rename internal/{resource => model}/tui/testdata/TestTUIDetailWithSubpanel (100%) rename internal/{resource => model}/tui/testdata/TestTUIHomePanel (100%) rename internal/{resource => model}/tui/testdata/TestTUIWidePanels (100%) rename internal/{resource => model}/tui/tui_test.go (79%) rename internal/{resource => model}/value/compare.go (100%) rename internal/{resource => model}/value/format.go (100%) rename internal/{resource => model}/value/format_test.go (100%) rename internal/{resource => model}/value/parse.go (100%) rename internal/{resource => model}/value/parse_test.go (100%) delete mode 100644 internal/resource/cmd/fields.go delete mode 100644 internal/resource/cmd/resolve.go diff --git a/AGENTS.md b/AGENTS.md index 5a12d890..1f3821cc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,10 +17,10 @@ This repo contains the Go-based Unikraft CLI. Use this file for contributor-faci ## Architecture overview - Single CLI binary with a command tree that routes to internal services. -- Core `Resource` interface models API objects with `Type`, `Key`, `Fields`, and `Raw`, and is extended by get/list/create/edit/delete behaviors. -- Resource data is expressed as a tree of `Field` nodes: each node has a name, optional value, nested subfields, and an `Elem` template for homogeneous arrays; patch metadata and verbosity drive editing and output. -- Resource utilities live alongside the resource layer (inside `internal/`): struct↔field conversion, field cloning/merging/deduping, map/slice encoding, path-based filtering/selection, and patch helpers for edits. -- Core logic lives under `internal/` and is organized by domain: configuration/profile handling, API client communication, resource abstractions, output/logging utilities, multi-region support, and shared helpers. +- Core `Model` interface (`internal/model`) models API objects with `Type`, `Key`, `Fields`, and `Raw`, and is extended by get/list/create/edit/delete behaviors. +- Model data is expressed as a tree of `Field` nodes: each node has a name, optional value, nested subfields, and an `Elem` template for homogeneous arrays; patch metadata and verbosity drive editing and output. +- Model utilities live alongside the model layer (inside `internal/model`): struct↔field conversion, field cloning/merging/deduping, map/slice encoding, path-based filtering/selection, and patch helpers for edits. +- Core logic lives under `internal/` and is organized by domain: configuration/profile handling, API client communication, model abstractions, output/logging utilities, multi-region support, and shared helpers. - Build artifacts and generated docs go to `dist/`; developer tooling/scripts live at the repo root and under `tools/`. ## Important dependencies diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 533a52fc..d359def5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -75,10 +75,10 @@ automatically determines the git tag — no manual tagging is needed. ## Architecture -The CLI follows a resource-oriented architecture: +The CLI follows a model-oriented architecture, where each API resource has a model: - **Commands** (`internal/cmd/`) — Kong-based command definitions with subcommand routing -- **Resources** (`internal/resource/`) — Unified interface for API objects with field introspection +- **Models** (`internal/model/`) — Unified interface for API objects with field introspection - **Multi-Metro** (`internal/multimetro/`) — Client abstraction for global infrastructure operations - **Configuration** (`internal/config/`) — Profile and credential management - **Telemetry** (`internal/telemetry/`) — Usage analytics (opt-out via `--no-telemetry`) diff --git a/cmd/unikraft/integration/helpers_test.go b/cmd/unikraft/integration/helpers_test.go index 78aebf01..19214fa7 100644 --- a/cmd/unikraft/integration/helpers_test.go +++ b/cmd/unikraft/integration/helpers_test.go @@ -23,7 +23,7 @@ import ( "unikraft.com/cli/internal/cmd" "unikraft.com/cli/internal/config" integ "unikraft.com/cli/internal/integration" - "unikraft.com/cli/internal/resource" + "unikraft.com/cli/internal/model" ) func TestMain(m *testing.M) { @@ -98,7 +98,7 @@ func runner(t *testing.T, online bool, servers []string) *integ.TestEnv { return } - partition, err := resource.LoadPartition(partitionPath, cmd.PartitionedResources...) + partition, err := model.LoadPartition(partitionPath, cmd.PartitionedResources...) require.NoError(t, err) require.NotNil(t, partition) diff --git a/internal/cmd/any.go b/internal/cmd/any.go index 33c4af09..eee10483 100644 --- a/internal/cmd/any.go +++ b/internal/cmd/any.go @@ -12,24 +12,24 @@ import ( "slices" "strings" - "unikraft.com/cli/internal/resource" - "unikraft.com/cli/internal/resource/cmd" + "unikraft.com/cli/internal/model" + "unikraft.com/cli/internal/model/cmd" wslices "unikraft.com/cli/internal/w/slices" "unikraft.com/x/joinerrgroup" ) type AnyResourceCmd struct { - cmd.ResourceCmd[AnyResource] - cmd.GettableResourceCmd[AnyResource] - cmd.ListableResourceCmd[AnyResource] + cmd.ModelCmd[AnyResource] + cmd.GettableModelCmd[AnyResource] + cmd.ListableModelCmd[AnyResource] - Create cmd.ResourceCreateCmd[AnyResource] `cmd:"" help:"Create a resource."` - Edit cmd.ResourceEditCmd[AnyResource] `cmd:"" help:"Edit a resource."` + Create cmd.ModelCreateCmd[AnyResource] `cmd:"" help:"Create a resource."` + Edit cmd.ModelEditCmd[AnyResource] `cmd:"" help:"Edit a resource."` - cmd.BulkDeletableResourceCmd[AnyResource] + cmd.BulkDeletableModelCmd[AnyResource] } -var resourceBackends = []resource.Resource{ +var resourceBackends = []model.Model{ Instance{}, InstanceTemplate{}, Volume{}, @@ -39,12 +39,12 @@ var resourceBackends = []resource.Resource{ } func backendIndexByType(typ string) int { - return slices.IndexFunc(resourceBackends, func(backend resource.Resource) bool { + return slices.IndexFunc(resourceBackends, func(backend model.Model) bool { return backend.Type().Name == typ }) } -func backendByType(typ string) (resource.Resource, bool) { +func backendByType(typ string) (model.Model, bool) { idx := backendIndexByType(typ) if idx < 0 { return nil, false @@ -52,7 +52,7 @@ func backendByType(typ string) (resource.Resource, bool) { return resourceBackends[idx], true } -func wrapAnyResource(res resource.Resource) AnyResource { +func wrapAnyResource(res model.Model) AnyResource { typ := res.Type().Name return AnyResource{ Type_: typ, @@ -70,14 +70,14 @@ type AnyResource struct { Key_ string `field:"key,short"` // The actual underlying resource, if populated - underlying resource.Resource + underlying model.Model } -func (a AnyResource) Type() resource.Type { +func (a AnyResource) Type() model.Type { if a.underlying != nil { return a.underlying.Type() } - return resource.Type{ + return model.Type{ Name: "resource", Names: "resources", } @@ -119,7 +119,7 @@ func parseAnyResourceKey(key string) (typ, underlyingKey string, hasType bool) { return k.typ, k.key, k.typ != "" } -func (a AnyResource) Key() resource.Key { +func (a AnyResource) Key() model.Key { if a.underlying != nil { return anyResourceKey{ typ: a.underlying.Type().Name, @@ -132,8 +132,8 @@ func (a AnyResource) Key() resource.Key { } } -func (a AnyResource) Fields(ctx context.Context) ([]resource.Field, error) { - fields, err := resource.FieldsFromStruct(a) +func (a AnyResource) Fields(ctx context.Context) ([]model.Field, error) { + fields, err := model.FieldsFromStruct(a) if err != nil { return nil, err } @@ -153,7 +153,7 @@ func (a AnyResource) Fields(ctx context.Context) ([]resource.Field, error) { return fields, nil } -func (a AnyResource) WithType(typ string) resource.Resource { +func (a AnyResource) WithType(typ string) model.Model { a.Type_ = typ return a } @@ -165,7 +165,7 @@ func (a AnyResource) Raw() any { return nil } -func (a AnyResource) Get(ctx context.Context, keys []string) ([]resource.Resource, error) { +func (a AnyResource) Get(ctx context.Context, keys []string) ([]model.Model, error) { if len(keys) == 0 { return nil, fmt.Errorf("no keys provided") } @@ -187,25 +187,25 @@ func (a AnyResource) Get(ctx context.Context, keys []string) ([]resource.Resourc return nil, fmt.Errorf("unknown resource type: %s", k.typ) } backend := resourceBackends[idx] - if _, ok := backend.(resource.GettableResource); !ok { + if _, ok := backend.(model.GettableModel); !ok { return nil, fmt.Errorf("resource type %s does not support Get", k.typ) } keysByBackend[idx] = append(keysByBackend[idx], k.key) } - perBackend := make([][]resource.Resource, len(resourceBackends)) + perBackend := make([][]model.Model, len(resourceBackends)) eg := joinerrgroup.Group{} for i, keyList := range keysByBackend { if len(keyList) == 0 { continue } - backend := resourceBackends[i].(resource.GettableResource) + backend := resourceBackends[i].(model.GettableModel) typ := backend.Type().Name eg.Go(func() error { resources, err := backend.Get(ctx, keyList) - wrapped := make([]resource.Resource, 0, len(resources)) + wrapped := make([]model.Model, 0, len(resources)) for _, res := range resources { wrapped = append(wrapped, wrapAnyResource(res)) } @@ -220,18 +220,18 @@ func (a AnyResource) Get(ctx context.Context, keys []string) ([]resource.Resourc return wslices.Flatten(perBackend), err } -func (a AnyResource) List(ctx context.Context) ([]resource.Resource, error) { - perBackend := make([][]resource.Resource, len(resourceBackends)) +func (a AnyResource) List(ctx context.Context) ([]model.Model, error) { + perBackend := make([][]model.Model, len(resourceBackends)) eg := joinerrgroup.Group{} for i, backend := range resourceBackends { - listable, ok := backend.(resource.ListableResource) + listable, ok := backend.(model.ListableModel) if !ok { continue } typ := backend.Type().Name eg.Go(func() error { resources, err := listable.List(ctx) - wrapped := make([]resource.Resource, 0, len(resources)) + wrapped := make([]model.Model, 0, len(resources)) for _, res := range resources { wrapped = append(wrapped, wrapAnyResource(res)) } @@ -246,7 +246,7 @@ func (a AnyResource) List(ctx context.Context) ([]resource.Resource, error) { return wslices.Flatten(perBackend), err } -func (a AnyResource) Edit(ctx context.Context, key string, fields []resource.Field) error { +func (a AnyResource) Edit(ctx context.Context, key string, fields []model.Field) error { typ, underlyingKey, hasType := parseAnyResourceKey(key) if !hasType { return fmt.Errorf("resource key %q must include resource type prefix", key) @@ -257,7 +257,7 @@ func (a AnyResource) Edit(ctx context.Context, key string, fields []resource.Fie return fmt.Errorf("unknown resource type: %s", typ) } - editable, ok := backend.(resource.EditableResource) + editable, ok := backend.(model.EditableModel) if !ok { return fmt.Errorf("resource type %s does not support Edit", typ) } @@ -268,8 +268,8 @@ func (a AnyResource) Edit(ctx context.Context, key string, fields []resource.Fie return nil } -func (a AnyResource) Create(ctx context.Context, fields []resource.Field) ([]resource.Resource, error) { - typeFields := resource.GetFieldByPath(fields, []string{"type"}) +func (a AnyResource) Create(ctx context.Context, fields []model.Field) ([]model.Model, error) { + typeFields := model.GetFieldByPath(fields, []string{"type"}) if len(typeFields) == 0 { return nil, fmt.Errorf("type field is required") } @@ -280,7 +280,7 @@ func (a AnyResource) Create(ctx context.Context, fields []resource.Field) ([]res return nil, fmt.Errorf("unknown resource type: %s", typ) } - creatable, ok := backend.(resource.CreatableResource) + creatable, ok := backend.(model.CreatableModel) if !ok { return nil, fmt.Errorf("resource type %s does not support Create", typ) } @@ -290,7 +290,7 @@ func (a AnyResource) Create(ctx context.Context, fields []resource.Field) ([]res return nil, fmt.Errorf("failed to create %s resource: %w", typ, err) } - wrapped := make([]resource.Resource, 0, len(results)) + wrapped := make([]model.Model, 0, len(results)) for _, res := range results { wrapped = append(wrapped, wrapAnyResource(res)) } @@ -298,9 +298,9 @@ func (a AnyResource) Create(ctx context.Context, fields []resource.Field) ([]res return wrapped, nil } -func (a AnyResource) underlyingFields(fields []resource.Field) []resource.Field { +func (a AnyResource) underlyingFields(fields []model.Field) []model.Field { filtered := slices.Clone(fields) - filtered = slices.DeleteFunc(filtered, func(field resource.Field) bool { + filtered = slices.DeleteFunc(filtered, func(field model.Field) bool { return field.Name == "type" || field.Name == "key" }) return filtered @@ -319,7 +319,7 @@ func (a AnyResource) Delete(ctx context.Context, keys []string) error { return fmt.Errorf("unknown resource type: %s", typ) } backend := resourceBackends[idx] - if _, ok := backend.(resource.DeletableResource); !ok { + if _, ok := backend.(model.DeletableModel); !ok { return fmt.Errorf("resource type %s does not support Delete", typ) } keysByBackend[idx] = append(keysByBackend[idx], underlyingKey) @@ -335,7 +335,7 @@ func (a AnyResource) Delete(ctx context.Context, keys []string) error { } backend := resourceBackends[i] typ := backend.Type().Name - deletable := backend.(resource.DeletableResource) + deletable := backend.(model.DeletableModel) if err := deletable.Delete(ctx, typeKeys); err != nil { errs = append(errs, fmt.Errorf("failed to delete %s resources: %w", typ, err)) diff --git a/internal/cmd/build_native.go b/internal/cmd/build_native.go index 19437a74..142755b5 100644 --- a/internal/cmd/build_native.go +++ b/internal/cmd/build_native.go @@ -28,7 +28,7 @@ import ( "unikraft.com/cli/internal/config" "unikraft.com/cli/internal/images" - "unikraft.com/cli/internal/resource" + "unikraft.com/cli/internal/model" ) // localBuildkit is the buildkitd that builds run on. @@ -91,7 +91,7 @@ func buildWithProgress(ctx context.Context, fn func(build.Builder) error) error return err } -func (c *ImageBuildCmd) Run(ctx context.Context, cfg *config.Config, partition *resource.Partition) error { +func (c *ImageBuildCmd) Run(ctx context.Context, cfg *config.Config, partition *model.Partition) error { if c.Remote != "" { return c.runRemote(ctx, cfg, partition) } @@ -189,6 +189,6 @@ func (c *ImageBuildCmd) Run(ctx context.Context, cfg *config.Config, partition * return nil } -func (c *BuildCmd) Run(ctx context.Context, cfg *config.Config, partition *resource.Partition) error { +func (c *BuildCmd) Run(ctx context.Context, cfg *config.Config, partition *model.Partition) error { return c.ImageBuildCmd.Run(ctx, cfg, partition) } diff --git a/internal/cmd/build_remote.go b/internal/cmd/build_remote.go index 3c88caf1..ddbea9e7 100644 --- a/internal/cmd/build_remote.go +++ b/internal/cmd/build_remote.go @@ -34,14 +34,14 @@ import ( "unikraft.com/x/log" "unikraft.com/cli/internal/config" - "unikraft.com/cli/internal/resource" + "unikraft.com/cli/internal/model" ) // 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 { +func (c *ImageBuildCmd) runRemote(ctx context.Context, cfg *config.Config, partition *model.Partition) error { secrets, err := buildflags.ParseSecretSpecs(c.Secret) if err != nil { return err diff --git a/internal/cmd/certificates.go b/internal/cmd/certificates.go index bb077b2e..0dde1822 100644 --- a/internal/cmd/certificates.go +++ b/internal/cmd/certificates.go @@ -18,20 +18,20 @@ import ( "unikraft.com/cli/internal/config" "unikraft.com/cli/internal/mirror" + "unikraft.com/cli/internal/model" + "unikraft.com/cli/internal/model/cmd" "unikraft.com/cli/internal/multimetro" - "unikraft.com/cli/internal/resource" - "unikraft.com/cli/internal/resource/cmd" "unikraft.com/cli/internal/types" ) type CertificatesCmd struct { - cmd.ResourceCmd[Certificate] - cmd.GettableResourceCmd[Certificate] - cmd.WaitableResourceCmd[Certificate] - cmd.ListableResourceCmd[Certificate] - cmd.BulkDeletableResourceCmd[Certificate] + cmd.ModelCmd[Certificate] + cmd.GettableModelCmd[Certificate] + cmd.WaitableModelCmd[Certificate] + cmd.ListableModelCmd[Certificate] + cmd.BulkDeletableModelCmd[Certificate] - Create cmd.ResourceCreateCmd[Certificate] `cmd:"" help:"Create a certificate."` + Create cmd.ModelCreateCmd[Certificate] `cmd:"" help:"Create a certificate."` } type Certificate struct { @@ -61,14 +61,14 @@ type Certificate struct { key multimetro.Key } -func (Certificate) Type() resource.Type { - return resource.Type{ +func (Certificate) Type() model.Type { + return model.Type{ Name: "certificate", Names: "certificates", } } -func (c Certificate) Key() resource.Key { +func (c Certificate) Key() model.Key { return c.key } @@ -76,23 +76,23 @@ func (c Certificate) Raw() any { return c.Certificate } -func (c Certificate) Fields(ctx context.Context) ([]resource.Field, error) { +func (c Certificate) Fields(ctx context.Context) ([]model.Field, error) { c.Metro = LinkName[Metro](defaultMetro(ctx, string(c.Metro))) - return resource.FieldsFromStruct(c) + return model.FieldsFromStruct(c) } -func (Certificate) List(ctx context.Context) ([]resource.Resource, error) { +func (Certificate) List(ctx context.Context) ([]model.Model, error) { g, err := multimetro.NewClient(ctx) if err != nil { return nil, err } - return group.CollectAllSlices(ctx, g, func(ctx context.Context, c multimetro.MetroClient) ([]resource.Resource, error) { + return group.CollectAllSlices(ctx, g, func(ctx context.Context, c multimetro.MetroClient) ([]model.Model, error) { log.G(ctx).Trace().Msg("listing certificates") resp, err := c.GetCertificates(ctx, nil, platform.GetCertificatesOpts{Details: new(true)}) if err != nil { return nil, err } - var results []resource.Resource + var results []model.Model var errs []error if resp == nil || resp.Data == nil { return nil, nil @@ -108,19 +108,19 @@ func (Certificate) List(ctx context.Context) ([]resource.Resource, error) { }) } -func (Certificate) Get(ctx context.Context, keys []string) ([]resource.Resource, error) { +func (Certificate) Get(ctx context.Context, keys []string) ([]model.Model, error) { g, err := multimetro.NewClient(ctx) if err != nil { return nil, err } - return group.CollectRefsSlices(ctx, g, multimetro.ParseKeys(keys).Refs(), func(ctx context.Context, c multimetro.MetroClient, refs group.Refs) ([]resource.Resource, group.Refs, error) { + return group.CollectRefsSlices(ctx, g, multimetro.ParseKeys(keys).Refs(), func(ctx context.Context, c multimetro.MetroClient, refs group.Refs) ([]model.Model, group.Refs, error) { log.G(ctx).Trace().Msg("getting certificates") resp, err := c.GetCertificates(ctx, refs.NameOrUUIDs(), platform.GetCertificatesOpts{Details: new(true)}) if err != nil && !platform.ErrorContainsOnly(err, platform.APIHTTPErrorNotFound) { return nil, nil, err } var found []group.Ref - var results []resource.Resource + var results []model.Model var errs []error if resp == nil || resp.Data == nil { return nil, nil, nil @@ -203,10 +203,10 @@ func (Certificate) Delete(ctx context.Context, keys []string) error { }) } -func (Certificate) Create(ctx context.Context, fields []resource.Field) ([]resource.Resource, error) { +func (Certificate) Create(ctx context.Context, fields []model.Field) ([]model.Model, error) { var req platform.CreateCertificateRequest var metro string - for key, field := range resource.IterFields(fields) { + for key, field := range model.IterFields(fields) { if field.Create.Set != nil { switch key.String() { case "name": diff --git a/internal/cmd/config.go b/internal/cmd/config.go index 15cbb39f..48ab4223 100644 --- a/internal/cmd/config.go +++ b/internal/cmd/config.go @@ -11,14 +11,14 @@ import ( "fmt" "unikraft.com/cli/internal/config" - "unikraft.com/cli/internal/resource" - "unikraft.com/cli/internal/resource/cmd" + "unikraft.com/cli/internal/model" + "unikraft.com/cli/internal/model/cmd" "unikraft.com/x/kingkong" ) type ConfigCmd struct { - cmd.ResourceCmd[Config] - cmd.GettableResourceCmd[Config] + cmd.ModelCmd[Config] + cmd.GettableModelCmd[Config] } // Config wraps config.Config to implement the resource interfaces. @@ -26,14 +26,14 @@ type Config struct { *config.Config } -func (Config) Type() resource.Type { - return resource.Type{ +func (Config) Type() model.Type { + return model.Type{ Name: "config", Names: "config", } } -func (c Config) Key() resource.Key { +func (c Config) Key() model.Key { return staticKey(c.Path) } @@ -41,23 +41,23 @@ func (c Config) Raw() any { return c.Config } -func (c Config) Fields(ctx context.Context) ([]resource.Field, error) { - return resource.FieldsFromStruct(c.Config) +func (c Config) Fields(ctx context.Context) ([]model.Field, error) { + return model.FieldsFromStruct(c.Config) } -func (c Config) Default(ctx context.Context) (resource.Resource, error) { +func (c Config) Default(ctx context.Context) (model.Model, error) { cfg := config.G(ctx) return Config{Config: cfg}, nil } -func (Config) Get(ctx context.Context, keys []string) ([]resource.Resource, error) { +func (Config) Get(ctx context.Context, keys []string) ([]model.Model, error) { if len(keys) == 0 { cfg := config.G(ctx) - return []resource.Resource{Config{Config: cfg}}, nil + return []model.Model{Config{Config: cfg}}, nil } var ( - results []resource.Resource + results []model.Model loadErr error ) for _, key := range keys { diff --git a/internal/cmd/images.go b/internal/cmd/images.go index dfa3a85e..836fcc07 100644 --- a/internal/cmd/images.go +++ b/internal/cmd/images.go @@ -29,14 +29,14 @@ import ( "unikraft.com/cli/internal/config" "unikraft.com/cli/internal/images" + "unikraft.com/cli/internal/model" + "unikraft.com/cli/internal/model/cmd" "unikraft.com/cli/internal/multimetro" - "unikraft.com/cli/internal/resource" - "unikraft.com/cli/internal/resource/cmd" "unikraft.com/cli/internal/types" ) type ImagesCmd struct { - cmd.ResourceCmd[ImageEntry] + cmd.ModelCmd[ImageEntry] Build ImageBuildCmd `cmd:"" help:"Build a Unikraft project into a container image."` Get ImagesGetCmd `cmd:"" help:"Inspect an image." aliases:"inspect,show"` @@ -81,14 +81,14 @@ type ImageFile struct { Size types.SizeBytes `field:",long" help:"Size of the image layer holding the file."` } -func (Image) Type() resource.Type { - return resource.Type{ +func (Image) Type() model.Type { + return model.Type{ Name: "image", Names: "images", } } -func (i Image) Key() resource.Key { +func (i Image) Key() model.Key { return staticKey(i.Ref.String()) } @@ -96,8 +96,8 @@ func (i Image) Raw() any { return i.Image.Descriptor } -func (i Image) Fields(ctx context.Context) ([]resource.Field, error) { - return resource.FieldsFromStruct(i) +func (i Image) Fields(ctx context.Context) ([]model.Field, error) { + return model.FieldsFromStruct(i) } func imageFileFrom(file imagespec.File) *ImageFile { @@ -131,13 +131,13 @@ func imageRomsFrom(files []imagespec.File) []ImageFile { return roms } -func (Image) Get(ctx context.Context, keys []string) ([]resource.Resource, error) { +func (Image) Get(ctx context.Context, keys []string) ([]model.Model, error) { access, err := images.Accessor(ctx) if err != nil { return nil, err } - perKey := make([][]resource.Resource, len(keys)) + perKey := make([][]model.Model, len(keys)) eg := joinerrgroup.Group{} for i, key := range keys { eg.Go(func() error { @@ -264,14 +264,14 @@ type ImageEntry struct { platformImage *platform.Image } -func (ImageEntry) Type() resource.Type { - return resource.Type{ +func (ImageEntry) Type() model.Type { + return model.Type{ Name: "image", Names: "images", } } -func (i ImageEntry) Key() resource.Key { +func (i ImageEntry) Key() model.Key { return staticKey(i.Ref.String()) } @@ -285,21 +285,21 @@ func (i ImageEntry) Raw() any { return nil } -func (i ImageEntry) Fields(ctx context.Context) ([]resource.Field, error) { - return resource.FieldsFromStruct(i) +func (i ImageEntry) Fields(ctx context.Context) ([]model.Field, error) { + return model.FieldsFromStruct(i) } func (ImageEntry) Examples() map[cmd.CmdType][]kingkong.Example { return Image{}.Examples() } -func (ImageEntry) List(ctx context.Context) ([]resource.Resource, error) { +func (ImageEntry) List(ctx context.Context) ([]model.Model, error) { client, err := multimetro.NewControlClient(ctx) if err != nil { return nil, err } - var controlplaneResults, platformResults []resource.Resource + var controlplaneResults, platformResults []model.Model eg, ctx := joinerrgroup.WithContext(ctx) eg.Go(func() error { @@ -364,7 +364,7 @@ func (ImageEntry) List(ctx context.Context) ([]resource.Resource, error) { return results, nil } -func listPlatformImages(ctx context.Context) ([]resource.Resource, error) { +func listPlatformImages(ctx context.Context) ([]model.Model, error) { g, err := multimetro.NewClient(ctx) if _, ok := errors.AsType[multimetro.ErrNoMetros](err); ok { log.G(ctx).Trace().Err(err).Msg("skipping image-store listing") @@ -375,7 +375,7 @@ func listPlatformImages(ctx context.Context) ([]resource.Resource, error) { } namespaces, _ := ctx.Value(imageNamespacesKey{}).([]string) - return group.CollectAllSlices(ctx, g, func(ctx context.Context, c multimetro.MetroClient) ([]resource.Resource, error) { + return group.CollectAllSlices(ctx, g, func(ctx context.Context, c multimetro.MetroClient) ([]model.Model, error) { log.G(ctx).Trace().Msg("listing images from image-store") resp, err := c.GetImageStore(ctx, nil, platform.GetImageStoreOpts{}) @@ -389,7 +389,7 @@ func listPlatformImages(ctx context.Context) ([]resource.Resource, error) { } } - var results []resource.Resource + var results []model.Model var errs []error if resp.Data != nil { for _, image := range resp.Data.Images { @@ -431,7 +431,7 @@ func (k imageKey) matches(entry ImageEntry) bool { return subject.Matches(k.ref) } -func (ImageEntry) Get(ctx context.Context, keys []string) ([]resource.Resource, error) { +func (ImageEntry) Get(ctx context.Context, keys []string) ([]model.Model, error) { client, err := multimetro.NewControlClient(ctx) if err != nil { return nil, err @@ -453,7 +453,7 @@ func (ImageEntry) Get(ctx context.Context, keys []string) ([]resource.Resource, } found := make(map[string]struct{}, len(normalizedKeys)) - var results []resource.Resource + var results []model.Model var errs []error if resp.Data != nil { for _, image := range resp.Data.Images { @@ -701,7 +701,7 @@ func (ImageEntry) loadFromPlatform(image platform.Image, metro *config.Metro) ([ } type ImagesGetCmd struct { - cmd.ResourceGetCmd[Image] + cmd.ModelGetCmd[Image] Insecure []string `help:"Allow insecure (HTTP/unverified TLS) connections to registries. Specify hostnames to restrict, or omit to apply to all." type:"optional"` } @@ -715,15 +715,15 @@ func (c *ImagesGetCmd) Run(ctx context.Context, stdio config.Stdio) error { } ctx = images.WithInsecureContext(ctx, opts...) } - return c.ResourceGetCmd.Run(ctx, stdio, nil) + return c.ModelGetCmd.Run(ctx, stdio, nil) } type ImagesDeleteCmd struct { - cmd.ResourceRemoveCmd[Image] + cmd.ModelRemoveCmd[Image] Insecure []string `help:"Allow insecure (HTTP/unverified TLS) connections to registries. Specify hostnames to restrict, or omit to apply to all." type:"optional"` } -func (c *ImagesDeleteCmd) Run(ctx context.Context, stdio config.Stdio, partition *resource.Partition) error { +func (c *ImagesDeleteCmd) Run(ctx context.Context, stdio config.Stdio, partition *model.Partition) error { if c.Insecure != nil { var opts []images.AccessorOpt if len(c.Insecure) > 0 { @@ -736,7 +736,7 @@ func (c *ImagesDeleteCmd) Run(ctx context.Context, stdio config.Stdio, partition // HACK: don't partition-filter the delete: image references may not match the // canonical form stored in the partition. The partition teardown handles cleanup // of images that weren't manually deleted. - return c.ResourceRemoveCmd.Run(ctx, stdio, nil) + return c.ModelRemoveCmd.Run(ctx, stdio, nil) } type ImagesCopyCmd struct { @@ -769,7 +769,7 @@ func (cmd ImagesCopyCmd) Examples() []kingkong.Example { } } -func (cmd ImagesCopyCmd) Run(ctx context.Context, partition *resource.Partition) error { +func (cmd ImagesCopyCmd) Run(ctx context.Context, partition *model.Partition) error { var opts []images.AccessorOpt if cmd.Insecure != nil { if len(cmd.Insecure) > 0 { @@ -822,18 +822,18 @@ func (cmd ImagesCopyCmd) Run(ctx context.Context, partition *resource.Partition) } type ImagesListCmd struct { - cmd.ResourceListCmd[ImageEntry] + cmd.ModelListCmd[ImageEntry] Namespace []string `help:"Only list images in this namespace."` } -func (c *ImagesListCmd) Run(ctx context.Context, stdio config.Stdio, partition *resource.Partition) error { - return c.ResourceListCmd.Run(context.WithValue(ctx, imageNamespacesKey{}, c.Namespace), stdio, partition) +func (c *ImagesListCmd) Run(ctx context.Context, stdio config.Stdio, partition *model.Partition) error { + return c.ModelListCmd.Run(context.WithValue(ctx, imageNamespacesKey{}, c.Namespace), stdio, partition) } // addImageToPartition registers an image reference with the partition so it gets // cleaned up during teardown. -func addImageToPartition(ctx context.Context, partition *resource.Partition, ref string) error { +func addImageToPartition(ctx context.Context, partition *model.Partition, ref string) error { parsed, err := images.ParseRef(ref) if err != nil { return fmt.Errorf("parsing image reference %q: %w", ref, err) diff --git a/internal/cmd/instance_checkpoints.go b/internal/cmd/instance_checkpoints.go index 0dfb35e3..52aa3cb2 100644 --- a/internal/cmd/instance_checkpoints.go +++ b/internal/cmd/instance_checkpoints.go @@ -21,22 +21,22 @@ import ( "unikraft.com/cli/internal/config" "unikraft.com/cli/internal/mirror" + "unikraft.com/cli/internal/model" + "unikraft.com/cli/internal/model/cmd" "unikraft.com/cli/internal/multimetro" - "unikraft.com/cli/internal/resource" - "unikraft.com/cli/internal/resource/cmd" "unikraft.com/cli/internal/types" ) type InstanceCheckpointsCmd struct { - cmd.ResourceCmd[InstanceCheckpoint] - cmd.GettableResourceCmd[InstanceCheckpoint] - cmd.ListableResourceCmd[InstanceCheckpoint] - cmd.WaitableResourceCmd[InstanceCheckpoint] - cmd.BulkDeletableResourceCmd[InstanceCheckpoint] - - Create cmd.ResourceCreateCmd[InstanceCheckpoint] `cmd:"" help:"Create a checkpoint from an instance."` - Edit cmd.ResourceEditCmd[InstanceCheckpoint] `cmd:"" help:"Edit an instance checkpoint."` - History InstanceCheckpointHistoryCmd `cmd:"" help:"Show the history of a checkpoint."` + cmd.ModelCmd[InstanceCheckpoint] + cmd.GettableModelCmd[InstanceCheckpoint] + cmd.ListableModelCmd[InstanceCheckpoint] + cmd.WaitableModelCmd[InstanceCheckpoint] + cmd.BulkDeletableModelCmd[InstanceCheckpoint] + + Create cmd.ModelCreateCmd[InstanceCheckpoint] `cmd:"" help:"Create a checkpoint from an instance."` + Edit cmd.ModelEditCmd[InstanceCheckpoint] `cmd:"" help:"Edit an instance checkpoint."` + History InstanceCheckpointHistoryCmd `cmd:"" help:"Show the history of a checkpoint."` } type InstanceCheckpoint struct { @@ -95,14 +95,14 @@ type InstanceCheckpoint struct { key multimetro.Key } -func (InstanceCheckpoint) Type() resource.Type { - return resource.Type{ +func (InstanceCheckpoint) Type() model.Type { + return model.Type{ Name: "instance-checkpoint", Names: "instance-checkpoints", } } -func (i InstanceCheckpoint) Key() resource.Key { +func (i InstanceCheckpoint) Key() model.Key { return i.key } @@ -110,11 +110,11 @@ func (i InstanceCheckpoint) Raw() any { return i.Instance } -func (i InstanceCheckpoint) Fields(ctx context.Context) ([]resource.Field, error) { - return resource.FieldsFromStruct(i) +func (i InstanceCheckpoint) Fields(ctx context.Context) ([]model.Field, error) { + return model.FieldsFromStruct(i) } -func (InstanceCheckpoint) List(ctx context.Context) ([]resource.Resource, error) { +func (InstanceCheckpoint) List(ctx context.Context) ([]model.Model, error) { g, err := multimetro.NewClient(ctx) if err != nil { return nil, err @@ -123,13 +123,13 @@ func (InstanceCheckpoint) List(ctx context.Context) ([]resource.Resource, error) if err != nil { return nil, err } - return group.CollectAllSlices(ctx, g, func(ctx context.Context, c multimetro.MetroClient) ([]resource.Resource, error) { + return group.CollectAllSlices(ctx, g, func(ctx context.Context, c multimetro.MetroClient) ([]model.Model, error) { log.G(ctx).Trace().Msg("listing instance checkpoints") resp, err := c.GetCheckpointInstances(ctx, nil, platform.GetCheckpointInstancesOpts{Details: new(true)}) if err != nil { return nil, err } - var results []resource.Resource + var results []model.Model var errs []error if resp == nil || resp.Data == nil { return nil, nil @@ -146,7 +146,7 @@ func (InstanceCheckpoint) List(ctx context.Context) ([]resource.Resource, error) }) } -func (InstanceCheckpoint) Get(ctx context.Context, keys []string) ([]resource.Resource, error) { +func (InstanceCheckpoint) Get(ctx context.Context, keys []string) ([]model.Model, error) { g, err := multimetro.NewClient(ctx) if err != nil { return nil, err @@ -155,14 +155,14 @@ func (InstanceCheckpoint) Get(ctx context.Context, keys []string) ([]resource.Re if err != nil { return nil, err } - return group.CollectRefsSlices(ctx, g, multimetro.ParseKeys(keys).Refs(), func(ctx context.Context, c multimetro.MetroClient, refs group.Refs) ([]resource.Resource, group.Refs, error) { + return group.CollectRefsSlices(ctx, g, multimetro.ParseKeys(keys).Refs(), func(ctx context.Context, c multimetro.MetroClient, refs group.Refs) ([]model.Model, group.Refs, error) { log.G(ctx).Trace().Msg("getting instance checkpoints") resp, err := c.GetCheckpointInstances(ctx, refs.NameOrUUIDs(), platform.GetCheckpointInstancesOpts{Details: new(true)}) if err != nil && !platform.ErrorContainsOnly(err, platform.APIHTTPErrorNotFound) { return nil, nil, err } var found []group.Ref - var results []resource.Resource + var results []model.Model var errs []error if resp == nil || resp.Data == nil { return nil, nil, nil @@ -243,7 +243,7 @@ func (InstanceCheckpoint) Delete(ctx context.Context, keys []string) error { }) } -func (InstanceCheckpoint) Edit(ctx context.Context, key string, fields []resource.Field) error { +func (InstanceCheckpoint) Edit(ctx context.Context, key string, fields []model.Field) error { parsedKeys := multimetro.ParseKeys([]string{key}) patches, err := patchRequests(fields, instanceCheckpointPatchSpec) if err != nil { @@ -311,10 +311,10 @@ func instanceCheckpointPatchSpec(path string, op patchOp, value any) (platform.M } } -func (InstanceCheckpoint) Create(ctx context.Context, fields []resource.Field) ([]resource.Resource, error) { +func (InstanceCheckpoint) Create(ctx context.Context, fields []model.Field) ([]model.Model, error) { var instance string var autokill Autokill - for key, field := range resource.IterFields(fields) { + for key, field := range model.IterFields(fields) { if field.Create == nil || field.Create.Set == nil { continue } @@ -489,14 +489,14 @@ type InstanceHistoryEntry struct { key multimetro.Key } -func (InstanceHistoryEntry) Type() resource.Type { - return resource.Type{ +func (InstanceHistoryEntry) Type() model.Type { + return model.Type{ Name: "instance-history-entry", Names: "instance-history-entries", } } -func (i InstanceHistoryEntry) Key() resource.Key { +func (i InstanceHistoryEntry) Key() model.Key { return i.key } @@ -504,14 +504,14 @@ func (i InstanceHistoryEntry) Raw() any { return i } -func (i InstanceHistoryEntry) Fields(ctx context.Context) ([]resource.Field, error) { - return resource.FieldsFromStruct(i) +func (i InstanceHistoryEntry) Fields(ctx context.Context) ([]model.Field, error) { + return model.FieldsFromStruct(i) } // getInstanceHistory fetches checkpoint history entries for the given targets // using the provided fetch function (either the instance or checkpoint history // endpoint) and flattens them into InstanceHistoryEntry rows. -func getInstanceHistory(ctx context.Context, targets []string, fetch func(ctx context.Context, c multimetro.MetroClient, ids []platform.NameOrUUID) (*platform.Response[platform.GetCheckpointHistoryResponseData], error)) ([]resource.Resource, error) { +func getInstanceHistory(ctx context.Context, targets []string, fetch func(ctx context.Context, c multimetro.MetroClient, ids []platform.NameOrUUID) (*platform.Response[platform.GetCheckpointHistoryResponseData], error)) ([]model.Model, error) { keys := multimetro.ParseKeys(targets) g, err := multimetro.NewClient(ctx) @@ -519,14 +519,14 @@ func getInstanceHistory(ctx context.Context, targets []string, fetch func(ctx co return nil, err } - return group.CollectRefsSlices(ctx, g, keys.Refs(), func(ctx context.Context, c multimetro.MetroClient, refs group.Refs) ([]resource.Resource, group.Refs, error) { + return group.CollectRefsSlices(ctx, g, keys.Refs(), func(ctx context.Context, c multimetro.MetroClient, refs group.Refs) ([]model.Model, group.Refs, error) { log.G(ctx).Trace().Msg("getting checkpoint history") resp, err := fetch(ctx, c, refs.NameOrUUIDs()) if err != nil && !platform.ErrorContainsOnly(err, platform.APIHTTPErrorNotFound) { return nil, nil, err } var found []group.Ref - var results []resource.Resource + var results []model.Model if resp == nil || resp.Data == nil { return nil, nil, nil } @@ -556,7 +556,7 @@ func getInstanceHistory(ctx context.Context, targets []string, fetch func(ctx co } // renderInstanceHistory prints history entries through the printer framework. -func renderInstanceHistory(ctx context.Context, out io.Writer, opts cmd.FormatOpts, entries []resource.Resource) error { +func renderInstanceHistory(ctx context.Context, out io.Writer, opts cmd.FormatOpts, entries []model.Model) error { return opts.Output. WithDefault(cmd.PrinterTypeTable). Print(ctx, out, opts.Field, InstanceHistoryEntry{}, entries...) diff --git a/internal/cmd/instance_templates.go b/internal/cmd/instance_templates.go index a90499e8..643780da 100644 --- a/internal/cmd/instance_templates.go +++ b/internal/cmd/instance_templates.go @@ -20,20 +20,20 @@ import ( "unikraft.com/cli/internal/config" "unikraft.com/cli/internal/mirror" + "unikraft.com/cli/internal/model" + "unikraft.com/cli/internal/model/cmd" "unikraft.com/cli/internal/multimetro" - "unikraft.com/cli/internal/resource" - "unikraft.com/cli/internal/resource/cmd" "unikraft.com/cli/internal/types" ) type InstanceTemplatesCmd struct { - cmd.ResourceCmd[InstanceTemplate] - cmd.GettableResourceCmd[InstanceTemplate] - cmd.ListableResourceCmd[InstanceTemplate] - cmd.BulkDeletableResourceCmd[InstanceTemplate] + cmd.ModelCmd[InstanceTemplate] + cmd.GettableModelCmd[InstanceTemplate] + cmd.ListableModelCmd[InstanceTemplate] + cmd.BulkDeletableModelCmd[InstanceTemplate] - Create cmd.ResourceCreateCmd[InstanceTemplate] `cmd:"" help:"Create an instance template."` - Edit cmd.ResourceEditCmd[InstanceTemplate] `cmd:"" help:"Edit an instance template."` + Create cmd.ModelCreateCmd[InstanceTemplate] `cmd:"" help:"Create an instance template."` + Edit cmd.ModelEditCmd[InstanceTemplate] `cmd:"" help:"Edit an instance template."` } type InstanceTemplate struct { @@ -91,14 +91,14 @@ type InstanceTemplate struct { key multimetro.Key } -func (InstanceTemplate) Type() resource.Type { - return resource.Type{ +func (InstanceTemplate) Type() model.Type { + return model.Type{ Name: "instance-template", Names: "instance-templates", } } -func (i InstanceTemplate) Key() resource.Key { +func (i InstanceTemplate) Key() model.Key { return i.key } @@ -106,8 +106,8 @@ func (i InstanceTemplate) Raw() any { return i.Instance } -func (i InstanceTemplate) Fields(ctx context.Context) ([]resource.Field, error) { - result, err := resource.FieldsFromStruct(i) +func (i InstanceTemplate) Fields(ctx context.Context) ([]model.Field, error) { + result, err := model.FieldsFromStruct(i) if err != nil { return nil, err } @@ -115,7 +115,7 @@ func (i InstanceTemplate) Fields(ctx context.Context) ([]resource.Field, error) return result, nil } -func (InstanceTemplate) List(ctx context.Context) ([]resource.Resource, error) { +func (InstanceTemplate) List(ctx context.Context) ([]model.Model, error) { g, err := multimetro.NewClient(ctx) if err != nil { return nil, err @@ -124,13 +124,13 @@ func (InstanceTemplate) List(ctx context.Context) ([]resource.Resource, error) { if err != nil { return nil, err } - return group.CollectAllSlices(ctx, g, func(ctx context.Context, c multimetro.MetroClient) ([]resource.Resource, error) { + return group.CollectAllSlices(ctx, g, func(ctx context.Context, c multimetro.MetroClient) ([]model.Model, error) { log.G(ctx).Trace().Msg("listing instance templates") resp, err := c.GetTemplateInstances(ctx, nil, platform.GetTemplateInstancesOpts{Details: new(true)}) if err != nil { return nil, err } - var results []resource.Resource + var results []model.Model var errs []error if resp == nil || resp.Data == nil { return nil, nil @@ -147,7 +147,7 @@ func (InstanceTemplate) List(ctx context.Context) ([]resource.Resource, error) { }) } -func (InstanceTemplate) Get(ctx context.Context, keys []string) ([]resource.Resource, error) { +func (InstanceTemplate) Get(ctx context.Context, keys []string) ([]model.Model, error) { g, err := multimetro.NewClient(ctx) if err != nil { return nil, err @@ -156,14 +156,14 @@ func (InstanceTemplate) Get(ctx context.Context, keys []string) ([]resource.Reso if err != nil { return nil, err } - return group.CollectRefsSlices(ctx, g, multimetro.ParseKeys(keys).Refs(), func(ctx context.Context, c multimetro.MetroClient, refs group.Refs) ([]resource.Resource, group.Refs, error) { + return group.CollectRefsSlices(ctx, g, multimetro.ParseKeys(keys).Refs(), func(ctx context.Context, c multimetro.MetroClient, refs group.Refs) ([]model.Model, group.Refs, error) { log.G(ctx).Trace().Msg("getting instance templates") resp, err := c.GetTemplateInstances(ctx, refs.NameOrUUIDs(), platform.GetTemplateInstancesOpts{Details: new(true)}) if err != nil && !platform.ErrorContainsOnly(err, platform.APIHTTPErrorNotFound) { return nil, nil, err } var found []group.Ref - var results []resource.Resource + var results []model.Model var errs []error if resp == nil || resp.Data == nil { return nil, nil, nil @@ -244,7 +244,7 @@ func (InstanceTemplate) Delete(ctx context.Context, keys []string) error { }) } -func (InstanceTemplate) Edit(ctx context.Context, key string, fields []resource.Field) error { +func (InstanceTemplate) Edit(ctx context.Context, key string, fields []model.Field) error { parsedKeys := multimetro.ParseKeys([]string{key}) patches, err := patchRequests(fields, instanceTemplatePatchSpec) if err != nil { @@ -312,10 +312,10 @@ func instanceTemplatePatchSpec(path string, op patchOp, value any) (platform.Mut } } -func (InstanceTemplate) Create(ctx context.Context, fields []resource.Field) ([]resource.Resource, error) { +func (InstanceTemplate) Create(ctx context.Context, fields []model.Field) ([]model.Model, error) { var instance string var autokill Autokill - for key, field := range resource.IterFields(fields) { + for key, field := range model.IterFields(fields) { if field.Create == nil || field.Create.Set == nil { continue } diff --git a/internal/cmd/instances.go b/internal/cmd/instances.go index 1fb9be1e..d1f239fb 100644 --- a/internal/cmd/instances.go +++ b/internal/cmd/instances.go @@ -34,27 +34,27 @@ import ( "unikraft.com/cli/internal/config" "unikraft.com/cli/internal/mirror" + "unikraft.com/cli/internal/model" + "unikraft.com/cli/internal/model/cmd" + "unikraft.com/cli/internal/model/value" "unikraft.com/cli/internal/multimetro" "unikraft.com/cli/internal/muxreader" - "unikraft.com/cli/internal/resource" - "unikraft.com/cli/internal/resource/cmd" - "unikraft.com/cli/internal/resource/value" "unikraft.com/cli/internal/types" wshell "unikraft.com/cli/internal/w/shell" ) type InstancesCmd struct { - cmd.ResourceCmd[Instance] - cmd.GettableResourceCmd[Instance] - cmd.WaitableResourceCmd[Instance] - cmd.ListableResourceCmd[Instance] - cmd.BulkDeletableResourceCmd[Instance] - - Create InstanceCreateCmd `cmd:"" aliases:"new" help:"Create an instance."` - Run InstanceRunCmd `cmd:"" help:"Create an instance and optionally follow its logs."` - Edit cmd.ResourceEditCmd[Instance] `cmd:"" help:"Edit an instance."` - Template InstanceTemplatesCmd `cmd:"" group:"cmd-templates" help:"Manage instance templates." aliases:"templates" set:"name=instance-template" set:"names=instance-templates"` - Checkpoint InstanceCheckpointsCmd `cmd:"" group:"cmd-checkpoints" help:"Manage instance checkpoints." aliases:"checkpoints" set:"name=instance-checkpoint" set:"names=instance-checkpoints"` + cmd.ModelCmd[Instance] + cmd.GettableModelCmd[Instance] + cmd.WaitableModelCmd[Instance] + cmd.ListableModelCmd[Instance] + cmd.BulkDeletableModelCmd[Instance] + + Create InstanceCreateCmd `cmd:"" aliases:"new" help:"Create an instance."` + Run InstanceRunCmd `cmd:"" help:"Create an instance and optionally follow its logs."` + Edit cmd.ModelEditCmd[Instance] `cmd:"" help:"Edit an instance."` + Template InstanceTemplatesCmd `cmd:"" group:"cmd-templates" help:"Manage instance templates." aliases:"templates" set:"name=instance-template" set:"names=instance-templates"` + Checkpoint InstanceCheckpointsCmd `cmd:"" group:"cmd-checkpoints" help:"Manage instance checkpoints." aliases:"checkpoints" set:"name=instance-checkpoint" set:"names=instance-checkpoints"` Logs InstancesLogsCmd `cmd:"" help:"Fetch and display instance logs."` Start InstancesStartCmd `cmd:"" help:"Start one or more instances."` @@ -74,17 +74,17 @@ type InstancesCmd struct { // InstanceCreateCmd extends the generic resource create command with flags // that do not map onto a single schema field. type InstanceCreateCmd struct { - cmd.ResourceCreateCmd[Instance] + cmd.ModelCreateCmd[Instance] DeleteOnStop bool `group:"flag-create" name:"rm" help:"Automatically delete the instance when it stops."` } -func (c *InstanceCreateCmd) Run(ctx context.Context, stdio config.Stdio, partition *resource.Partition) error { - _, err := c.RunResources(ctx, stdio, partition) +func (c *InstanceCreateCmd) Run(ctx context.Context, stdio config.Stdio, partition *model.Partition) error { + _, err := c.RunModels(ctx, stdio, partition) return err } -func (c *InstanceCreateCmd) RunResources(ctx context.Context, stdio config.Stdio, partition *resource.Partition) ([]resource.Resource, error) { +func (c *InstanceCreateCmd) RunModels(ctx context.Context, stdio config.Stdio, partition *model.Partition) ([]model.Model, error) { if c.DeleteOnStop { c.Set = append(c.Set, map[string]string{"features": string(platform.InstanceFeatureDeleteOnStop)}) } @@ -97,7 +97,7 @@ func (c *InstanceCreateCmd) RunResources(ctx context.Context, stdio config.Stdio return nil, fmt.Errorf("--domain cannot be used with --service") } } - return c.ResourceCreateCmd.RunResources(ctx, stdio, partition) + return c.ModelCreateCmd.RunModels(ctx, stdio, partition) } type Instance struct { @@ -725,14 +725,14 @@ func (a *InstanceArgs) UnmarshalJSON(data []byte) error { return a.UnmarshalText([]byte(s)) } -func (Instance) Type() resource.Type { - return resource.Type{ +func (Instance) Type() model.Type { + return model.Type{ Name: "instance", Names: "instances", } } -func (i Instance) Key() resource.Key { +func (i Instance) Key() model.Key { return i.key } @@ -740,9 +740,9 @@ func (i Instance) Raw() any { return i.Instance } -func (i Instance) Fields(ctx context.Context) ([]resource.Field, error) { +func (i Instance) Fields(ctx context.Context) ([]model.Field, error) { i.Metro = LinkName[Metro](defaultMetro(ctx, string(i.Metro))) - result, err := resource.FieldsFromStruct(i) + result, err := model.FieldsFromStruct(i) if err != nil { return nil, err } @@ -750,7 +750,7 @@ func (i Instance) Fields(ctx context.Context) ([]resource.Field, error) { return result, nil } -func (Instance) List(ctx context.Context) ([]resource.Resource, error) { +func (Instance) List(ctx context.Context) ([]model.Model, error) { g, err := multimetro.NewClient(ctx) if err != nil { return nil, err @@ -759,13 +759,13 @@ func (Instance) List(ctx context.Context) ([]resource.Resource, error) { if err != nil { return nil, err } - return group.CollectAllSlices(ctx, g, func(ctx context.Context, c multimetro.MetroClient) ([]resource.Resource, error) { + return group.CollectAllSlices(ctx, g, func(ctx context.Context, c multimetro.MetroClient) ([]model.Model, error) { log.G(ctx).Trace().Msg("listing instances") resp, err := c.GetInstances(ctx, nil, platform.GetInstancesOpts{Details: new(true)}) if err != nil { return nil, err } - var results []resource.Resource + var results []model.Model var errs []error if resp == nil || resp.Data == nil { return nil, nil @@ -781,7 +781,7 @@ func (Instance) List(ctx context.Context) ([]resource.Resource, error) { }) } -func (Instance) Get(ctx context.Context, keys []string) ([]resource.Resource, error) { +func (Instance) Get(ctx context.Context, keys []string) ([]model.Model, error) { g, err := multimetro.NewClient(ctx) if err != nil { return nil, err @@ -790,14 +790,14 @@ func (Instance) Get(ctx context.Context, keys []string) ([]resource.Resource, er if err != nil { return nil, err } - return group.CollectRefsSlices(ctx, g, multimetro.ParseKeys(keys).Refs(), func(ctx context.Context, c multimetro.MetroClient, refs group.Refs) ([]resource.Resource, group.Refs, error) { + return group.CollectRefsSlices(ctx, g, multimetro.ParseKeys(keys).Refs(), func(ctx context.Context, c multimetro.MetroClient, refs group.Refs) ([]model.Model, group.Refs, error) { log.G(ctx).Trace().Msg("getting instances") resp, err := c.GetInstances(ctx, refs.NameOrUUIDs(), platform.GetInstancesOpts{Details: new(true)}) if err != nil && !platform.ErrorContainsOnly(err, platform.APIHTTPErrorNotFound) { return nil, nil, err } var found []group.Ref - var results []resource.Resource + var results []model.Model var errs []error if resp == nil || resp.Data == nil { return nil, nil, nil @@ -904,13 +904,13 @@ func (Instance) Delete(ctx context.Context, keys []string) error { }) } -func (Instance) Edit(ctx context.Context, key string, fields []resource.Field) error { +func (Instance) Edit(ctx context.Context, key string, fields []model.Field) error { parsedKeys := multimetro.ParseKeys([]string{key}) // Check if there's a state transition requested - if so, we need current state. var targetState types.InstanceState var hasStateChange bool - if stateFields := resource.GetFieldByPath(fields, resource.FieldPath{"state"}); len(stateFields) > 0 { + if stateFields := model.GetFieldByPath(fields, model.FieldPath{"state"}); len(stateFields) > 0 { field := stateFields[0] if field.Edit != nil && field.Edit.Set != nil { targetState = field.Edit.Set.(types.InstanceState) @@ -930,7 +930,7 @@ func (Instance) Edit(ctx context.Context, key string, fields []resource.Field) e // Extract volume add/del ops before the standard patch pipeline. var volumeAddOps []*InstanceVolume var volumeDelOps []string - for path, field := range resource.IterFields(fields) { + for path, field := range model.IterFields(fields) { if path.String() != "volumes" || field.Edit == nil { continue } @@ -1188,13 +1188,13 @@ func instancePatchSpec(path string, op patchOp, value any) (platform.MutableInst } } -func (Instance) Create(ctx context.Context, fields []resource.Field) ([]resource.Resource, error) { +func (Instance) Create(ctx context.Context, fields []model.Field) ([]model.Model, error) { var req platform.CreateInstanceRequest var metro string var imageURL string var pullPolicy *platform.PullPolicy var autostart bool - for key, field := range resource.IterFields(fields) { + for key, field := range model.IterFields(fields) { if field.Create == nil || field.Create.Set == nil { continue } @@ -1805,11 +1805,11 @@ func (c *InstancesStartCmd) Run(ctx context.Context, stdio config.Stdio) error { for _, k := range started { keySet[k.Canonical()] = struct{}{} } - before = slices.DeleteFunc(slices.Clone(before), func(r resource.Resource) bool { + before = slices.DeleteFunc(slices.Clone(before), func(r model.Model) bool { _, ok := keySet[r.Key().Canonical()] return !ok }) - updated = slices.DeleteFunc(slices.Clone(updated), func(r resource.Resource) bool { + updated = slices.DeleteFunc(slices.Clone(updated), func(r model.Model) bool { _, ok := keySet[r.Key().Canonical()] return !ok }) @@ -1890,11 +1890,11 @@ func (c *InstancesStopCmd) Run(ctx context.Context, stdio config.Stdio) error { for _, k := range stopped { keySet[k.Canonical()] = struct{}{} } - before = slices.DeleteFunc(slices.Clone(before), func(r resource.Resource) bool { + before = slices.DeleteFunc(slices.Clone(before), func(r model.Model) bool { _, ok := keySet[r.Key().Canonical()] return !ok }) - updated = slices.DeleteFunc(slices.Clone(updated), func(r resource.Resource) bool { + updated = slices.DeleteFunc(slices.Clone(updated), func(r model.Model) bool { _, ok := keySet[r.Key().Canonical()] return !ok }) @@ -1985,11 +1985,11 @@ func (c *InstancesRestartCmd) Run(ctx context.Context, stdio config.Stdio) error for _, k := range started { keySet[k.Canonical()] = struct{}{} } - before = slices.DeleteFunc(slices.Clone(before), func(r resource.Resource) bool { + before = slices.DeleteFunc(slices.Clone(before), func(r model.Model) bool { _, ok := keySet[r.Key().Canonical()] return !ok }) - updated = slices.DeleteFunc(slices.Clone(updated), func(r resource.Resource) bool { + updated = slices.DeleteFunc(slices.Clone(updated), func(r model.Model) bool { _, ok := keySet[r.Key().Canonical()] return !ok }) @@ -2180,11 +2180,11 @@ func (c *InstancesSuspendCmd) Run(ctx context.Context, stdio config.Stdio) error for _, k := range suspended { keySet[k.Canonical()] = struct{}{} } - before = slices.DeleteFunc(slices.Clone(before), func(r resource.Resource) bool { + before = slices.DeleteFunc(slices.Clone(before), func(r model.Model) bool { _, ok := keySet[r.Key().Canonical()] return !ok }) - updated = slices.DeleteFunc(slices.Clone(updated), func(r resource.Resource) bool { + updated = slices.DeleteFunc(slices.Clone(updated), func(r model.Model) bool { _, ok := keySet[r.Key().Canonical()] return !ok }) diff --git a/internal/cmd/instances_tui.go b/internal/cmd/instances_tui.go index e788285d..bf95e4b1 100644 --- a/internal/cmd/instances_tui.go +++ b/internal/cmd/instances_tui.go @@ -18,8 +18,8 @@ import ( "unikraft.com/cloud/sdk/platform/group" "unikraft.com/cloud/sdk/platform/logs" + modeltui "unikraft.com/cli/internal/model/tui" "unikraft.com/cli/internal/multimetro" - resourcetui "unikraft.com/cli/internal/resource/tui" ) // Subpanels returns subpanels for instance detail views. @@ -41,7 +41,7 @@ func (Instance) Subpanels(ctx context.Context, key string) []tea.Model { } return []tea.Model{ - resourcetui.NewReaderPanelFunc(ctx, "Logs", readerFunc, true), + modeltui.NewReaderPanelFunc(ctx, "Logs", readerFunc, true), } } diff --git a/internal/cmd/link.go b/internal/cmd/link.go index de9de959..a9ebe376 100644 --- a/internal/cmd/link.go +++ b/internal/cmd/link.go @@ -11,12 +11,12 @@ import ( "unikraft.com/cloud/sdk/platform/group" + "unikraft.com/cli/internal/model" "unikraft.com/cli/internal/multimetro" - "unikraft.com/cli/internal/resource" ) // Link models a resource reference using group.Ref-compatible fields. -type Link[T resource.Resource] struct { +type Link[T model.Model] struct { Metro string `name:"metro" json:"metro,omitempty" mirror:"metro" field:"-"` Name string `name:"name" json:"name,omitempty" mirror:"name" field:",long" help:"Name of the linked resource."` UUID string `name:"uuid" json:"uuid,omitempty" mirror:"uuid" field:",long" help:"Unique identifier of the linked resource."` @@ -30,7 +30,7 @@ func (l Link[T]) Ref() group.Ref { } } -func (l Link[T]) Link() (string, resource.Key, bool) { +func (l Link[T]) Link() (string, model.Key, bool) { if l.Name == "" && l.UUID == "" { return "", nil, false } @@ -48,7 +48,7 @@ func (l Link[T]) Link() (string, resource.Key, bool) { // always embedded anonymously, so encoding.TextMarshaler would promote onto // every embedder - including through a `type X Y` alias, which strips // explicit methods but not promoted ones. Embed TextLink to opt in. -func FormatLink[T resource.Resource](l Link[T]) ([]byte, error) { +func FormatLink[T model.Model](l Link[T]) ([]byte, error) { k := multimetro.Key{ Metro: l.Metro, Name: l.Name, @@ -59,7 +59,7 @@ func FormatLink[T resource.Resource](l Link[T]) ([]byte, error) { // ParseLink parses text as a multimetro key. Not a (*Link).UnmarshalText // method, for the same reason as FormatLink. -func ParseLink[T resource.Resource](text []byte) (Link[T], error) { +func ParseLink[T model.Model](text []byte) (Link[T], error) { // multimetro.ParseKey takes its input as-is, so surrounding whitespace // would end up inside the name and fail every lookup. key := multimetro.ParseKey(strings.TrimSpace(string(text))) @@ -74,7 +74,7 @@ func ParseLink[T resource.Resource](text []byte) (Link[T], error) { // fields holding nothing but a reference (`certificate=my-cert`). Embedding // Link alone grants no marshaling, so a type needing its own can define it // without being silently overridden. -type TextLink[T resource.Resource] struct { +type TextLink[T model.Model] struct { Link[T] } @@ -111,9 +111,9 @@ func (l *TextLink[T]) UnmarshalJSON(data []byte) error { } // LinkName models a simple name-only link. -type LinkName[T resource.Resource] string +type LinkName[T model.Model] string -func (l LinkName[T]) Link() (string, resource.Key, bool) { +func (l LinkName[T]) Link() (string, model.Key, bool) { if l == "" { return "", nil, false } diff --git a/internal/cmd/marshal_test.go b/internal/cmd/marshal_test.go index a86ea5dd..f405427f 100644 --- a/internal/cmd/marshal_test.go +++ b/internal/cmd/marshal_test.go @@ -16,9 +16,9 @@ import ( "unikraft.com/cloud/sdk/platform" "unikraft.com/cli/internal/cmd" - "unikraft.com/cli/internal/resource" - "unikraft.com/cli/internal/resource/patch" - "unikraft.com/cli/internal/resource/value" + "unikraft.com/cli/internal/model" + "unikraft.com/cli/internal/model/patch" + "unikraft.com/cli/internal/model/value" "unikraft.com/cli/internal/types" ) @@ -280,7 +280,7 @@ func TestEditPatches(t *testing.T) { tests := []struct { name string - res resource.Resource + res model.Model spec patch.PatchSpec want map[string]string wantErr string @@ -379,7 +379,7 @@ func TestEditPatches(t *testing.T) { require.NoError(t, err) got := map[string]string{} - for path, f := range resource.IterFields(patched) { + for path, f := range model.IterFields(patched) { if f.Edit == nil { continue } @@ -420,7 +420,7 @@ func TestLinkCollection(t *testing.T) { }{Name: "member-inst"}) for _, tt := range []struct { - res resource.Resource + res model.Model path string want string }{ @@ -433,7 +433,7 @@ func TestLinkCollection(t *testing.T) { require.NoError(t, err) var got []string - for path, f := range resource.IterFields(fields) { + for path, f := range model.IterFields(fields) { if path.String() != tt.path { continue } @@ -495,7 +495,7 @@ service: `, string(shown)) got := map[string]string{} - for path, f := range resource.IterFields(out) { + for path, f := range model.IterFields(out) { if f.Create == nil || f.Create.Set == nil { continue } diff --git a/internal/cmd/metros.go b/internal/cmd/metros.go index 8c626d28..90ace410 100644 --- a/internal/cmd/metros.go +++ b/internal/cmd/metros.go @@ -14,8 +14,8 @@ import ( "unikraft.com/cli/internal/config" "unikraft.com/cli/internal/httpclient" - "unikraft.com/cli/internal/resource" - "unikraft.com/cli/internal/resource/cmd" + "unikraft.com/cli/internal/model" + "unikraft.com/cli/internal/model/cmd" "unikraft.com/cli/internal/types" wsync "unikraft.com/cli/internal/w/sync" "unikraft.com/cloud/sdk/platform" @@ -25,9 +25,9 @@ import ( ) type MetrosCmd struct { - cmd.ResourceCmd[Metro] - cmd.GettableResourceCmd[Metro] - cmd.ListableResourceCmd[Metro] + cmd.ModelCmd[Metro] + cmd.GettableModelCmd[Metro] + cmd.ListableModelCmd[Metro] } type Metro struct { @@ -39,14 +39,14 @@ type Metro struct { url string } -func (Metro) Type() resource.Type { - return resource.Type{ +func (Metro) Type() model.Type { + return model.Type{ Name: "metro", Names: "metros", } } -func (i Metro) Key() resource.Key { +func (i Metro) Key() model.Key { return staticKey(i.Name) } @@ -54,8 +54,8 @@ func (i Metro) Raw() any { return i } -func (i Metro) Fields(ctx context.Context) ([]resource.Field, error) { - fields, err := resource.FieldsFromStruct(i) +func (i Metro) Fields(ctx context.Context) ([]model.Field, error) { + fields, err := model.FieldsFromStruct(i) if err != nil { return nil, err } @@ -69,14 +69,14 @@ func (i Metro) Fields(ctx context.Context) ([]resource.Field, error) { endpoint: i.url, name: i.Name, } - quotaFields, err := resource.FieldsFromStruct(quotas) + quotaFields, err := model.FieldsFromStruct(quotas) if err != nil { return nil, err } - fields = append(fields, resource.Field{ + fields = append(fields, model.Field{ Name: "quotas", Help: "Usage and limits of your quota in the metro.", - Verbosity: resource.FieldVerbosityLong, + Verbosity: model.FieldVerbosityLong, Subfields: quotaFields, }) @@ -189,27 +189,27 @@ func (i Metro) Fields(ctx context.Context) ([]resource.Field, error) { }) // Add a grouped "status" field with lazy-computed subfields - fields = append(fields, resource.Field{ + fields = append(fields, model.Field{ Name: "status", Help: "Reachability of the metro endpoint.", - Verbosity: resource.FieldVerbosityLong, - Subfields: []resource.Field{ + Verbosity: model.FieldVerbosityLong, + Subfields: []model.Field{ { Name: "ip", Help: "IP addresses the endpoint resolves to.", - Verbosity: resource.FieldVerbosityLong, + Verbosity: model.FieldVerbosityLong, ValueCallback: ip, }, { Name: "ping", Help: "TCP connection latency to the endpoint.", - Verbosity: resource.FieldVerbosityLong, + Verbosity: model.FieldVerbosityLong, ValueCallback: ping, }, { Name: "online", Help: "Whether the endpoint answers an HTTP request without a server error: online or offline.", - Verbosity: resource.FieldVerbosityLong, + Verbosity: model.FieldVerbosityLong, ValueCallback: online, }, }, @@ -218,13 +218,13 @@ func (i Metro) Fields(ctx context.Context) ([]resource.Field, error) { return fields, nil } -func (Metro) List(ctx context.Context) ([]resource.Resource, error) { +func (Metro) List(ctx context.Context) ([]model.Model, error) { profile, err := config.G(ctx).CurrentProfile() if err != nil { return nil, err } - var results []resource.Resource + var results []model.Model for _, metro := range profile.Metros { result := Metro{ Name: metro.Name, @@ -238,7 +238,7 @@ func (Metro) List(ctx context.Context) ([]resource.Resource, error) { return results, nil } -func (Metro) Get(ctx context.Context, keys []string) ([]resource.Resource, error) { +func (Metro) Get(ctx context.Context, keys []string) ([]model.Model, error) { return getFromListable(ctx, Metro{}, keys) } diff --git a/internal/cmd/output_test.go b/internal/cmd/output_test.go index 85606062..e36ed4a7 100644 --- a/internal/cmd/output_test.go +++ b/internal/cmd/output_test.go @@ -20,8 +20,8 @@ import ( "unikraft.com/cloud/sdk/platform" "unikraft.com/cli/internal/cmd" - "unikraft.com/cli/internal/resource" - resourcecmd "unikraft.com/cli/internal/resource/cmd" + "unikraft.com/cli/internal/model" + modelcmd "unikraft.com/cli/internal/model/cmd" "unikraft.com/cli/internal/types" ) @@ -37,25 +37,25 @@ func sectionHeader(name string) string { // dumpResource renders a resource using kv, kv-all, table, and debug printers // and returns the concatenated output. -func dumpResource(t *testing.T, res resource.Resource) string { +func dumpResource(t *testing.T, res model.Model) string { t.Helper() var output strings.Builder type section struct { name string - format resourcecmd.PrinterType + format modelcmd.PrinterType fieldSpecs []string } sections := []section{ - {"kv", resourcecmd.PrinterTypeKeyValue, nil}, - {"kv-all", resourcecmd.PrinterTypeKeyValue, []string{"all"}}, - {"table", resourcecmd.PrinterTypeTable, nil}, - {"debug", resourcecmd.PrintTypeDebug, nil}, + {"kv", modelcmd.PrinterTypeKeyValue, nil}, + {"kv-all", modelcmd.PrinterTypeKeyValue, []string{"all"}}, + {"table", modelcmd.PrinterTypeTable, nil}, + {"debug", modelcmd.PrintTypeDebug, nil}, } for _, s := range sections { - printer := resourcecmd.Printer{Type: s.format} + printer := modelcmd.Printer{Type: s.format} var buf bytes.Buffer err := printer.Print(t.Context(), &buf, s.fieldSpecs, res, res) @@ -168,7 +168,7 @@ func instancesOutputTests(t *testing.T) { sample.Stop.ExitCode = &exitCode require.NoError(t, sample.Image.UnmarshalText([]byte("nginx:latest"))) - integ.Gild[resource.Resource](t, dumpResource, sample) + integ.Gild[model.Model](t, dumpResource, sample) } // Every other sample carries a registry reference, so nothing exercises how an @@ -186,7 +186,7 @@ func instancesHTTPOCIOutputTests(t *testing.T) { "https+oci://cdn.example.com/me/app/@sha256:"+ "43d3d758e6fba7d4734ac142cfdbf8aa786fcbbfd828017eecaadc5140a4b190"))) - integ.Gild[resource.Resource](t, dumpResource, sample) + integ.Gild[model.Model](t, dumpResource, sample) } func instanceTemplatesOutputTests(t *testing.T) { @@ -209,7 +209,7 @@ func instanceTemplatesOutputTests(t *testing.T) { sample.Timing.TemplateTime = new(types.DurationUS(56000)) require.NoError(t, sample.Image.UnmarshalText([]byte("nginx:latest"))) - integ.Gild[resource.Resource](t, dumpResource, sample) + integ.Gild[model.Model](t, dumpResource, sample) } func instanceCheckpointsOutputTests(t *testing.T) { @@ -240,7 +240,7 @@ func instanceCheckpointsOutputTests(t *testing.T) { sample.Timing.CheckpointTime = new(types.DurationUS(78000)) require.NoError(t, sample.Image.UnmarshalText([]byte("nginx:latest"))) - integ.Gild[resource.Resource](t, dumpResource, sample) + integ.Gild[model.Model](t, dumpResource, sample) } func instanceHistoryOutputTests(t *testing.T) { @@ -253,7 +253,7 @@ func instanceHistoryOutputTests(t *testing.T) { // ("never") instead of a wall-clock-relative string. } - integ.Gild[resource.Resource](t, dumpResource, sample) + integ.Gild[model.Model](t, dumpResource, sample) } func volumesOutputTests(t *testing.T) { @@ -275,7 +275,7 @@ func volumesOutputTests(t *testing.T) { AccessMode: new(types.AccessMode(platform.VolumeAccessModeRwo)), } - integ.Gild[resource.Resource](t, dumpResource, sample) + integ.Gild[model.Model](t, dumpResource, sample) } func volumeTemplatesOutputTests(t *testing.T) { @@ -290,7 +290,7 @@ func volumeTemplatesOutputTests(t *testing.T) { Persistent: true, } - integ.Gild[resource.Resource](t, dumpResource, sample) + integ.Gild[model.Model](t, dumpResource, sample) } func servicesOutputTests(t *testing.T) { @@ -319,7 +319,7 @@ func servicesOutputTests(t *testing.T) { sample.Limits.Soft = 5 sample.Limits.Hard = 50 - integ.Gild[resource.Resource](t, dumpResource, sample) + integ.Gild[model.Model](t, dumpResource, sample) } func certificatesOutputTests(t *testing.T) { @@ -334,7 +334,7 @@ func certificatesOutputTests(t *testing.T) { State: types.CertificateState(platform.CertificateStateValid), } - integ.Gild[resource.Resource](t, dumpResource, sample) + integ.Gild[model.Model](t, dumpResource, sample) } func imagesOutputTests(t *testing.T) { @@ -373,5 +373,5 @@ func imagesOutputTests(t *testing.T) { require.NoError(t, sample.Ref.UnmarshalText([]byte("nginx:latest"))) require.NoError(t, sample.Config.Platform.UnmarshalText([]byte("linux/amd64"))) - integ.Gild[resource.Resource](t, dumpResource, sample) + integ.Gild[model.Model](t, dumpResource, sample) } diff --git a/internal/cmd/profile.go b/internal/cmd/profile.go index 62b02cd7..e31a3598 100644 --- a/internal/cmd/profile.go +++ b/internal/cmd/profile.go @@ -15,17 +15,17 @@ import ( "github.com/MakeNowJust/heredoc" jujuerrors "github.com/juju/errors" "unikraft.com/cli/internal/config" - "unikraft.com/cli/internal/resource" - "unikraft.com/cli/internal/resource/cmd" + "unikraft.com/cli/internal/model" + "unikraft.com/cli/internal/model/cmd" "unikraft.com/cli/internal/tui/selector" "unikraft.com/x/kingkong" "unikraft.com/x/log" ) type ProfileCmd struct { - cmd.ResourceCmd[Profile] - cmd.GettableResourceCmd[Profile] - cmd.ListableResourceCmd[Profile] + cmd.ModelCmd[Profile] + cmd.GettableModelCmd[Profile] + cmd.ListableModelCmd[Profile] Use UseCmd `cmd:"" help:"Switch between profiles."` } @@ -37,14 +37,14 @@ type Profile struct { Metros []string `field:",short" json:"metros" help:"Metros of the profile."` } -func (Profile) Type() resource.Type { - return resource.Type{ +func (Profile) Type() model.Type { + return model.Type{ Name: "profile", Names: "profiles", } } -func (i Profile) Key() resource.Key { +func (i Profile) Key() model.Key { return staticKey(i.Name) } @@ -52,15 +52,15 @@ func (i Profile) Raw() any { return i } -func (i Profile) Fields(ctx context.Context) ([]resource.Field, error) { - return resource.FieldsFromStruct(i) +func (i Profile) Fields(ctx context.Context) ([]model.Field, error) { + return model.FieldsFromStruct(i) } -func (Profile) List(ctx context.Context) ([]resource.Resource, error) { +func (Profile) List(ctx context.Context) ([]model.Model, error) { cfg := config.G(ctx) profiles := cfg.Profiles - var results []resource.Resource + var results []model.Model for _, profile := range profiles { metroNames := make([]string, 0, len(profile.Metros)) for _, metro := range profile.Metros { @@ -74,13 +74,13 @@ func (Profile) List(ctx context.Context) ([]resource.Resource, error) { } results = append(results, result) } - slices.SortFunc(results, func(a, b resource.Resource) int { + slices.SortFunc(results, func(a, b model.Model) int { return cmp.Compare(a.(Profile).Name, b.(Profile).Name) }) return results, nil } -func (Profile) Get(ctx context.Context, keys []string) ([]resource.Resource, error) { +func (Profile) Get(ctx context.Context, keys []string) ([]model.Model, error) { return getFromListable(ctx, Profile{}, keys) } diff --git a/internal/cmd/root.go b/internal/cmd/root.go index ab28bd65..abc2b47a 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -31,8 +31,8 @@ import ( "unikraft.com/cli/internal/cmd/login" "unikraft.com/cli/internal/config" "unikraft.com/cli/internal/logfmt" - "unikraft.com/cli/internal/resource" - "unikraft.com/cli/internal/resource/cmd" + "unikraft.com/cli/internal/model" + "unikraft.com/cli/internal/model/cmd" wkong "unikraft.com/cli/internal/w/kong" wmaps "unikraft.com/cli/internal/w/maps" ) @@ -131,17 +131,17 @@ func NewRootCmd(ctx context.Context, args []string, stdio config.Stdio, signals kongcompletion.Register( parser, kongcompletion.WithPredictor("resource-key-path", complete.PredictFiles("*")), - kongcompletion.WithPredictor("resource-key-profile", cmd.PredictResourceKey[Profile](ctx)), - kongcompletion.WithPredictor("resource-key-metro", cmd.PredictResourceKey[Metro](ctx)), - kongcompletion.WithPredictor("resource-key-instance", cmd.PredictResourceKey[Instance](ctx)), - kongcompletion.WithPredictor("resource-key-instance-template", cmd.PredictResourceKey[InstanceTemplate](ctx)), - kongcompletion.WithPredictor("resource-key-instance-checkpoint", cmd.PredictResourceKey[InstanceCheckpoint](ctx)), - kongcompletion.WithPredictor("resource-key-volume", cmd.PredictResourceKey[Volume](ctx)), - kongcompletion.WithPredictor("resource-key-volume-template", cmd.PredictResourceKey[VolumeTemplate](ctx)), - kongcompletion.WithPredictor("resource-key-service", cmd.PredictResourceKey[ServiceGroup](ctx)), - kongcompletion.WithPredictor("resource-key-certificate", cmd.PredictResourceKey[Certificate](ctx)), - kongcompletion.WithPredictor("resource-key-image", cmd.PredictResourceKey[ImageEntry](ctx)), - kongcompletion.WithPredictor("resource-key-resource", cmd.PredictResourceKey[AnyResource](ctx)), + kongcompletion.WithPredictor("resource-key-profile", cmd.PredictModelKey[Profile](ctx)), + kongcompletion.WithPredictor("resource-key-metro", cmd.PredictModelKey[Metro](ctx)), + kongcompletion.WithPredictor("resource-key-instance", cmd.PredictModelKey[Instance](ctx)), + kongcompletion.WithPredictor("resource-key-instance-template", cmd.PredictModelKey[InstanceTemplate](ctx)), + kongcompletion.WithPredictor("resource-key-instance-checkpoint", cmd.PredictModelKey[InstanceCheckpoint](ctx)), + kongcompletion.WithPredictor("resource-key-volume", cmd.PredictModelKey[Volume](ctx)), + kongcompletion.WithPredictor("resource-key-volume-template", cmd.PredictModelKey[VolumeTemplate](ctx)), + kongcompletion.WithPredictor("resource-key-service", cmd.PredictModelKey[ServiceGroup](ctx)), + kongcompletion.WithPredictor("resource-key-certificate", cmd.PredictModelKey[Certificate](ctx)), + kongcompletion.WithPredictor("resource-key-image", cmd.PredictModelKey[ImageEntry](ctx)), + kongcompletion.WithPredictor("resource-key-resource", cmd.PredictModelKey[AnyResource](ctx)), ) kctx, err := parser.Parse(args) @@ -222,7 +222,7 @@ func NewRootCmd(ctx context.Context, args []string, stdio config.Stdio, signals kctx.BindTo(ctx, (*context.Context)(nil)) - partition, err := resource.LoadPartitionFromEnv(PartitionedResources...) + partition, err := model.LoadPartitionFromEnv(PartitionedResources...) if err != nil { cancelTimeout() return ctx, nil, nil, nil, jujuerrors.Annotate(err, "loading partition from environment") @@ -377,7 +377,7 @@ func NewParser(cli *UnikraftCLI) (*kong.Kong, error) { }))...) } -var PartitionedResources = []resource.Resource{ +var PartitionedResources = []model.Model{ Image{}, Instance{}, InstanceTemplate{}, diff --git a/internal/cmd/run.go b/internal/cmd/run.go index fbf2e6f4..eb50d3ab 100644 --- a/internal/cmd/run.go +++ b/internal/cmd/run.go @@ -17,9 +17,9 @@ import ( "unikraft.com/x/kingkong" "unikraft.com/cli/internal/config" + "unikraft.com/cli/internal/model" "unikraft.com/cli/internal/multimetro" "unikraft.com/cli/internal/muxreader" - "unikraft.com/cli/internal/resource" ) // InstanceRunCmd is a convenience wrapper around `instance create` that adds @@ -103,12 +103,12 @@ func (InstanceRunCmd) Examples() []kingkong.Example { } } -func (c *InstanceRunCmd) Run(ctx context.Context, stdio config.Stdio, partition *resource.Partition) error { +func (c *InstanceRunCmd) Run(ctx context.Context, stdio config.Stdio, partition *model.Partition) error { // Unlike create, run starts the instance unless told otherwise. if !c.GeneratedFlags().IsSet("autostart") { c.Set = append(c.Set, map[string]string{"autostart": "true"}) } - created, err := c.RunResources(ctx, stdio, partition) + created, err := c.RunModels(ctx, stdio, partition) if err != nil { return err } @@ -162,7 +162,7 @@ func (RunCmd) Examples() []kingkong.Example { return instanceExamples } -func (c *RunCmd) Run(ctx context.Context, stdio config.Stdio, partition *resource.Partition) error { +func (c *RunCmd) Run(ctx context.Context, stdio config.Stdio, partition *model.Partition) error { return c.InstanceRunCmd.Run(ctx, stdio, partition) } diff --git a/internal/cmd/sandbox.go b/internal/cmd/sandbox.go index c39fbcc9..f67dce54 100644 --- a/internal/cmd/sandbox.go +++ b/internal/cmd/sandbox.go @@ -21,9 +21,9 @@ import ( "unikraft.com/x/log" "unikraft.com/cli/internal/config" + "unikraft.com/cli/internal/model" + "unikraft.com/cli/internal/model/value" "unikraft.com/cli/internal/multimetro" - "unikraft.com/cli/internal/resource" - "unikraft.com/cli/internal/resource/value" "unikraft.com/cloud/sdk/plugins/sandbox" xio "unikraft.com/x/io" "unikraft.com/x/shell" @@ -71,7 +71,7 @@ func (cmd ExecSandboxInstanceCmd) Examples() []kingkong.Example { } } -func (c *ExecSandboxInstanceCmd) Run(ctx context.Context, stdio config.Stdio, partition *resource.Partition) error { +func (c *ExecSandboxInstanceCmd) Run(ctx context.Context, stdio config.Stdio, partition *model.Partition) error { if _, err := parseEnv(c.Env); err != nil { return err } @@ -126,7 +126,7 @@ func parseEnv(records []string) (map[string]string, error) { return env, nil } -func resolveSandboxTarget(ctx context.Context, stdio config.Stdio, partition *resource.Partition, target string, opts SandboxPluginOpts) (sandbox.Target, error) { +func resolveSandboxTarget(ctx context.Context, stdio config.Stdio, partition *model.Partition, target string, opts SandboxPluginOpts) (sandbox.Target, error) { instance, err := lookupInstance(ctx, partition, target) if err != nil { return sandbox.Target{}, err @@ -152,7 +152,7 @@ func resolveSandboxTarget(ctx context.Context, stdio config.Stdio, partition *re } // lookupInstance is the one instance target names. -func lookupInstance(ctx context.Context, partition *resource.Partition, target string) (Instance, error) { +func lookupInstance(ctx context.Context, partition *model.Partition, target string) (Instance, error) { key := multimetro.ParseKey(target) gettable := partition.WrapGettable(Instance{}) @@ -233,7 +233,7 @@ func (cmd WriteSandboxInstanceCmd) Examples() []kingkong.Example { } } -func (c *WriteSandboxInstanceCmd) Run(ctx context.Context, stdio config.Stdio, partition *resource.Partition) error { +func (c *WriteSandboxInstanceCmd) Run(ctx context.Context, stdio config.Stdio, partition *model.Partition) error { if err := sandbox.StatLocalFile(c.Local, "written"); err != nil { return err } @@ -299,7 +299,7 @@ func (cmd ReadSandboxInstanceCmd) Examples() []kingkong.Example { } } -func (c *ReadSandboxInstanceCmd) Run(ctx context.Context, stdio config.Stdio, partition *resource.Partition) error { +func (c *ReadSandboxInstanceCmd) Run(ctx context.Context, stdio config.Stdio, partition *model.Partition) error { if err := checkLocalName(c.Remote, c.Local); err != nil { return err } @@ -421,7 +421,7 @@ func (cmd CopySandboxInstanceCmd) Examples() []kingkong.Example { } } -func (c *CopySandboxInstanceCmd) Run(ctx context.Context, stdio config.Stdio, partition *resource.Partition) error { +func (c *CopySandboxInstanceCmd) Run(ctx context.Context, stdio config.Stdio, partition *model.Partition) error { srcTarget, srcPath := parseCopyPath(c.Source) dstTarget, dstPath := parseCopyPath(c.Destination) diff --git a/internal/cmd/sandbox_plugin.go b/internal/cmd/sandbox_plugin.go index f0cbbfae..6383e403 100644 --- a/internal/cmd/sandbox_plugin.go +++ b/internal/cmd/sandbox_plugin.go @@ -22,8 +22,8 @@ import ( "unikraft.com/x/ptr" "unikraft.com/cli/internal/config" - "unikraft.com/cli/internal/resource" - "unikraft.com/cli/internal/resource/cmd" + "unikraft.com/cli/internal/model" + "unikraft.com/cli/internal/model/cmd" "unikraft.com/cli/internal/tui/selector" ) @@ -50,7 +50,7 @@ type SandboxPluginOpts struct { PluginImage string `name:"plugin-image" default:"${sandbox_plugin_image}" help:"Image of the sandbox plugin to attach when the instance has none." placeholder:"ref"` } -func attachPlugin(ctx context.Context, stdio config.Stdio, partition *resource.Partition, instance Instance, opts SandboxPluginOpts) error { +func attachPlugin(ctx context.Context, stdio config.Stdio, partition *model.Partition, instance Instance, opts SandboxPluginOpts) error { cause := missingPlugin(instance, opts.PluginName) if cause == nil { return nil @@ -100,7 +100,7 @@ func pendingPlugin(instance Instance, plugin string) (platform.InstancePendingUp return platform.InstancePendingUpdate{}, false } -func tryAttachPlugin(ctx context.Context, stdio config.Stdio, partition *resource.Partition, instance Instance, opts SandboxPluginOpts, cause error) error { +func tryAttachPlugin(ctx context.Context, stdio config.Stdio, partition *model.Partition, instance Instance, opts SandboxPluginOpts, cause error) error { plugin, image := opts.PluginName, opts.PluginImage if slices.Contains(instance.Instance.Features, platform.InstanceFeatureDeleteOnStop) { return fmt.Errorf("%w\nhint: the instance is deleted when it stops, so the plugin cannot be attached in place; recreate it with --plugin name=%s,image=%s", cause, plugin, image) @@ -183,9 +183,9 @@ func restartInstance(ctx context.Context, stdio config.Stdio, instance Instance) return start.Run(ctx, stdio) } -func attachInstancePlugin(ctx context.Context, stdio config.Stdio, partition *resource.Partition, instance Instance, opts SandboxPluginOpts, restart bool) error { +func attachInstancePlugin(ctx context.Context, stdio config.Stdio, partition *model.Partition, instance Instance, opts SandboxPluginOpts, restart bool) error { stdio.Stdout = io.Discard - edit := cmd.ResourceEditCmd[Instance]{ + edit := cmd.ModelEditCmd[Instance]{ Target: instance.Key().String(), Add: []map[string]string{{"plugins": fmt.Sprintf("name=%s,image=%s", opts.PluginName, opts.PluginImage)}}, Output: cmd.Printer{Type: cmd.PrinterTypeQuiet}, diff --git a/internal/cmd/services.go b/internal/cmd/services.go index 338e6399..665e1bd0 100644 --- a/internal/cmd/services.go +++ b/internal/cmd/services.go @@ -21,22 +21,22 @@ import ( "unikraft.com/cli/internal/config" "unikraft.com/cli/internal/mirror" + "unikraft.com/cli/internal/model" + "unikraft.com/cli/internal/model/cmd" + "unikraft.com/cli/internal/model/value" "unikraft.com/cli/internal/multimetro" - "unikraft.com/cli/internal/resource" - "unikraft.com/cli/internal/resource/cmd" - "unikraft.com/cli/internal/resource/value" "unikraft.com/cli/internal/types" ) type ServicesCmd struct { - cmd.ResourceCmd[ServiceGroup] - cmd.GettableResourceCmd[ServiceGroup] - cmd.WaitableResourceCmd[ServiceGroup] - cmd.ListableResourceCmd[ServiceGroup] - cmd.BulkDeletableResourceCmd[ServiceGroup] - - Create cmd.ResourceCreateCmd[ServiceGroup] `cmd:"" help:"Create a service group."` - Edit cmd.ResourceEditCmd[ServiceGroup] `cmd:"" help:"Edit a service group."` + cmd.ModelCmd[ServiceGroup] + cmd.GettableModelCmd[ServiceGroup] + cmd.WaitableModelCmd[ServiceGroup] + cmd.ListableModelCmd[ServiceGroup] + cmd.BulkDeletableModelCmd[ServiceGroup] + + Create cmd.ModelCreateCmd[ServiceGroup] `cmd:"" help:"Create a service group."` + Edit cmd.ModelEditCmd[ServiceGroup] `cmd:"" help:"Edit a service group."` } type ServiceGroup struct { @@ -190,14 +190,14 @@ func (d *Domain) UnmarshalJSON(data []byte) error { return json.Unmarshal(data, (*domainJSON)(d)) } -func (ServiceGroup) Type() resource.Type { - return resource.Type{ +func (ServiceGroup) Type() model.Type { + return model.Type{ Name: "service", Names: "services", } } -func (s ServiceGroup) Key() resource.Key { +func (s ServiceGroup) Key() model.Key { return s.key } @@ -205,9 +205,9 @@ func (s ServiceGroup) Raw() any { return s.ServiceGroup } -func (s ServiceGroup) Fields(ctx context.Context) ([]resource.Field, error) { +func (s ServiceGroup) Fields(ctx context.Context) ([]model.Field, error) { s.Metro = LinkName[Metro](defaultMetro(ctx, string(s.Metro))) - result, err := resource.FieldsFromStruct(s) + result, err := model.FieldsFromStruct(s) if err != nil { return nil, err } @@ -216,18 +216,18 @@ func (s ServiceGroup) Fields(ctx context.Context) ([]resource.Field, error) { return result, nil } -func (ServiceGroup) List(ctx context.Context) ([]resource.Resource, error) { +func (ServiceGroup) List(ctx context.Context) ([]model.Model, error) { g, err := multimetro.NewClient(ctx) if err != nil { return nil, err } - return group.CollectAllSlices(ctx, g, func(ctx context.Context, c multimetro.MetroClient) ([]resource.Resource, error) { + return group.CollectAllSlices(ctx, g, func(ctx context.Context, c multimetro.MetroClient) ([]model.Model, error) { log.G(ctx).Trace().Msg("listing service groups") resp, err := c.GetServiceGroups(ctx, nil, platform.GetServiceGroupsOpts{Details: new(true)}) if err != nil { return nil, err } - var results []resource.Resource + var results []model.Model var errs []error if resp == nil || resp.Data == nil { return nil, nil @@ -243,19 +243,19 @@ func (ServiceGroup) List(ctx context.Context) ([]resource.Resource, error) { }) } -func (ServiceGroup) Get(ctx context.Context, keys []string) ([]resource.Resource, error) { +func (ServiceGroup) Get(ctx context.Context, keys []string) ([]model.Model, error) { g, err := multimetro.NewClient(ctx) if err != nil { return nil, err } - return group.CollectRefsSlices(ctx, g, multimetro.ParseKeys(keys).Refs(), func(ctx context.Context, c multimetro.MetroClient, refs group.Refs) ([]resource.Resource, group.Refs, error) { + return group.CollectRefsSlices(ctx, g, multimetro.ParseKeys(keys).Refs(), func(ctx context.Context, c multimetro.MetroClient, refs group.Refs) ([]model.Model, group.Refs, error) { log.G(ctx).Trace().Msg("getting service groups") resp, err := c.GetServiceGroups(ctx, refs.NameOrUUIDs(), platform.GetServiceGroupsOpts{Details: new(true)}) if err != nil && !platform.ErrorContainsOnly(err, platform.APIHTTPErrorNotFound) { return nil, nil, err } var found []group.Ref - var results []resource.Resource + var results []model.Model var errs []error if resp == nil || resp.Data == nil { return nil, nil, nil @@ -324,10 +324,10 @@ func (ServiceGroup) Delete(ctx context.Context, keys []string) error { }) } -func (ServiceGroup) Create(ctx context.Context, fields []resource.Field) ([]resource.Resource, error) { +func (ServiceGroup) Create(ctx context.Context, fields []model.Field) ([]model.Model, error) { var req platform.CreateServiceGroupRequest var metro string - for key, field := range resource.IterFields(fields) { + for key, field := range model.IterFields(fields) { if field.Create != nil && field.Create.Set != nil { switch key.String() { case "metro": @@ -407,7 +407,7 @@ func (ServiceGroup) Create(ctx context.Context, fields []resource.Field) ([]reso return results, nil } -func (ServiceGroup) Edit(ctx context.Context, key string, fields []resource.Field) error { +func (ServiceGroup) Edit(ctx context.Context, key string, fields []model.Field) error { parsedKeys := multimetro.ParseKeys([]string{key}) patches, err := patchRequests(fields, serviceGroupPatchSpec) if err != nil { diff --git a/internal/cmd/shell.go b/internal/cmd/shell.go index d519ee8a..a82c4838 100644 --- a/internal/cmd/shell.go +++ b/internal/cmd/shell.go @@ -21,8 +21,8 @@ import ( xstdio "unikraft.com/x/stdio" "unikraft.com/cli/internal/config" - "unikraft.com/cli/internal/resource" - "unikraft.com/cli/internal/resource/cmd" + "unikraft.com/cli/internal/model" + "unikraft.com/cli/internal/model/cmd" "unikraft.com/cli/internal/types" "unikraft.com/cli/pkg/shellbuiltins" ) @@ -79,7 +79,7 @@ func (ShellSandboxInstanceCmd) Examples() []kingkong.Example { } } -func (c *ShellSandboxInstanceCmd) Run(ctx context.Context, stdio config.Stdio, partition *resource.Partition, signals *xsignal.Signals) error { +func (c *ShellSandboxInstanceCmd) Run(ctx context.Context, stdio config.Stdio, partition *model.Partition, signals *xsignal.Signals) error { env, err := parseEnv(c.Env) if err != nil { return err @@ -121,19 +121,19 @@ func (c *ShellSandboxInstanceCmd) Run(ctx context.Context, stdio config.Stdio, p // ShellInstance runs the shell's builtins with the CLI's own commands. type ShellInstance struct { Key string - Partition *resource.Partition + Partition *model.Partition } func (i ShellInstance) Get(ctx context.Context, stdio xstdio.Stdio, format cmd.FormatOpts) error { - return (&cmd.ResourceGetCmd[Instance]{Targets: []string{i.Key}, FormatOpts: format}).Run(ctx, cliStdio(stdio), i.Partition) + return (&cmd.ModelGetCmd[Instance]{Targets: []string{i.Key}, FormatOpts: format}).Run(ctx, cliStdio(stdio), i.Partition) } func (i ShellInstance) Volumes(ctx context.Context, stdio xstdio.Stdio, format cmd.FormatOpts) error { - return (&cmd.ResourceListCmd[Volume]{FormatOpts: format}).Run(ctx, cliStdio(stdio), i.Partition) + return (&cmd.ModelListCmd[Volume]{FormatOpts: format}).Run(ctx, cliStdio(stdio), i.Partition) } func (i ShellInstance) Edit(ctx context.Context, stdio xstdio.Stdio, set map[string]string) error { - return (&cmd.ResourceEditCmd[Instance]{Target: i.Key, Set: []map[string]string{set}}).Run(ctx, cliStdio(stdio), i.Partition) + return (&cmd.ModelEditCmd[Instance]{Target: i.Key, Set: []map[string]string{set}}).Run(ctx, cliStdio(stdio), i.Partition) } func (i ShellInstance) Attach(ctx context.Context, stdio xstdio.Stdio, volume, at string, readonly bool) error { diff --git a/internal/cmd/tui_native.go b/internal/cmd/tui_native.go index f4d207e2..a81c845b 100644 --- a/internal/cmd/tui_native.go +++ b/internal/cmd/tui_native.go @@ -14,15 +14,15 @@ import ( tea "charm.land/bubbletea/v2" "unikraft.com/cli/internal/config" - "unikraft.com/cli/internal/resource" - resourcetui "unikraft.com/cli/internal/resource/tui" + "unikraft.com/cli/internal/model" + modeltui "unikraft.com/cli/internal/model/tui" "unikraft.com/cli/internal/tui/uitui" xio "unikraft.com/x/io" ) // NewTUIModel builds the same Bubble Tea model used by `unikraft tui`. func NewTUIModel(ctx context.Context, resourceArg, nameArg string) (tea.Model, error) { - registry := resource.NewRegistry() + registry := model.NewRegistry() registry.Register(Instance{}, Instance{}) registry.Register(Volume{}, Volume{}) registry.Register(ServiceGroup{}, ServiceGroup{}) @@ -44,28 +44,28 @@ func NewTUIModel(ctx context.Context, resourceArg, nameArg string) (tea.Model, e if selected.Get == nil { return nil, fmt.Errorf("resource %s does not support get", selected.Name) } - panel = resourcetui.NewDetailPanel(ctx, registry, selected, nameArg) + panel = modeltui.NewDetailPanel(ctx, registry, selected, nameArg) case resourceArg != "": selected, ok := registry.Resolve(resourceArg) if !ok { return nil, fmt.Errorf("unknown resource: %s", resourceArg) } - panel = resourcetui.NewListPanel(ctx, registry, selected) + panel = modeltui.NewListPanel(ctx, registry, selected) default: - panel = resourcetui.NewHomePanel(ctx, registry) + panel = modeltui.NewHomePanel(ctx, registry) } return uitui.NewModel(panel), nil } func (cmd *TUICmd) Run(ctx context.Context, stdio config.Stdio) error { - model, err := NewTUIModel(ctx, cmd.Resource, cmd.Name) + m, err := NewTUIModel(ctx, cmd.Resource, cmd.Name) if err != nil { return err } program := tea.NewProgram( - model, + m, tea.WithInput(stdio.Stdin), tea.WithOutput(xio.Unwrap(stdio.Stdout)), ) diff --git a/internal/cmd/unavailable_js.go b/internal/cmd/unavailable_js.go index cd0def50..4e2fe63a 100644 --- a/internal/cmd/unavailable_js.go +++ b/internal/cmd/unavailable_js.go @@ -14,7 +14,7 @@ import ( "github.com/alecthomas/kong" "unikraft.com/cli/internal/config" - "unikraft.com/cli/internal/resource" + "unikraft.com/cli/internal/model" ) // notAvailable reports that a command needs the native CLI. @@ -25,11 +25,11 @@ func notAvailable(what string) error { ) } -func (c *BuildCmd) Run(ctx context.Context, cfg *config.Config, partition *resource.Partition) error { +func (c *BuildCmd) Run(ctx context.Context, cfg *config.Config, partition *model.Partition) error { return notAvailable("build") } -func (c *ImageBuildCmd) Run(ctx context.Context, cfg *config.Config, partition *resource.Partition) error { +func (c *ImageBuildCmd) Run(ctx context.Context, cfg *config.Config, partition *model.Partition) error { return notAvailable("image build") } @@ -49,6 +49,6 @@ func (cmd *UpgradeCmd) Run(ctx context.Context, stdio config.Stdio) error { return notAvailable("upgrade") } -func (c *VolumeImportCmd) Run(ctx context.Context, stdio config.Stdio, partition *resource.Partition) error { +func (c *VolumeImportCmd) Run(ctx context.Context, stdio config.Stdio, partition *model.Partition) error { return notAvailable("volume import") } diff --git a/internal/cmd/util.go b/internal/cmd/util.go index b933a8bc..187d36f4 100644 --- a/internal/cmd/util.go +++ b/internal/cmd/util.go @@ -16,8 +16,8 @@ import ( "unikraft.com/x/log" "unikraft.com/cli/internal/config" + "unikraft.com/cli/internal/model" "unikraft.com/cli/internal/multimetro" - "unikraft.com/cli/internal/resource" ) // defaultMetro returns the metro if non-empty, otherwise falls back to the @@ -35,7 +35,7 @@ func defaultMetro(ctx context.Context, metro string) string { // linkToConsole links the name field to the console page at path. // It does nothing when profile is not an organization on Unikraft Cloud. -func linkToConsole(ctx context.Context, fields []resource.Field, profile *config.Profile, path ...string) { +func linkToConsole(ctx context.Context, fields []model.Field, profile *config.Profile, path ...string) { if profile == nil || profile.ControlPlane == "" { log.G(ctx).Trace(). Strs("path", path). @@ -50,18 +50,18 @@ func linkToConsole(ctx context.Context, fields []resource.Field, profile *config return } link := "https://console.unikraft.cloud/org/" + profile.Organization + "/" + strings.Join(path, "/") - for _, field := range resource.GetFieldRefByPathString(fields, "name") { + for _, field := range model.GetFieldRefByPathString(fields, "name") { field.Hyperlink = link } } -func getFromListable(ctx context.Context, listable resource.ListableResource, keys []string) ([]resource.Resource, error) { +func getFromListable(ctx context.Context, listable model.ListableModel, keys []string) ([]model.Model, error) { all, err := listable.List(ctx) if err != nil { return nil, err } - found := make([]resource.Resource, 0, len(keys)) + found := make([]model.Model, 0, len(keys)) var notFound []string loop: for _, key := range keys { @@ -120,18 +120,18 @@ type createdResource[T any] struct { // recoverCreated builds resources for refs that a listing could not find but // that a create call already reported. Refs with nothing to fall back on are // returned unchanged. -func recoverCreated[R resource.Resource, T any]( +func recoverCreated[R model.Model, T any]( ctx context.Context, refs group.Refs, createdData map[string]createdResource[T], load func(*group.Ref, T, *config.Metro, *config.Profile) (R, error), -) ([]resource.Resource, group.Refs) { +) ([]model.Model, group.Refs) { profile, err := config.G(ctx).CurrentProfile() if err != nil { return nil, refs } - var recovered []resource.Resource + var recovered []model.Model var missing group.Refs for _, ref := range refs { key := multimetro.Key(ref).String() @@ -167,7 +167,7 @@ type patchReq[P ~string] struct { Value any } -func patchRequests[P ~string](fields []resource.Field, specFor func(path string, op patchOp, value any) (P, any, error)) ([]patchReq[P], error) { +func patchRequests[P ~string](fields []model.Field, specFor func(path string, op patchOp, value any) (P, any, error)) ([]patchReq[P], error) { var reqs []patchReq[P] addReq := func(op patchOp, path string, value any) error { if value == nil { @@ -197,7 +197,7 @@ func patchRequests[P ~string](fields []resource.Field, specFor func(path string, return nil } - for key, field := range resource.IterFields(fields) { + for key, field := range model.IterFields(fields) { if field.Edit == nil { continue } diff --git a/internal/cmd/util_test.go b/internal/cmd/util_test.go index aab0e0f9..5a4d3071 100644 --- a/internal/cmd/util_test.go +++ b/internal/cmd/util_test.go @@ -15,8 +15,8 @@ import ( "unikraft.com/cloud/sdk/platform/group" "unikraft.com/cli/internal/config" + "unikraft.com/cli/internal/model" "unikraft.com/cli/internal/multimetro" - "unikraft.com/cli/internal/resource" ) func TestRecoverCreatedFallsBackToCreateResponse(t *testing.T) { @@ -77,8 +77,8 @@ func TestRecoverCreatedWithoutProfileReportsAllMissing(t *testing.T) { } // nameLink returns the hyperlink of the name field. -func nameLink(fields []resource.Field) string { - for key, field := range resource.IterFields(fields) { +func nameLink(fields []model.Field) string { + for key, field := range model.IterFields(fields) { if key.String() == "name" { return field.Hyperlink } @@ -101,7 +101,7 @@ func TestResourcesLinkToTheConsole(t *testing.T) { }) for _, tc := range []struct { - resource resource.Resource + resource model.Model link string }{ {Instance{Metro: "fra", Name: "web", Profile: &profile}, "https://console.unikraft.cloud/org/acme/instances/fra/web"}, diff --git a/internal/cmd/volume_templates.go b/internal/cmd/volume_templates.go index b9f9e69d..9e2e6a8f 100644 --- a/internal/cmd/volume_templates.go +++ b/internal/cmd/volume_templates.go @@ -19,20 +19,20 @@ import ( "unikraft.com/cli/internal/config" "unikraft.com/cli/internal/mirror" + "unikraft.com/cli/internal/model" + "unikraft.com/cli/internal/model/cmd" "unikraft.com/cli/internal/multimetro" - "unikraft.com/cli/internal/resource" - "unikraft.com/cli/internal/resource/cmd" "unikraft.com/cli/internal/types" ) type VolumeTemplatesCmd struct { - cmd.ResourceCmd[VolumeTemplate] - cmd.GettableResourceCmd[VolumeTemplate] - cmd.ListableResourceCmd[VolumeTemplate] - cmd.BulkDeletableResourceCmd[VolumeTemplate] + cmd.ModelCmd[VolumeTemplate] + cmd.GettableModelCmd[VolumeTemplate] + cmd.ListableModelCmd[VolumeTemplate] + cmd.BulkDeletableModelCmd[VolumeTemplate] - Create cmd.ResourceCreateCmd[VolumeTemplate] `cmd:"" help:"Create a volume template."` - Edit cmd.ResourceEditCmd[VolumeTemplate] `cmd:"" help:"Edit a volume template."` + Create cmd.ModelCreateCmd[VolumeTemplate] `cmd:"" help:"Create a volume template."` + Edit cmd.ModelEditCmd[VolumeTemplate] `cmd:"" help:"Edit a volume template."` } type VolumeTemplate struct { @@ -60,14 +60,14 @@ type VolumeTemplate struct { key multimetro.Key } -func (VolumeTemplate) Type() resource.Type { - return resource.Type{ +func (VolumeTemplate) Type() model.Type { + return model.Type{ Name: "volume-template", Names: "volume-templates", } } -func (v VolumeTemplate) Key() resource.Key { +func (v VolumeTemplate) Key() model.Key { return v.key } @@ -75,11 +75,11 @@ func (v VolumeTemplate) Raw() any { return v.Volume } -func (v VolumeTemplate) Fields(ctx context.Context) ([]resource.Field, error) { - return resource.FieldsFromStruct(v) +func (v VolumeTemplate) Fields(ctx context.Context) ([]model.Field, error) { + return model.FieldsFromStruct(v) } -func (VolumeTemplate) List(ctx context.Context) ([]resource.Resource, error) { +func (VolumeTemplate) List(ctx context.Context) ([]model.Model, error) { g, err := multimetro.NewClient(ctx) if err != nil { return nil, err @@ -88,13 +88,13 @@ func (VolumeTemplate) List(ctx context.Context) ([]resource.Resource, error) { if err != nil { return nil, err } - return group.CollectAllSlices(ctx, g, func(ctx context.Context, c multimetro.MetroClient) ([]resource.Resource, error) { + return group.CollectAllSlices(ctx, g, func(ctx context.Context, c multimetro.MetroClient) ([]model.Model, error) { log.G(ctx).Trace().Msg("listing volume templates") resp, err := c.GetTemplateVolumes(ctx, nil, platform.GetTemplateVolumesOpts{Details: new(true)}) if err != nil { return nil, err } - var results []resource.Resource + var results []model.Model var errs []error if resp == nil || resp.Data == nil { return nil, nil @@ -111,7 +111,7 @@ func (VolumeTemplate) List(ctx context.Context) ([]resource.Resource, error) { }) } -func (VolumeTemplate) Get(ctx context.Context, keys []string) ([]resource.Resource, error) { +func (VolumeTemplate) Get(ctx context.Context, keys []string) ([]model.Model, error) { g, err := multimetro.NewClient(ctx) if err != nil { return nil, err @@ -120,14 +120,14 @@ func (VolumeTemplate) Get(ctx context.Context, keys []string) ([]resource.Resour if err != nil { return nil, err } - return group.CollectRefsSlices(ctx, g, multimetro.ParseKeys(keys).Refs(), func(ctx context.Context, c multimetro.MetroClient, refs group.Refs) ([]resource.Resource, group.Refs, error) { + return group.CollectRefsSlices(ctx, g, multimetro.ParseKeys(keys).Refs(), func(ctx context.Context, c multimetro.MetroClient, refs group.Refs) ([]model.Model, group.Refs, error) { log.G(ctx).Trace().Msg("getting volume templates") resp, err := c.GetTemplateVolumes(ctx, refs.NameOrUUIDs(), platform.GetTemplateVolumesOpts{Details: new(true)}) if err != nil && !platform.ErrorContainsOnly(err, platform.APIHTTPErrorNotFound) { return nil, nil, err } var found []group.Ref - var results []resource.Resource + var results []model.Model var errs []error if resp == nil || resp.Data == nil { return nil, nil, nil @@ -208,7 +208,7 @@ func (VolumeTemplate) Delete(ctx context.Context, keys []string) error { }) } -func (VolumeTemplate) Edit(ctx context.Context, key string, fields []resource.Field) error { +func (VolumeTemplate) Edit(ctx context.Context, key string, fields []model.Field) error { parsedKeys := multimetro.ParseKeys([]string{key}) patches, err := patchRequests(fields, volumeTemplatePatchSpec) if err != nil { @@ -269,9 +269,9 @@ func volumeTemplatePatchSpec(path string, op patchOp, value any) (platform.Mutab } } -func (VolumeTemplate) Create(ctx context.Context, fields []resource.Field) ([]resource.Resource, error) { +func (VolumeTemplate) Create(ctx context.Context, fields []model.Field) ([]model.Model, error) { var volume string - for key, field := range resource.IterFields(fields) { + for key, field := range model.IterFields(fields) { if field.Create == nil || field.Create.Set == nil { continue } diff --git a/internal/cmd/volumes.go b/internal/cmd/volumes.go index 2f7a8f10..fde27861 100644 --- a/internal/cmd/volumes.go +++ b/internal/cmd/volumes.go @@ -24,24 +24,24 @@ import ( "unikraft.com/cli/internal/config" "unikraft.com/cli/internal/mirror" + "unikraft.com/cli/internal/model" + "unikraft.com/cli/internal/model/cmd" + "unikraft.com/cli/internal/model/patch" + "unikraft.com/cli/internal/model/value" "unikraft.com/cli/internal/multimetro" - "unikraft.com/cli/internal/resource" - "unikraft.com/cli/internal/resource/cmd" - "unikraft.com/cli/internal/resource/patch" - "unikraft.com/cli/internal/resource/value" "unikraft.com/cli/internal/types" ) type VolumesCmd struct { - cmd.ResourceCmd[Volume] - cmd.GettableResourceCmd[Volume] - cmd.WaitableResourceCmd[Volume] - cmd.ListableResourceCmd[Volume] - cmd.BulkDeletableResourceCmd[Volume] + cmd.ModelCmd[Volume] + cmd.GettableModelCmd[Volume] + cmd.WaitableModelCmd[Volume] + cmd.ListableModelCmd[Volume] + cmd.BulkDeletableModelCmd[Volume] - Create cmd.ResourceCreateCmd[Volume] `cmd:"" help:"Create a volume."` - Edit cmd.ResourceEditCmd[Volume] `cmd:"" help:"Edit a volume."` - Template VolumeTemplatesCmd `cmd:"" group:"cmd-templates" help:"Manage volume templates." aliases:"templates" set:"name=volume-template" set:"names=volume-templates"` + Create cmd.ModelCreateCmd[Volume] `cmd:"" help:"Create a volume."` + Edit cmd.ModelEditCmd[Volume] `cmd:"" help:"Edit a volume."` + Template VolumeTemplatesCmd `cmd:"" group:"cmd-templates" help:"Manage volume templates." aliases:"templates" set:"name=volume-template" set:"names=volume-templates"` Clone VolumesCloneCmd `cmd:"" help:"Clone a volume."` Attach VolumeAttachCmd `cmd:"" help:"Attach a volume to an instance."` @@ -72,7 +72,7 @@ func (VolumesCloneCmd) Examples() []kingkong.Example { } } -func (c *VolumesCloneCmd) Run(ctx context.Context, stdio config.Stdio, partition *resource.Partition) error { +func (c *VolumesCloneCmd) Run(ctx context.Context, stdio config.Stdio, partition *model.Partition) error { spec := patch.PatchSpec{ Set: make(map[string][]string), } @@ -227,14 +227,14 @@ type Volume struct { DeleteLock bool `mirror:"volume.delete_lock" field:"delete-lock,long" edit:"set" flag:"delete-lock" help:"Prevent deletion until the lock is removed."` } -func (Volume) Type() resource.Type { - return resource.Type{ +func (Volume) Type() model.Type { + return model.Type{ Name: "volume", Names: "volumes", } } -func (i Volume) Key() resource.Key { +func (i Volume) Key() model.Key { return i.key } @@ -242,9 +242,9 @@ func (i Volume) Raw() any { return i.Volume } -func (i Volume) Fields(ctx context.Context) ([]resource.Field, error) { +func (i Volume) Fields(ctx context.Context) ([]model.Field, error) { i.Metro = LinkName[Metro](defaultMetro(ctx, string(i.Metro))) - result, err := resource.FieldsFromStruct(i) + result, err := model.FieldsFromStruct(i) if err != nil { return nil, err } @@ -253,12 +253,12 @@ func (i Volume) Fields(ctx context.Context) ([]resource.Field, error) { return result, nil } -func (Volume) List(ctx context.Context) ([]resource.Resource, error) { +func (Volume) List(ctx context.Context) ([]model.Model, error) { g, err := multimetro.NewClient(ctx) if err != nil { return nil, err } - return group.CollectAllSlices(ctx, g, func(ctx context.Context, c multimetro.MetroClient) ([]resource.Resource, error) { + return group.CollectAllSlices(ctx, g, func(ctx context.Context, c multimetro.MetroClient) ([]model.Model, error) { log.G(ctx).Trace().Msg("listing volumes") resp, err := c.GetVolumes(ctx, nil, platform.GetVolumesOpts{Details: new(true)}) if err != nil { @@ -267,7 +267,7 @@ func (Volume) List(ctx context.Context) ([]resource.Resource, error) { if resp == nil || resp.Data == nil { return nil, nil } - var results []resource.Resource + var results []model.Model var errs []error for _, volume := range resp.Data.Volumes { result, err := Volume{}.load(nil, volume, &c.Metro) @@ -280,20 +280,20 @@ func (Volume) List(ctx context.Context) ([]resource.Resource, error) { }) } -func (Volume) Get(ctx context.Context, keys []string) ([]resource.Resource, error) { +func (Volume) Get(ctx context.Context, keys []string) ([]model.Model, error) { g, err := multimetro.NewClient(ctx) if err != nil { return nil, err } - return group.CollectRefsSlices(ctx, g, multimetro.ParseKeys(keys).Refs(), func(ctx context.Context, c multimetro.MetroClient, refs group.Refs) ([]resource.Resource, group.Refs, error) { + return group.CollectRefsSlices(ctx, g, multimetro.ParseKeys(keys).Refs(), func(ctx context.Context, c multimetro.MetroClient, refs group.Refs) ([]model.Model, group.Refs, error) { log.G(ctx).Trace().Msg("getting volumes") resp, err := c.GetVolumes(ctx, refs.NameOrUUIDs(), platform.GetVolumesOpts{Details: new(true)}) if err != nil && !platform.ErrorContainsOnly(err, platform.APIHTTPErrorNotFound) { return nil, nil, err } var found []group.Ref - var results []resource.Resource + var results []model.Model var errs []error if resp == nil || resp.Data == nil { return nil, nil, nil @@ -384,10 +384,10 @@ func (Volume) Delete(ctx context.Context, keys []string) error { }) } -func (Volume) Create(ctx context.Context, fields []resource.Field) ([]resource.Resource, error) { +func (Volume) Create(ctx context.Context, fields []model.Field) ([]model.Model, error) { var req platform.CreateVolumeRequest var metro string - for key, field := range resource.IterFields(fields) { + for key, field := range model.IterFields(fields) { if field.Create.Set != nil { switch key.String() { case "name": @@ -461,7 +461,7 @@ func (Volume) Create(ctx context.Context, fields []resource.Field) ([]resource.R return results, nil } -func (Volume) Edit(ctx context.Context, key string, fields []resource.Field) error { +func (Volume) Edit(ctx context.Context, key string, fields []model.Field) error { parsedKeys := multimetro.ParseKeys([]string{key}) patches, err := patchRequests(fields, volumePatchSpec) if err != nil { @@ -587,7 +587,7 @@ func (VolumeAttachCmd) Examples() []kingkong.Example { } } -func (c *VolumeAttachCmd) Run(ctx context.Context, stdio config.Stdio, partition *resource.Partition) error { +func (c *VolumeAttachCmd) Run(ctx context.Context, stdio config.Stdio, partition *model.Partition) error { vol := &InstanceVolume{At: c.At, Readonly: c.Readonly} link, err := ParseLink[Volume]([]byte(c.Volume)) if err != nil { @@ -598,7 +598,7 @@ func (c *VolumeAttachCmd) Run(ctx context.Context, stdio config.Stdio, partition if err != nil { return err } - editCmd := &cmd.ResourceEditCmd[Instance]{ + editCmd := &cmd.ModelEditCmd[Instance]{ Target: c.To, Add: []map[string]string{{"volumes": string(volStr)}}, FormatOpts: c.FormatOpts, @@ -623,8 +623,8 @@ func (VolumeDetachCmd) Examples() []kingkong.Example { } } -func (c *VolumeDetachCmd) Run(ctx context.Context, stdio config.Stdio, partition *resource.Partition) error { - editCmd := &cmd.ResourceEditCmd[Instance]{ +func (c *VolumeDetachCmd) Run(ctx context.Context, stdio config.Stdio, partition *model.Partition) error { + editCmd := &cmd.ModelEditCmd[Instance]{ Target: c.From, Del: []map[string]string{{"volumes": c.Volume}}, FormatOpts: c.FormatOpts, diff --git a/internal/cmd/volumes_import_native.go b/internal/cmd/volumes_import_native.go index 31ee3884..b71f0b78 100644 --- a/internal/cmd/volumes_import_native.go +++ b/internal/cmd/volumes_import_native.go @@ -29,11 +29,11 @@ import ( "unikraft.com/x/ptr" "unikraft.com/cli/internal/config" + "unikraft.com/cli/internal/model" "unikraft.com/cli/internal/multimetro" - "unikraft.com/cli/internal/resource" ) -func (c *VolumeImportCmd) Run(ctx context.Context, stdio config.Stdio, partition *resource.Partition) error { +func (c *VolumeImportCmd) Run(ctx context.Context, stdio config.Stdio, partition *model.Partition) error { if c.Source == "" { return fmt.Errorf("source path is required") } diff --git a/internal/integration/env.go b/internal/integration/env.go index 849bbca7..ff02ed40 100644 --- a/internal/integration/env.go +++ b/internal/integration/env.go @@ -21,7 +21,7 @@ import ( "github.com/charmbracelet/x/ansi" "github.com/stretchr/testify/require" - "unikraft.com/cli/internal/resource" + "unikraft.com/cli/internal/model" ) // TestEnv holds per-test environment for running CLI commands deterministically. @@ -155,7 +155,7 @@ func (env *TestEnv) RunRaw(t *testing.T, args []string, opts ...CmdOption) (stri 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) + c.Env = append(c.Env, model.UnikraftPartitionEnv+"="+env.partitionPath) } err := c.Run() @@ -245,7 +245,7 @@ func (env *TestEnv) StartBackground(t *testing.T, args []string, waitAddr string 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, resource.UnikraftPartitionEnv+"="+env.partitionPath) + c.Env = append(c.Env, model.UnikraftPartitionEnv+"="+env.partitionPath) c.Cancel = func() error { return c.Process.Signal(os.Interrupt) } diff --git a/internal/resource/cmd/LICENSE.fragment.txt b/internal/model/cmd/LICENSE.fragment.txt similarity index 100% rename from internal/resource/cmd/LICENSE.fragment.txt rename to internal/model/cmd/LICENSE.fragment.txt diff --git a/internal/resource/cmd/cmd.go b/internal/model/cmd/cmd.go similarity index 74% rename from internal/resource/cmd/cmd.go rename to internal/model/cmd/cmd.go index c77965bf..3607e962 100644 --- a/internal/resource/cmd/cmd.go +++ b/internal/model/cmd/cmd.go @@ -25,66 +25,66 @@ import ( "unikraft.com/x/log" "unikraft.com/cli/internal/config" + "unikraft.com/cli/internal/model" + "unikraft.com/cli/internal/model/patch" "unikraft.com/cli/internal/multimetro" - "unikraft.com/cli/internal/resource" - "unikraft.com/cli/internal/resource/patch" "unikraft.com/cli/internal/tui/watcher" wkong "unikraft.com/cli/internal/w/kong" "unikraft.com/cloud/sdk/platform/group" ) -type ResourceCmdInterface interface { - Underlying() resource.Resource +type ModelCmdInterface interface { + Underlying() model.Model } -type ResourceCmd[R resource.Resource] struct{} +type ModelCmd[R model.Model] struct{} -var _ ResourceCmdInterface = (*ResourceCmd[resource.GettableResource])(nil) +var _ ModelCmdInterface = (*ModelCmd[model.GettableModel])(nil) -func (cmd ResourceCmd[R]) Underlying() resource.Resource { +func (cmd ModelCmd[R]) Underlying() model.Model { var empty R return empty } -type GettableResourceCmd[R resource.GettableResource] struct { - Get ResourceGetCmd[R] `cmd:"" help:"Inspect a ${name}." aliases:"inspect,show"` +type GettableModelCmd[R model.GettableModel] struct { + Get ModelGetCmd[R] `cmd:"" help:"Inspect a ${name}." aliases:"inspect,show"` } -type WaitableResourceCmd[R resource.GettableResource] struct { - Wait ResourceWaitCmd[R] `cmd:"" help:"Wait for ${names} to match a filter."` +type WaitableModelCmd[R model.GettableModel] struct { + Wait ModelWaitCmd[R] `cmd:"" help:"Wait for ${names} to match a filter."` } -type ListableResourceCmd[R resource.GettableListableResource] struct { - List ResourceListCmd[R] `cmd:"" help:"List ${names}." aliases:"ls"` +type ListableModelCmd[R model.GettableListableModel] struct { + List ModelListCmd[R] `cmd:"" help:"List ${names}." aliases:"ls"` } -type DeletableResourceCmd[R resource.DeletableResource] struct { - Delete ResourceRemoveCmd[R] `cmd:"" help:"Remove a ${name}." aliases:"rm,remove"` +type DeletableModelCmd[R model.DeletableModel] struct { + Delete ModelRemoveCmd[R] `cmd:"" help:"Remove a ${name}." aliases:"rm,remove"` } -// BulkDeletableResourceCmd exposes a `delete` command which supports deleting +// BulkDeletableModelCmd exposes a `delete` command which supports deleting // resources by name as well as bulk deletion via `--all` and `--filter`. // -// We keep this separate from DeletableResourceCmd so that resources which are +// We keep this separate from DeletableModelCmd so that resources which are // deletable but not listable can still embed delete-by-name behavior. -type BulkDeletableResourceCmd[R interface { - resource.DeletableResource - resource.ListableResource +type BulkDeletableModelCmd[R interface { + model.DeletableModel + model.ListableModel }] struct { - Delete ResourceBulkRemoveCmd[R] `cmd:"" help:"Remove a ${name}." aliases:"rm,remove"` + Delete ModelBulkRemoveCmd[R] `cmd:"" help:"Remove a ${name}." aliases:"rm,remove"` } -type EditableResourceCmd[R resource.EditableResource] struct { - Edit ResourceEditCmd[R] `cmd:"" help:"Edit a ${name}."` +type EditableModelCmd[R model.EditableModel] struct { + Edit ModelEditCmd[R] `cmd:"" help:"Edit a ${name}."` } -type CreatableResourceCmd[R resource.CreatableResource] struct { - Create ResourceCreateCmd[R] `cmd:"" help:"Create a ${name}."` +type CreatableModelCmd[R model.CreatableModel] struct { + Create ModelCreateCmd[R] `cmd:"" help:"Create a ${name}."` } -func (cmd ResourceCmd[R]) HelpSections() []kingkong.HelpSection { +func (cmd ModelCmd[R]) HelpSections() []kingkong.HelpSection { var r R fields, err := r.Fields(context.Background()) if err != nil { panic(err) } - fields, err = SelectFields(fields, true, resource.FieldVerbosityInvisible, nil) + fields, err = SelectFields(fields, true, model.FieldVerbosityInvisible, nil) if err != nil { panic(err) } @@ -95,7 +95,7 @@ func (cmd ResourceCmd[R]) HelpSections() []kingkong.HelpSection { paths := []string{} base := kingkong.DimmedColor(field.Name) paths = append(paths, base) - for path := range resource.IterFields(field.Subfields) { + for path := range model.IterFields(field.Subfields) { parts := make([]string, 0, len(path)+1) parts = append(parts, base) for _, part := range path { @@ -117,9 +117,9 @@ func (cmd ResourceCmd[R]) HelpSections() []kingkong.HelpSection { } } -func (cmd ResourceCmd[R]) Examples() []kingkong.Example { +func (cmd ModelCmd[R]) Examples() []kingkong.Example { var r R - if ep, ok := any(r).(ExampledResource); ok { + if ep, ok := any(r).(ExampledModel); ok { return ep.Examples()[CmdTypeNone] // top-level } return nil @@ -131,7 +131,7 @@ type FormatOpts struct { Output Printer `short:"o" help:"Output format. One of: kv, table, json, yaml, raw, quiet, template."` } -type ResourceListCmd[R resource.GettableListableResource] struct { +type ModelListCmd[R model.GettableListableModel] struct { Targets []string `arg:"" name:"target" optional:"" completion-predictor:"resource-key-${name}" help:"Target ${names} to list."` Filter []string `help:"Filter output based on a field value." example:"name==my-instance,metro==fra" sep:"none"` Watch *time.Duration `short:"w" help:"Watch for changes and refresh output. Defaults to 2s." type:"optional" placeholder:"duration"` @@ -145,19 +145,19 @@ type ResourceListCmd[R resource.GettableListableResource] struct { DefaultFilter filters.Filter `kong:"-"` } -func (cmd ResourceListCmd[R]) HelpSections() []kingkong.HelpSection { - return ResourceCmd[R]{}.HelpSections() +func (cmd ModelListCmd[R]) HelpSections() []kingkong.HelpSection { + return ModelCmd[R]{}.HelpSections() } -func (cmd ResourceListCmd[R]) Examples() []kingkong.Example { +func (cmd ModelListCmd[R]) Examples() []kingkong.Example { var r R - if ep, ok := any(r).(ExampledResource); ok { + if ep, ok := any(r).(ExampledModel); ok { return ep.Examples()[CmdTypeList] } return nil } -func (cmd *ResourceListCmd[R]) Run(ctx context.Context, stdio config.Stdio, partition *resource.Partition) error { +func (cmd *ModelListCmd[R]) Run(ctx context.Context, stdio config.Stdio, partition *model.Partition) error { filter, err := filters.ParseAll(cmd.Filter...) if err != nil { return err @@ -165,7 +165,7 @@ func (cmd *ResourceListCmd[R]) Run(ctx context.Context, stdio config.Stdio, part if cmd.DefaultFilter != nil { filter = filters.All{cmd.DefaultFilter, filter} } - ctx = resource.WithFilter(ctx, filter) + ctx = model.WithFilter(ctx, filter) sortSpecs, err := parseSortSpecs(cmd.Sort...) if err != nil { @@ -174,7 +174,7 @@ func (cmd *ResourceListCmd[R]) Run(ctx context.Context, stdio config.Stdio, part var empty R render := func(out io.Writer) error { - var resources []resource.Resource + var resources []model.Model var opErr error if len(cmd.Targets) > 0 { r := partition.WrapGettable(empty) @@ -187,13 +187,13 @@ func (cmd *ResourceListCmd[R]) Run(ctx context.Context, stdio config.Stdio, part return opErr } - resources, filterErr := filterResources(ctx, resources, filter) + resources, filterErr := filterModels(ctx, resources, filter) if filterErr != nil { opErr = errors.Join(opErr, filterErr) } if len(sortSpecs) > 0 { - resources, err = sortResources(ctx, resources, sortSpecs) + resources, err = sortModels(ctx, resources, sortSpecs) if err != nil { return errors.Join(opErr, err) } @@ -215,34 +215,34 @@ func (cmd *ResourceListCmd[R]) Run(ctx context.Context, stdio config.Stdio, part return render(stdio.Stdout) } -type ResourceGetCmd[R resource.GettableResource] struct { +type ModelGetCmd[R model.GettableModel] struct { Targets []string `arg:"" name:"target" optional:"" completion-predictor:"resource-key-${name}" help:"Target ${names} to get."` Watch *time.Duration `short:"w" help:"Watch for changes and refresh output. Defaults to 2s." type:"optional" placeholder:"duration"` FormatOpts } -func (cmd ResourceGetCmd[R]) HelpSections() []kingkong.HelpSection { - return ResourceCmd[R]{}.HelpSections() +func (cmd ModelGetCmd[R]) HelpSections() []kingkong.HelpSection { + return ModelCmd[R]{}.HelpSections() } -func (cmd ResourceGetCmd[R]) Examples() []kingkong.Example { +func (cmd ModelGetCmd[R]) Examples() []kingkong.Example { var r R - if ep, ok := any(r).(ExampledResource); ok { + if ep, ok := any(r).(ExampledModel); ok { return ep.Examples()[CmdTypeGet] } return nil } -func (cmd *ResourceGetCmd[R]) Run(ctx context.Context, stdio config.Stdio, partition *resource.Partition) error { +func (cmd *ModelGetCmd[R]) Run(ctx context.Context, stdio config.Stdio, partition *model.Partition) error { var empty R r := partition.WrapGettable(empty) render := func(out io.Writer) error { - var resources []resource.Resource + var resources []model.Model var opErr error if len(cmd.Targets) == 0 { - def, ok := any(empty).(resource.DefaultResource) + def, ok := any(empty).(model.DefaultModel) if !ok { return fmt.Errorf("parsing arguments: no %s specified", empty.Type().Names) } @@ -250,7 +250,7 @@ func (cmd *ResourceGetCmd[R]) Run(ctx context.Context, stdio config.Stdio, parti if err != nil { return err } - resources = []resource.Resource{res} + resources = []model.Model{res} } else { resources, opErr = r.Get(ctx, cmd.Targets) if opErr != nil && len(resources) == 0 { @@ -273,7 +273,7 @@ func (cmd *ResourceGetCmd[R]) Run(ctx context.Context, stdio config.Stdio, parti return render(stdio.Stdout) } -type ResourceWaitCmd[R resource.GettableResource] struct { +type ModelWaitCmd[R model.GettableModel] struct { Targets []string `arg:"" name:"target" completion-predictor:"resource-key-${name}" help:"Target ${names} to wait for."` Until []string `help:"Filter expression to wait for." example:"state==running,state!=stopped" sep:"none" required:"" aliases:"filter"` @@ -282,19 +282,19 @@ type ResourceWaitCmd[R resource.GettableResource] struct { FormatOpts } -func (cmd ResourceWaitCmd[R]) HelpSections() []kingkong.HelpSection { - return ResourceCmd[R]{}.HelpSections() +func (cmd ModelWaitCmd[R]) HelpSections() []kingkong.HelpSection { + return ModelCmd[R]{}.HelpSections() } -func (cmd ResourceWaitCmd[R]) Examples() []kingkong.Example { +func (cmd ModelWaitCmd[R]) Examples() []kingkong.Example { var r R - if ep, ok := any(r).(ExampledResource); ok { + if ep, ok := any(r).(ExampledModel); ok { return ep.Examples()[CmdTypeWait] } return nil } -func (cmd *ResourceWaitCmd[R]) Run(ctx context.Context, stdio config.Stdio, partition *resource.Partition) error { +func (cmd *ModelWaitCmd[R]) Run(ctx context.Context, stdio config.Stdio, partition *model.Partition) error { var empty R if len(cmd.Targets) == 0 { return fmt.Errorf("no %s specified", empty.Type().Names) @@ -305,7 +305,7 @@ func (cmd *ResourceWaitCmd[R]) Run(ctx context.Context, stdio config.Stdio, part if err != nil { return err } - ctx = resource.WithFilter(ctx, filter) + ctx = model.WithFilter(ctx, filter) ticker := time.NewTicker(cmd.Interval) defer ticker.Stop() @@ -317,7 +317,7 @@ func (cmd *ResourceWaitCmd[R]) Run(ctx context.Context, stdio config.Stdio, part return err } - filtered, err := filterResources(ctx, resources, filter) + filtered, err := filterModels(ctx, resources, filter) if err != nil { return err } @@ -366,7 +366,7 @@ func (cmd *ResourceWaitCmd[R]) Run(ctx context.Context, stdio config.Stdio, part } } -func filterResources(ctx context.Context, resources []resource.Resource, filter filters.Filter) (filtered []resource.Resource, rerr error) { +func filterModels(ctx context.Context, resources []model.Model, filter filters.Filter) (filtered []model.Model, rerr error) { if filter == nil { return resources, nil } @@ -376,12 +376,12 @@ func filterResources(ctx context.Context, resources []resource.Resource, filter if len(filterKeys) == 0 { return resources, nil } - filterPaths := make([]resource.FieldPath, len(filterKeys)) + filterPaths := make([]model.FieldPath, len(filterKeys)) for i, key := range filterKeys { - filterPaths[i] = resource.FieldPath(key) + filterPaths[i] = model.FieldPath(key) } - resolved, err := resolveResources(ctx, resources, filterPaths) + resolved, err := resolveModels(ctx, resources, filterPaths) if err != nil { return nil, err } @@ -409,25 +409,25 @@ func filterResources(ctx context.Context, resources []resource.Resource, filter return filtered, rerr } -type ResourceRemoveCmd[R resource.DeletableResource] struct { +type ModelRemoveCmd[R model.DeletableModel] struct { Targets []string `arg:"" name:"target" completion-predictor:"resource-key-${name}" help:"Target ${names} to remove."` FormatOpts } -func (cmd ResourceRemoveCmd[R]) HelpSections() []kingkong.HelpSection { - return ResourceCmd[R]{}.HelpSections() +func (cmd ModelRemoveCmd[R]) HelpSections() []kingkong.HelpSection { + return ModelCmd[R]{}.HelpSections() } -func (cmd ResourceRemoveCmd[R]) Examples() []kingkong.Example { +func (cmd ModelRemoveCmd[R]) Examples() []kingkong.Example { var r R - if ep, ok := any(r).(ExampledResource); ok { + if ep, ok := any(r).(ExampledModel); ok { return ep.Examples()[CmdTypeDelete] } return nil } -func (cmd *ResourceRemoveCmd[R]) Run(ctx context.Context, stdio config.Stdio, partition *resource.Partition) error { +func (cmd *ModelRemoveCmd[R]) Run(ctx context.Context, stdio config.Stdio, partition *model.Partition) error { var empty R r := partition.WrapDeletable(empty) @@ -450,7 +450,7 @@ func (cmd *ResourceRemoveCmd[R]) Run(ctx context.Context, stdio config.Stdio, pa for _, ref := range notFound.Refs { missing[multimetro.Key(ref).String()] = struct{}{} } - toPrint = slices.DeleteFunc(slices.Clone(resources), func(r resource.Resource) bool { + toPrint = slices.DeleteFunc(slices.Clone(resources), func(r model.Model) bool { _, ok := missing[r.Key().String()] return ok }) @@ -469,9 +469,9 @@ func (cmd *ResourceRemoveCmd[R]) Run(ctx context.Context, stdio config.Stdio, pa return errors.Join(getErr, deleteErr) } -type ResourceBulkRemoveCmd[R interface { - resource.DeletableResource - resource.ListableResource +type ModelBulkRemoveCmd[R interface { + model.DeletableModel + model.ListableModel }] struct { Targets []string `arg:"" name:"target" optional:"" completion-predictor:"resource-key-${name}" help:"Target ${names} to remove."` @@ -482,21 +482,21 @@ type ResourceBulkRemoveCmd[R interface { FormatOpts } -func (cmd ResourceBulkRemoveCmd[R]) HelpSections() []kingkong.HelpSection { - return ResourceCmd[R]{}.HelpSections() +func (cmd ModelBulkRemoveCmd[R]) HelpSections() []kingkong.HelpSection { + return ModelCmd[R]{}.HelpSections() } -func (cmd ResourceBulkRemoveCmd[R]) Examples() []kingkong.Example { +func (cmd ModelBulkRemoveCmd[R]) Examples() []kingkong.Example { var r R - if ep, ok := any(r).(ExampledResource); ok { + if ep, ok := any(r).(ExampledModel); ok { return ep.Examples()[CmdTypeDelete] } return nil } -func (cmd *ResourceBulkRemoveCmd[R]) Run(ctx context.Context, stdio config.Stdio, partition *resource.Partition) error { +func (cmd *ModelBulkRemoveCmd[R]) Run(ctx context.Context, stdio config.Stdio, partition *model.Partition) error { var empty R - var resources []resource.Resource + var resources []model.Model if cmd.All || len(cmd.Filter) > 0 { if len(cmd.Targets) > 0 { // would be nice if xor groups could enforce this @@ -515,11 +515,11 @@ func (cmd *ResourceBulkRemoveCmd[R]) Run(ctx context.Context, stdio config.Stdio } if filter != nil { - resources, err = filterResources(ctx, resources, filter) + resources, err = filterModels(ctx, resources, filter) if err != nil { return err } - resources = unwrapResources(resources) + resources = unwrapModels(resources) } if !cmd.Force && len(resources) > 0 { @@ -593,7 +593,7 @@ func (cmd *ResourceBulkRemoveCmd[R]) Run(ctx context.Context, stdio config.Stdio } } -type ResourceEditCmd[R resource.EditableResource] struct { +type ModelEditCmd[R model.EditableModel] struct { Target string `arg:"" name:"target" completion-predictor:"resource-key-${name}" help:"Target ${name} to edit."` generated *FlagSet @@ -612,19 +612,19 @@ type ResourceEditCmd[R resource.EditableResource] struct { FormatOpts } -func (cmd ResourceEditCmd[R]) HelpSections() []kingkong.HelpSection { - return ResourceCmd[R]{}.HelpSections() +func (cmd ModelEditCmd[R]) HelpSections() []kingkong.HelpSection { + return ModelCmd[R]{}.HelpSections() } -func (cmd ResourceEditCmd[R]) Examples() []kingkong.Example { +func (cmd ModelEditCmd[R]) Examples() []kingkong.Example { var r R - if ep, ok := any(r).(ExampledResource); ok { + if ep, ok := any(r).(ExampledModel); ok { return ep.Examples()[CmdTypeEdit] } return nil } -func (cmd *ResourceEditCmd[R]) toPatchSpec() (patch.PatchSpec, error) { +func (cmd *ModelEditCmd[R]) toPatchSpec() (patch.PatchSpec, error) { spec := patch.PatchSpec{ Set: make(map[string][]string), Add: make(map[string][]string), @@ -642,7 +642,7 @@ func (cmd *ResourceEditCmd[R]) toPatchSpec() (patch.PatchSpec, error) { return spec, cmd.generated.Apply(&spec) } -func (cmd *ResourceEditCmd[R]) BuildFlags(opts ...kong.Option) (*FlagSet, error) { +func (cmd *ModelEditCmd[R]) BuildFlags(opts ...kong.Option) (*FlagSet, error) { var empty R fields, err := empty.Fields(context.Background()) if err != nil { @@ -652,7 +652,7 @@ func (cmd *ResourceEditCmd[R]) BuildFlags(opts ...kong.Option) (*FlagSet, error) return cmd.generated, err } -func (cmd *ResourceEditCmd[R]) Run(ctx context.Context, stdio config.Stdio, partition *resource.Partition) error { +func (cmd *ModelEditCmd[R]) Run(ctx context.Context, stdio config.Stdio, partition *model.Partition) error { spec, err := cmd.toPatchSpec() if err != nil { return err @@ -661,9 +661,9 @@ func (cmd *ResourceEditCmd[R]) Run(ctx context.Context, stdio config.Stdio, part var empty R r := partition.WrapEditable(empty) - var res resource.Resource + var res model.Model if cmd.Target == "" { - def, ok := any(empty).(resource.DefaultResource) + def, ok := any(empty).(model.DefaultModel) if !ok { return fmt.Errorf("parsing arguments: no %s specified", empty.Type().Names) } @@ -744,7 +744,7 @@ func (cmd *ResourceEditCmd[R]) Run(ctx context.Context, stdio config.Stdio, part return PrintPatches(stdio.Stdout, patched, false) } - updated := []resource.Resource{res} + updated := []model.Model{res} if len(patched) > 0 { editKey := res.Key().String() if err := r.Edit(ctx, editKey, patched); err != nil { @@ -767,10 +767,10 @@ func (cmd *ResourceEditCmd[R]) Run(ctx context.Context, stdio config.Stdio, part Str("resource", res.Key().String()). Msg("no edits made") } - return Diff(ctx, stdio.Stdout, cmd.FormatOpts, empty, []resource.Resource{res}, updated) + return Diff(ctx, stdio.Stdout, cmd.FormatOpts, empty, []model.Model{res}, updated) } -type ResourceCreateCmd[R resource.CreatableResource] struct { +type ModelCreateCmd[R model.CreatableModel] struct { SetArgs generated *FlagSet @@ -785,19 +785,19 @@ type ResourceCreateCmd[R resource.CreatableResource] struct { FormatOpts } -func (cmd ResourceCreateCmd[R]) HelpSections() []kingkong.HelpSection { - return ResourceCmd[R]{}.HelpSections() +func (cmd ModelCreateCmd[R]) HelpSections() []kingkong.HelpSection { + return ModelCmd[R]{}.HelpSections() } -func (cmd ResourceCreateCmd[R]) Examples() []kingkong.Example { +func (cmd ModelCreateCmd[R]) Examples() []kingkong.Example { var r R - if ep, ok := any(r).(ExampledResource); ok { + if ep, ok := any(r).(ExampledModel); ok { return ep.Examples()[CmdTypeCreate] } return nil } -func (cmd *ResourceCreateCmd[R]) BuildFlags(opts ...kong.Option) (*FlagSet, error) { +func (cmd *ModelCreateCmd[R]) BuildFlags(opts ...kong.Option) (*FlagSet, error) { var empty R fields, err := empty.Fields(context.Background()) if err != nil { @@ -808,11 +808,11 @@ func (cmd *ResourceCreateCmd[R]) BuildFlags(opts ...kong.Option) (*FlagSet, erro } // GeneratedFlags returns the flags built from the resource's schema. -func (cmd *ResourceCreateCmd[R]) GeneratedFlags() *FlagSet { +func (cmd *ModelCreateCmd[R]) GeneratedFlags() *FlagSet { return cmd.generated } -func (cmd *ResourceCreateCmd[R]) toPatchSpec() (patch.PatchSpec, error) { +func (cmd *ModelCreateCmd[R]) toPatchSpec() (patch.PatchSpec, error) { spec := patch.PatchSpec{ Create: true, Set: make(map[string][]string), @@ -823,12 +823,12 @@ func (cmd *ResourceCreateCmd[R]) toPatchSpec() (patch.PatchSpec, error) { return spec, cmd.generated.Apply(&spec) } -func (cmd *ResourceCreateCmd[R]) Run(ctx context.Context, stdio config.Stdio, partition *resource.Partition) error { - _, err := cmd.RunResources(ctx, stdio, partition) +func (cmd *ModelCreateCmd[R]) Run(ctx context.Context, stdio config.Stdio, partition *model.Partition) error { + _, err := cmd.RunModels(ctx, stdio, partition) return err } -func (cmd *ResourceCreateCmd[R]) RunResources(ctx context.Context, stdio config.Stdio, partition *resource.Partition) ([]resource.Resource, error) { +func (cmd *ModelCreateCmd[R]) RunModels(ctx context.Context, stdio config.Stdio, partition *model.Partition) ([]model.Model, error) { spec, err := cmd.toPatchSpec() if err != nil { return nil, err @@ -836,9 +836,9 @@ func (cmd *ResourceCreateCmd[R]) RunResources(ctx context.Context, stdio config. var empty R r := partition.WrapCreatable(empty) - fieldsResource := resource.Resource(empty) + fieldsResource := model.Model(empty) if typed, ok := any(empty).(interface { - WithType(string) resource.Resource + WithType(string) model.Model }); ok { if v, ok := spec.SetTyped["type"].(string); ok && v != "" { fieldsResource = typed.WithType(v) @@ -905,7 +905,7 @@ func (cmd *ResourceCreateCmd[R]) RunResources(ctx context.Context, stdio config. } // saveYAML writes YAML to a file or stdout (if filename is "-"). -func saveYAML(filename string, stdio config.Stdio, res resource.Resource, fields, patches []resource.Field, create bool) error { +func saveYAML(filename string, stdio config.Stdio, res model.Model, fields, patches []model.Field, create bool) error { var w io.Writer if filename == "-" { w = stdio.Stdout diff --git a/internal/resource/cmd/cmd_test.go b/internal/model/cmd/cmd_test.go similarity index 81% rename from internal/resource/cmd/cmd_test.go rename to internal/model/cmd/cmd_test.go index 5ebc02e8..373205ec 100644 --- a/internal/resource/cmd/cmd_test.go +++ b/internal/model/cmd/cmd_test.go @@ -26,17 +26,17 @@ import ( "sigs.k8s.io/yaml" "unikraft.com/cli/internal/config" - "unikraft.com/cli/internal/resource" - resourcet "unikraft.com/cli/internal/resource/testing" + "unikraft.com/cli/internal/model" + modelt "unikraft.com/cli/internal/model/testing" "unikraft.com/cli/internal/types" wkong "unikraft.com/cli/internal/w/kong" "unikraft.com/cloud/sdk/platform/group" ) // setupTestEnv creates a TestEnv with standard test data. -func setupTestEnv() *resourcet.TestEnv { - env := resourcet.NewTestEnv() - env.Add(resourcet.TestResource{ +func setupTestEnv() *modelt.TestEnv { + env := modelt.NewTestEnv() + env.Add(modelt.TestModel{ ID: "id-test1", Name: "test1", State: "pending", @@ -44,20 +44,20 @@ func setupTestEnv() *resourcet.TestEnv { Hidden: "hidden-test1", Invisible: "invisible-test1", Created: time.Now().Add(-3 * 24 * time.Hour), - Settings: resourcet.TestSettings{ + Settings: modelt.TestSettings{ Foo: 42, Bar: "hello", Score: 10.0, Flag: true, }, - Authors: []resourcet.TestAuthor{ + Authors: []modelt.TestAuthor{ {Name: "Alice", Email: "alice@example.com"}, {Name: "Bob", Email: "bob@example.com"}, }, Tags: []string{"prod", "web"}, Usage: types.MeterUsage[int]{Used: 70, Total: 100}, }) - env.Add(resourcet.TestResource{ + env.Add(modelt.TestModel{ ID: "id-test2", Name: "test2", State: "pending", @@ -65,12 +65,12 @@ func setupTestEnv() *resourcet.TestEnv { Hidden: "hidden-test2", Invisible: "invisible-test2", Created: time.Now().Add(-40 * 24 * time.Hour), - Settings: resourcet.TestSettings{ + Settings: modelt.TestSettings{ Foo: 7, Bar: "world", Score: 1.2, }, - Authors: []resourcet.TestAuthor{ + Authors: []modelt.TestAuthor{ {Name: "Charlie", Email: "charlie@example.com"}, {Name: "Dana", Email: "dana@example.com"}, }, @@ -82,16 +82,16 @@ func setupTestEnv() *resourcet.TestEnv { func TestList(t *testing.T) { env := setupTestEnv() - ctx := resourcet.WithTestEnv(context.Background(), env) - partition := &resource.Partition{} + ctx := modelt.WithTestEnv(context.Background(), env) + partition := &model.Partition{} - empty := env.NewResource() + empty := env.NewModel() resources, err := empty.List(ctx) require.NoError(t, err) assert.Len(t, resources, 2) var listOut bytes.Buffer - listCmd := &ResourceListCmd[resourcet.TestResource]{ + listCmd := &ModelListCmd[modelt.TestModel]{ Targets: nil, } err = listCmd.Run(ctx, testStdio(&listOut), partition) @@ -105,7 +105,7 @@ func TestList(t *testing.T) { t.Run("field", func(t *testing.T) { var out bytes.Buffer - cmd := &ResourceListCmd[resourcet.TestResource]{ + cmd := &ModelListCmd[modelt.TestModel]{ Field: wkong.GreedyStrings{"name", "id"}, } err := cmd.Run(ctx, testStdio(&out), partition) @@ -129,7 +129,7 @@ func TestList(t *testing.T) { t.Run("field exclude", func(t *testing.T) { var out bytes.Buffer - cmd := &ResourceListCmd[resourcet.TestResource]{ + cmd := &ModelListCmd[modelt.TestModel]{ Field: wkong.GreedyStrings{"-url"}, Output: Printer{Type: PrinterTypeKeyValue}, } @@ -144,7 +144,7 @@ func TestList(t *testing.T) { t.Run("filter", func(t *testing.T) { var out bytes.Buffer - cmd := &ResourceListCmd[resourcet.TestResource]{ + cmd := &ModelListCmd[modelt.TestModel]{ Filter: []string{"name==test1"}, } err := cmd.Run(ctx, testStdio(&out), partition) @@ -166,7 +166,7 @@ func TestList(t *testing.T) { t.Run("filter-wildcard-nested", func(t *testing.T) { var out bytes.Buffer - cmd := &ResourceListCmd[resourcet.TestResource]{ + cmd := &ModelListCmd[modelt.TestModel]{ Filter: []string{"authors.*.email==alice@example.com"}, Output: Printer{Type: PrinterTypeQuiet}, } @@ -198,7 +198,7 @@ func TestList(t *testing.T) { t.Run("filter-indexed-nested", func(t *testing.T) { var out bytes.Buffer - cmd := &ResourceListCmd[resourcet.TestResource]{ + cmd := &ModelListCmd[modelt.TestModel]{ Filter: []string{"authors.0.name==Alice"}, Output: Printer{Type: PrinterTypeQuiet}, } @@ -221,7 +221,7 @@ func TestList(t *testing.T) { t.Run("filter-nested-struct", func(t *testing.T) { var out bytes.Buffer - cmd := &ResourceListCmd[resourcet.TestResource]{ + cmd := &ModelListCmd[modelt.TestModel]{ Filter: []string{"settings.bar==hello"}, Output: Printer{Type: PrinterTypeQuiet}, } @@ -235,7 +235,7 @@ func TestList(t *testing.T) { t.Run("filter-unknown-field", func(t *testing.T) { var out bytes.Buffer - cmd := &ResourceListCmd[resourcet.TestResource]{ + cmd := &ModelListCmd[modelt.TestModel]{ Filter: []string{"nonexistent==value"}, Output: Printer{Type: PrinterTypeQuiet}, } @@ -250,7 +250,7 @@ func TestList(t *testing.T) { t.Run("filter-unknown-field-dedup", func(t *testing.T) { var out bytes.Buffer - cmd := &ResourceListCmd[resourcet.TestResource]{ + cmd := &ModelListCmd[modelt.TestModel]{ Filter: []string{"missing_field==value"}, Output: Printer{Type: PrinterTypeQuiet}, } @@ -266,7 +266,7 @@ func TestList(t *testing.T) { t.Run("filter-unknown-nested-field", func(t *testing.T) { var out bytes.Buffer - cmd := &ResourceListCmd[resourcet.TestResource]{ + cmd := &ModelListCmd[modelt.TestModel]{ Filter: []string{"settings.nonexistent==value"}, Output: Printer{Type: PrinterTypeQuiet}, } @@ -277,7 +277,7 @@ func TestList(t *testing.T) { t.Run("sort-asc", func(t *testing.T) { var out bytes.Buffer - cmd := &ResourceListCmd[resourcet.TestResource]{ + cmd := &ModelListCmd[modelt.TestModel]{ Sort: wkong.GreedyStrings{"name"}, Output: Printer{Type: PrinterTypeQuiet}, } @@ -290,7 +290,7 @@ func TestList(t *testing.T) { t.Run("sort-asc-explicit", func(t *testing.T) { var out bytes.Buffer - cmd := &ResourceListCmd[resourcet.TestResource]{ + cmd := &ModelListCmd[modelt.TestModel]{ Sort: wkong.GreedyStrings{"+name"}, Output: Printer{Type: PrinterTypeQuiet}, } @@ -303,7 +303,7 @@ func TestList(t *testing.T) { t.Run("sort-desc", func(t *testing.T) { var out bytes.Buffer - cmd := &ResourceListCmd[resourcet.TestResource]{ + cmd := &ModelListCmd[modelt.TestModel]{ Sort: wkong.GreedyStrings{"-name"}, Output: Printer{Type: PrinterTypeQuiet}, } @@ -316,7 +316,7 @@ func TestList(t *testing.T) { t.Run("sort-asc-nested", func(t *testing.T) { var out bytes.Buffer - cmd := &ResourceListCmd[resourcet.TestResource]{ + cmd := &ModelListCmd[modelt.TestModel]{ Sort: wkong.GreedyStrings{"settings.bar"}, Output: Printer{Type: PrinterTypeQuiet}, } @@ -331,7 +331,7 @@ func TestList(t *testing.T) { t.Run("sort-desc-nested", func(t *testing.T) { var out bytes.Buffer - cmd := &ResourceListCmd[resourcet.TestResource]{ + cmd := &ModelListCmd[modelt.TestModel]{ Sort: wkong.GreedyStrings{"-settings.bar"}, Output: Printer{Type: PrinterTypeQuiet}, } @@ -348,7 +348,7 @@ func TestList(t *testing.T) { var out bytes.Buffer // Both test1 and test2 have state="pending", so sort by state first, // then by name descending to break the tie - cmd := &ResourceListCmd[resourcet.TestResource]{ + cmd := &ModelListCmd[modelt.TestModel]{ Sort: wkong.GreedyStrings{"state", "-name"}, Output: Printer{Type: PrinterTypeQuiet}, } @@ -364,7 +364,7 @@ func TestList(t *testing.T) { var out bytes.Buffer // Sort by state ascending, then by settings.foo ascending // test1 has settings.foo=42, test2 has settings.foo=7 - cmd := &ResourceListCmd[resourcet.TestResource]{ + cmd := &ModelListCmd[modelt.TestModel]{ Sort: wkong.GreedyStrings{"+state", "settings.foo"}, Output: Printer{Type: PrinterTypeQuiet}, } @@ -393,68 +393,68 @@ func TestParseSortSpecs(t *testing.T) { name: "single field ascending implicit", input: []string{"name"}, expected: []SortSpec{ - {Path: resource.ParseFieldPath("name"), Descending: false}, + {Path: model.ParseFieldPath("name"), Descending: false}, }, }, { name: "single field ascending explicit", input: []string{"+name"}, expected: []SortSpec{ - {Path: resource.ParseFieldPath("name"), Descending: false}, + {Path: model.ParseFieldPath("name"), Descending: false}, }, }, { name: "single field descending", input: []string{"-name"}, expected: []SortSpec{ - {Path: resource.ParseFieldPath("name"), Descending: true}, + {Path: model.ParseFieldPath("name"), Descending: true}, }, }, { name: "nested field", input: []string{"timing.uptime"}, expected: []SortSpec{ - {Path: resource.ParseFieldPath("timing.uptime"), Descending: false}, + {Path: model.ParseFieldPath("timing.uptime"), Descending: false}, }, }, { name: "nested field descending", input: []string{"-timing.uptime"}, expected: []SortSpec{ - {Path: resource.ParseFieldPath("timing.uptime"), Descending: true}, + {Path: model.ParseFieldPath("timing.uptime"), Descending: true}, }, }, { name: "multiple fields", input: []string{"state", "name"}, expected: []SortSpec{ - {Path: resource.ParseFieldPath("state"), Descending: false}, - {Path: resource.ParseFieldPath("name"), Descending: false}, + {Path: model.ParseFieldPath("state"), Descending: false}, + {Path: model.ParseFieldPath("name"), Descending: false}, }, }, { name: "multiple fields mixed directions", input: []string{"state", "-timing.uptime"}, expected: []SortSpec{ - {Path: resource.ParseFieldPath("state"), Descending: false}, - {Path: resource.ParseFieldPath("timing.uptime"), Descending: true}, + {Path: model.ParseFieldPath("state"), Descending: false}, + {Path: model.ParseFieldPath("timing.uptime"), Descending: true}, }, }, { name: "multiple fields with explicit prefix", input: []string{"+state", "-name", "+id"}, expected: []SortSpec{ - {Path: resource.ParseFieldPath("state"), Descending: false}, - {Path: resource.ParseFieldPath("name"), Descending: true}, - {Path: resource.ParseFieldPath("id"), Descending: false}, + {Path: model.ParseFieldPath("state"), Descending: false}, + {Path: model.ParseFieldPath("name"), Descending: true}, + {Path: model.ParseFieldPath("id"), Descending: false}, }, }, { name: "with spaces", input: []string{" state ", " -name "}, expected: []SortSpec{ - {Path: resource.ParseFieldPath("state"), Descending: false}, - {Path: resource.ParseFieldPath("name"), Descending: true}, + {Path: model.ParseFieldPath("state"), Descending: false}, + {Path: model.ParseFieldPath("name"), Descending: true}, }, }, { @@ -466,8 +466,8 @@ func TestParseSortSpecs(t *testing.T) { name: "empty field in list", input: []string{"state", "", "name"}, expected: []SortSpec{ - {Path: resource.ParseFieldPath("state"), Descending: false}, - {Path: resource.ParseFieldPath("name"), Descending: false}, + {Path: model.ParseFieldPath("state"), Descending: false}, + {Path: model.ParseFieldPath("name"), Descending: false}, }, }, } @@ -487,13 +487,13 @@ func TestParseSortSpecs(t *testing.T) { func TestListOutput(t *testing.T) { env := setupTestEnv() - ctx := resourcet.WithTestEnv(context.Background(), env) - partition := &resource.Partition{} + ctx := modelt.WithTestEnv(context.Background(), env) + partition := &model.Partition{} runList := func(t *testing.T, opts FormatOpts) string { t.Helper() var out bytes.Buffer - cmd := &ResourceListCmd[resourcet.TestResource]{ + cmd := &ModelListCmd[modelt.TestModel]{ FormatOpts: opts, } err := cmd.Run(ctx, testStdio(&out), partition) @@ -580,7 +580,7 @@ func TestListOutput(t *testing.T) { t.Run("raw", func(t *testing.T) { output := runList(t, FormatOpts{Output: Printer{Type: PrinterTypeRaw}}) - var resources []resourcet.TestResource + var resources []modelt.TestModel err := json.Unmarshal([]byte(output), &resources) require.NoError(t, err) require.Len(t, resources, 2) @@ -617,14 +617,14 @@ func TestListOutput(t *testing.T) { func TestPartialResultsPrintedBeforeError(t *testing.T) { t.Run("list", func(t *testing.T) { env := setupTestEnv() - env.Hooks.List = func(ctx context.Context, next func(context.Context) ([]resource.Resource, error)) ([]resource.Resource, error) { + env.Hooks.List = func(ctx context.Context, next func(context.Context) ([]model.Model, error)) ([]model.Model, error) { resources, _ := next(ctx) return resources, errors.New("list failed") } - ctx := resourcet.WithTestEnv(context.Background(), env) + ctx := modelt.WithTestEnv(context.Background(), env) var out bytes.Buffer - cmd := &ResourceListCmd[resourcet.TestResource]{ + cmd := &ModelListCmd[modelt.TestModel]{ Output: Printer{Type: PrinterTypeQuiet}, } err := cmd.Run(ctx, testStdio(&out), nil) @@ -635,14 +635,14 @@ func TestPartialResultsPrintedBeforeError(t *testing.T) { t.Run("get", func(t *testing.T) { env := setupTestEnv() - env.Hooks.Get = func(ctx context.Context, keys []string, next func(context.Context, []string) ([]resource.Resource, error)) ([]resource.Resource, error) { + env.Hooks.Get = func(ctx context.Context, keys []string, next func(context.Context, []string) ([]model.Model, error)) ([]model.Model, error) { resources, _ := next(ctx, keys) return resources, errors.New("get failed") } - ctx := resourcet.WithTestEnv(context.Background(), env) + ctx := modelt.WithTestEnv(context.Background(), env) var out bytes.Buffer - cmd := &ResourceGetCmd[resourcet.TestResource]{ + cmd := &ModelGetCmd[modelt.TestModel]{ Targets: []string{"test1", "missing"}, Output: Printer{Type: PrinterTypeQuiet}, } @@ -653,15 +653,15 @@ func TestPartialResultsPrintedBeforeError(t *testing.T) { }) t.Run("create", func(t *testing.T) { - env := resourcet.NewTestEnv() - env.Hooks.Create = func(ctx context.Context, fields []resource.Field, next func(context.Context, []resource.Field) ([]resource.Resource, error)) ([]resource.Resource, error) { + env := modelt.NewTestEnv() + env.Hooks.Create = func(ctx context.Context, fields []model.Field, next func(context.Context, []model.Field) ([]model.Model, error)) ([]model.Model, error) { resources, _ := next(ctx, fields) return resources, errors.New("create failed") } - ctx := resourcet.WithTestEnv(context.Background(), env) + ctx := modelt.WithTestEnv(context.Background(), env) var out bytes.Buffer - cmd := &ResourceCreateCmd[resourcet.TestResource]{ + cmd := &ModelCreateCmd[modelt.TestModel]{ Set: []map[string]string{{"name": "created"}}, Output: Printer{Type: PrinterTypeQuiet}, } @@ -671,16 +671,16 @@ func TestPartialResultsPrintedBeforeError(t *testing.T) { }) t.Run("delete", func(t *testing.T) { - env := resourcet.NewTestEnv() - env.Add(resourcet.TestResource{Name: "ok", ID: "id-ok"}) - env.Add(resourcet.TestResource{Name: "fail", ID: "id-fail"}) + env := modelt.NewTestEnv() + env.Add(modelt.TestModel{Name: "ok", ID: "id-ok"}) + env.Add(modelt.TestModel{Name: "fail", ID: "id-fail"}) env.Hooks.Delete = func(ctx context.Context, _ []string, _ func(context.Context, []string) error) error { return group.ErrRefNotFound{Refs: group.Refs{{Name: "fail"}}} } - ctx := resourcet.WithTestEnv(context.Background(), env) + ctx := modelt.WithTestEnv(context.Background(), env) var out bytes.Buffer - cmd := &ResourceRemoveCmd[resourcet.TestResource]{ + cmd := &ModelRemoveCmd[modelt.TestModel]{ Targets: []string{"ok", "fail"}, Output: Printer{Type: PrinterTypeQuiet}, } @@ -695,14 +695,14 @@ func TestPartialResultsPrintedBeforeError(t *testing.T) { func TestPartialResultsOrderWhenCallerPrintsError(t *testing.T) { t.Run("list/table", func(t *testing.T) { env := setupTestEnv() - env.Hooks.List = func(ctx context.Context, next func(context.Context) ([]resource.Resource, error)) ([]resource.Resource, error) { + env.Hooks.List = func(ctx context.Context, next func(context.Context) ([]model.Model, error)) ([]model.Model, error) { resources, _ := next(ctx) return resources, errors.New("list failed") } - ctx := resourcet.WithTestEnv(context.Background(), env) + ctx := modelt.WithTestEnv(context.Background(), env) var out bytes.Buffer - cmd := &ResourceListCmd[resourcet.TestResource]{ + cmd := &ModelListCmd[modelt.TestModel]{ Output: Printer{Type: PrinterTypeTable}, } err := cmd.Run(ctx, testStdio(&out), nil) @@ -718,14 +718,14 @@ func TestPartialResultsOrderWhenCallerPrintsError(t *testing.T) { t.Run("get/kv", func(t *testing.T) { env := setupTestEnv() - env.Hooks.Get = func(ctx context.Context, keys []string, next func(context.Context, []string) ([]resource.Resource, error)) ([]resource.Resource, error) { + env.Hooks.Get = func(ctx context.Context, keys []string, next func(context.Context, []string) ([]model.Model, error)) ([]model.Model, error) { resources, _ := next(ctx, keys) return resources, errors.New("get failed") } - ctx := resourcet.WithTestEnv(context.Background(), env) + ctx := modelt.WithTestEnv(context.Background(), env) var out bytes.Buffer - cmd := &ResourceGetCmd[resourcet.TestResource]{ + cmd := &ModelGetCmd[modelt.TestModel]{ Targets: []string{"test1", "missing"}, Output: Printer{Type: PrinterTypeKeyValue}, } @@ -744,16 +744,16 @@ func TestPartialResultsOrderWhenCallerPrintsError(t *testing.T) { }) t.Run("delete/quiet", func(t *testing.T) { - env := resourcet.NewTestEnv() - env.Add(resourcet.TestResource{Name: "ok", ID: "id-ok"}) - env.Add(resourcet.TestResource{Name: "fail", ID: "id-fail"}) + env := modelt.NewTestEnv() + env.Add(modelt.TestModel{Name: "ok", ID: "id-ok"}) + env.Add(modelt.TestModel{Name: "fail", ID: "id-fail"}) env.Hooks.Delete = func(ctx context.Context, _ []string, _ func(context.Context, []string) error) error { return group.ErrRefNotFound{Refs: group.Refs{{Name: "fail"}}} } - ctx := resourcet.WithTestEnv(context.Background(), env) + ctx := modelt.WithTestEnv(context.Background(), env) var out bytes.Buffer - cmd := &ResourceRemoveCmd[resourcet.TestResource]{ + cmd := &ModelRemoveCmd[modelt.TestModel]{ Targets: []string{"ok", "fail"}, Output: Printer{Type: PrinterTypeQuiet}, } @@ -772,15 +772,15 @@ func TestPartialResultsOrderWhenCallerPrintsError(t *testing.T) { }) t.Run("create/kv", func(t *testing.T) { - env := resourcet.NewTestEnv() - env.Hooks.Create = func(ctx context.Context, fields []resource.Field, next func(context.Context, []resource.Field) ([]resource.Resource, error)) ([]resource.Resource, error) { + env := modelt.NewTestEnv() + env.Hooks.Create = func(ctx context.Context, fields []model.Field, next func(context.Context, []model.Field) ([]model.Model, error)) ([]model.Model, error) { resources, _ := next(ctx, fields) return resources, errors.New("create failed") } - ctx := resourcet.WithTestEnv(context.Background(), env) + ctx := modelt.WithTestEnv(context.Background(), env) var out bytes.Buffer - cmd := &ResourceCreateCmd[resourcet.TestResource]{ + cmd := &ModelCreateCmd[modelt.TestModel]{ Set: []map[string]string{{"name": "created"}}, Output: Printer{Type: PrinterTypeKeyValue}, } @@ -801,11 +801,11 @@ func TestPartialResultsOrderWhenCallerPrintsError(t *testing.T) { func TestTableNestedFieldSelection(t *testing.T) { env := setupTestEnv() - ctx := resourcet.WithTestEnv(context.Background(), env) - partition := &resource.Partition{} + ctx := modelt.WithTestEnv(context.Background(), env) + partition := &model.Partition{} var out bytes.Buffer - cmd := &ResourceGetCmd[resourcet.TestResource]{ + cmd := &ModelGetCmd[modelt.TestModel]{ Targets: []string{"test1"}, Output: Printer{Type: PrinterTypeTable}, Field: wkong.GreedyStrings{"name", "authors"}, @@ -820,15 +820,15 @@ func TestTableNestedFieldSelection(t *testing.T) { func TestGet(t *testing.T) { env := setupTestEnv() - ctx := resourcet.WithTestEnv(context.Background(), env) - partition := &resource.Partition{} + ctx := modelt.WithTestEnv(context.Background(), env) + partition := &model.Partition{} - empty := env.NewResource() + empty := env.NewModel() resources, err := empty.Get(ctx, []string{"test1"}) require.NoError(t, err) require.Len(t, resources, 1) - test := resources[0].(resourcet.TestResource) + test := resources[0].(modelt.TestModel) assert.Equal(t, "test1", test.Name) assert.Equal(t, "id-test1", test.ID) assert.Equal(t, 42, test.Settings.Foo) @@ -839,7 +839,7 @@ func TestGet(t *testing.T) { assert.NotEmpty(t, fields) var inspectOut bytes.Buffer - inspectCmd := &ResourceGetCmd[resourcet.TestResource]{ + inspectCmd := &ModelGetCmd[modelt.TestModel]{ Targets: []string{"test1"}, } err = inspectCmd.Run(ctx, testStdio(&inspectOut), partition) @@ -852,7 +852,7 @@ func TestGet(t *testing.T) { t.Run("no_args", func(t *testing.T) { var out bytes.Buffer - cmd := &ResourceGetCmd[resourcet.TestResource]{ + cmd := &ModelGetCmd[modelt.TestModel]{ Targets: []string{}, } err := cmd.Run(ctx, testStdio(&out), partition) @@ -861,7 +861,7 @@ func TestGet(t *testing.T) { t.Run("multiple", func(t *testing.T) { var out bytes.Buffer - cmd := &ResourceGetCmd[resourcet.TestResource]{ + cmd := &ModelGetCmd[modelt.TestModel]{ Targets: []string{"test1", "test2"}, } err := cmd.Run(ctx, testStdio(&out), partition) @@ -876,7 +876,7 @@ func TestGet(t *testing.T) { t.Run("field", func(t *testing.T) { var out bytes.Buffer - cmd := &ResourceGetCmd[resourcet.TestResource]{ + cmd := &ModelGetCmd[modelt.TestModel]{ Targets: []string{"test1"}, Field: wkong.GreedyStrings{"id", "url"}, } @@ -902,13 +902,13 @@ func TestGet(t *testing.T) { func TestFieldVerbosity(t *testing.T) { env := setupTestEnv() - ctx := resourcet.WithTestEnv(context.Background(), env) - partition := &resource.Partition{} + ctx := modelt.WithTestEnv(context.Background(), env) + partition := &model.Partition{} runList := func(t *testing.T, fields []string) string { t.Helper() var out bytes.Buffer - cmd := &ResourceListCmd[resourcet.TestResource]{ + cmd := &ModelListCmd[modelt.TestModel]{ Field: fields, } err := cmd.Run(ctx, testStdio(&out), partition) @@ -919,7 +919,7 @@ func TestFieldVerbosity(t *testing.T) { runInspect := func(t *testing.T, fields []string) string { t.Helper() var out bytes.Buffer - cmd := &ResourceGetCmd[resourcet.TestResource]{ + cmd := &ModelGetCmd[modelt.TestModel]{ Targets: []string{"test1"}, Field: fields, } @@ -968,13 +968,13 @@ func TestFieldVerbosity(t *testing.T) { func TestGetOutput(t *testing.T) { env := setupTestEnv() - ctx := resourcet.WithTestEnv(context.Background(), env) - partition := &resource.Partition{} + ctx := modelt.WithTestEnv(context.Background(), env) + partition := &model.Partition{} runInspect := func(t *testing.T, printer Printer) string { t.Helper() var out bytes.Buffer - cmd := &ResourceGetCmd[resourcet.TestResource]{ + cmd := &ModelGetCmd[modelt.TestModel]{ Targets: []string{"test1", "test2"}, Output: printer, } @@ -992,23 +992,23 @@ func TestGetOutput(t *testing.T) { } func TestWait(t *testing.T) { - partition := &resource.Partition{} + partition := &model.Partition{} t.Run("already_matching", func(t *testing.T) { - env := resourcet.NewTestEnv() - env.Add(resourcet.TestResource{ + env := modelt.NewTestEnv() + env.Add(modelt.TestModel{ ID: "id-test1", Name: "test1", State: "ready", }) - env.Add(resourcet.TestResource{ + env.Add(modelt.TestModel{ ID: "id-test2", Name: "test2", State: "ready", }) - ctx := resourcet.WithTestEnv(context.Background(), env) + ctx := modelt.WithTestEnv(context.Background(), env) - cmd := &ResourceWaitCmd[resourcet.TestResource]{ + cmd := &ModelWaitCmd[modelt.TestModel]{ Targets: []string{"test1", "test2"}, Until: []string{"state==ready"}, Interval: 10 * time.Millisecond, @@ -1019,11 +1019,11 @@ func TestWait(t *testing.T) { t.Run("timeout", func(t *testing.T) { env := setupTestEnv() - ctx := resourcet.WithTestEnv(context.Background(), env) + ctx := modelt.WithTestEnv(context.Background(), env) ctx, cancel := context.WithTimeout(ctx, 1*time.Second) defer cancel() - cmd := &ResourceWaitCmd[resourcet.TestResource]{ + cmd := &ModelWaitCmd[modelt.TestModel]{ Targets: []string{"test1", "test2"}, Until: []string{"state==ready"}, Interval: 10 * time.Millisecond, @@ -1035,24 +1035,24 @@ func TestWait(t *testing.T) { } func TestWaitOutput(t *testing.T) { - env := resourcet.NewTestEnv() - env.Add(resourcet.TestResource{ + env := modelt.NewTestEnv() + env.Add(modelt.TestModel{ ID: "id-test1", Name: "test1", State: "ready", }) - env.Add(resourcet.TestResource{ + env.Add(modelt.TestModel{ ID: "id-test2", Name: "test2", State: "ready", }) - ctx := resourcet.WithTestEnv(context.Background(), env) - partition := &resource.Partition{} + ctx := modelt.WithTestEnv(context.Background(), env) + partition := &model.Partition{} runWait := func(t *testing.T, printer Printer) string { t.Helper() var out bytes.Buffer - cmd := &ResourceWaitCmd[resourcet.TestResource]{ + cmd := &ModelWaitCmd[modelt.TestModel]{ Targets: []string{"test1", "test2"}, Until: []string{"state==ready"}, Interval: 10 * time.Millisecond, @@ -1072,14 +1072,14 @@ func TestWaitOutput(t *testing.T) { } func TestCreate(t *testing.T) { - env := resourcet.NewTestEnv() - ctx := resourcet.WithTestEnv(context.Background(), env) + env := modelt.NewTestEnv() + ctx := modelt.WithTestEnv(context.Background(), env) - empty := env.NewResource() + empty := env.NewModel() templateFields, err := empty.Fields(ctx) require.NoError(t, err) - for key, field := range resource.IterFields(templateFields) { + for key, field := range model.IterFields(templateFields) { if field.Create == nil { continue } @@ -1097,7 +1097,7 @@ func TestCreate(t *testing.T) { require.NoError(t, err) require.Len(t, res, 1) - created := res[0].(resourcet.TestResource) + created := res[0].(modelt.TestModel) assert.Equal(t, "test-new", created.Name) assert.Equal(t, 100, created.Settings.Foo) assert.Equal(t, "created", created.Settings.Bar) @@ -1105,14 +1105,14 @@ func TestCreate(t *testing.T) { } func TestCreateOutput(t *testing.T) { - env := resourcet.NewTestEnv() - ctx := resourcet.WithTestEnv(context.Background(), env) - partition := &resource.Partition{} + env := modelt.NewTestEnv() + ctx := modelt.WithTestEnv(context.Background(), env) + partition := &model.Partition{} runCreate := func(t *testing.T, printer Printer) string { t.Helper() var out bytes.Buffer - cmd := &ResourceCreateCmd[resourcet.TestResource]{ + cmd := &ModelCreateCmd[modelt.TestModel]{ Set: []map[string]string{ {"name": "test-output"}, {"settings.foo": "100"}, @@ -1134,12 +1134,12 @@ func TestCreateOutput(t *testing.T) { } func TestCreateDryRun(t *testing.T) { - env := resourcet.NewTestEnv() - ctx := resourcet.WithTestEnv(context.Background(), env) - partition := &resource.Partition{} + env := modelt.NewTestEnv() + ctx := modelt.WithTestEnv(context.Background(), env) + partition := &model.Partition{} var out bytes.Buffer - cmd := &ResourceCreateCmd[resourcet.TestResource]{ + cmd := &ModelCreateCmd[modelt.TestModel]{ DryRun: true, Set: []map[string]string{ {"name": "test-dry"}, @@ -1170,10 +1170,10 @@ func TestCreateDryRunWithGeneratedZeroValues(t *testing.T) { // so an explicitly-set zero must still reach the patch. run := func(t *testing.T, name string, args ...string) string { t.Helper() - env := resourcet.NewTestEnv() - ctx := resourcet.WithTestEnv(context.Background(), env) + env := modelt.NewTestEnv() + ctx := modelt.WithTestEnv(context.Background(), env) - cmd := &ResourceCreateCmd[resourcet.TestResource]{ + cmd := &ModelCreateCmd[modelt.TestModel]{ DryRun: true, Set: []map[string]string{{"name": name}}, } @@ -1182,7 +1182,7 @@ func TestCreateDryRunWithGeneratedZeroValues(t *testing.T) { require.NoError(t, parseInto(t, set, args...)) var out bytes.Buffer - require.NoError(t, cmd.Run(ctx, testStdio(&out), &resource.Partition{})) + require.NoError(t, cmd.Run(ctx, testStdio(&out), &model.Partition{})) assert.NotContains(t, env.Store, name) return out.String() } @@ -1210,7 +1210,7 @@ func TestCreatePatchSpecFileArgs(t *testing.T) { setFile := tempFile(t, " 123 \n") setTextFile := tempFile(t, " created\n") - cmd := &ResourceCreateCmd[resourcet.TestResource]{ + cmd := &ModelCreateCmd[modelt.TestModel]{ Set: []map[string]string{{"name": "test-inline"}}, SetFile: []map[string]string{ {"name": nameFile}, @@ -1228,16 +1228,16 @@ func TestCreatePatchSpecFileArgs(t *testing.T) { } func TestCreateSetFile(t *testing.T) { - env := resourcet.NewTestEnv() - ctx := resourcet.WithTestEnv(context.Background(), env) - partition := &resource.Partition{} + env := modelt.NewTestEnv() + ctx := modelt.WithTestEnv(context.Background(), env) + partition := &model.Partition{} nameFile := tempFile(t, " test-file ") setFile := tempFile(t, " 101 \n") setTextFile := tempFile(t, " created\n") var out bytes.Buffer - cmd := &ResourceCreateCmd[resourcet.TestResource]{ + cmd := &ModelCreateCmd[modelt.TestModel]{ SetFile: []map[string]string{ {"name": nameFile}, {"settings.foo": setFile}, @@ -1255,19 +1255,19 @@ func TestCreateSetFile(t *testing.T) { } func TestEdit(t *testing.T) { - env := resourcet.NewTestEnv() - env.Add(resourcet.TestResource{ + env := modelt.NewTestEnv() + env.Add(modelt.TestModel{ ID: "id-edit", Name: "test-edit", URL: "https://example.com", - Settings: resourcet.TestSettings{ + Settings: modelt.TestSettings{ Foo: 10, Bar: "original", }, }) - ctx := resourcet.WithTestEnv(context.Background(), env) + ctx := modelt.WithTestEnv(context.Background(), env) - empty := env.NewResource() + empty := env.NewModel() resources, err := empty.Get(ctx, []string{"test-edit"}) require.NoError(t, err) require.Len(t, resources, 1) @@ -1276,7 +1276,7 @@ func TestEdit(t *testing.T) { templateFields, err := target.Fields(ctx) require.NoError(t, err) - for key, field := range resource.IterFields(templateFields) { + for key, field := range model.IterFields(templateFields) { if field.Edit == nil { continue } @@ -1295,7 +1295,7 @@ func TestEdit(t *testing.T) { require.NoError(t, err) require.Len(t, results, 1) - edited := results[0].(resourcet.TestResource) + edited := results[0].(modelt.TestModel) assert.Equal(t, "test-edit", edited.Name) assert.Equal(t, "id-edit", edited.ID) assert.Equal(t, 999, edited.Settings.Foo) @@ -1307,23 +1307,23 @@ func TestEdit(t *testing.T) { } func TestEditOutput(t *testing.T) { - env := resourcet.NewTestEnv() - env.Add(resourcet.TestResource{ + env := modelt.NewTestEnv() + env.Add(modelt.TestModel{ ID: "id-edit", Name: "test-edit", URL: "https://example.com", - Settings: resourcet.TestSettings{ + Settings: modelt.TestSettings{ Foo: 10, Bar: "original", }, }) - ctx := resourcet.WithTestEnv(context.Background(), env) - partition := &resource.Partition{} + ctx := modelt.WithTestEnv(context.Background(), env) + partition := &model.Partition{} runEdit := func(t *testing.T, printer Printer) string { t.Helper() var out bytes.Buffer - cmd := &ResourceEditCmd[resourcet.TestResource]{ + cmd := &ModelEditCmd[modelt.TestModel]{ Target: "test-edit", Set: []map[string]string{ {"settings.foo": "999"}, @@ -1344,21 +1344,21 @@ func TestEditOutput(t *testing.T) { } func TestEditDryRun(t *testing.T) { - env := resourcet.NewTestEnv() - env.Add(resourcet.TestResource{ + env := modelt.NewTestEnv() + env.Add(modelt.TestModel{ ID: "id-edit", Name: "test-edit", URL: "https://example.com", - Settings: resourcet.TestSettings{ + Settings: modelt.TestSettings{ Foo: 10, Bar: "original", }, }) - ctx := resourcet.WithTestEnv(context.Background(), env) - partition := &resource.Partition{} + ctx := modelt.WithTestEnv(context.Background(), env) + partition := &model.Partition{} var out bytes.Buffer - cmd := &ResourceEditCmd[resourcet.TestResource]{ + cmd := &ModelEditCmd[modelt.TestModel]{ Target: "test-edit", DryRun: true, Set: []map[string]string{ @@ -1386,21 +1386,21 @@ func TestEditDryRun(t *testing.T) { } func TestEditCmdNoChangesDryRun(t *testing.T) { - env := resourcet.NewTestEnv() - env.Add(resourcet.TestResource{ + env := modelt.NewTestEnv() + env.Add(modelt.TestModel{ ID: "id-edit", Name: "test-edit", URL: "https://example.com", - Settings: resourcet.TestSettings{ + Settings: modelt.TestSettings{ Foo: 10, Bar: "original", }, }) - ctx := resourcet.WithTestEnv(context.Background(), env) - partition := &resource.Partition{} + ctx := modelt.WithTestEnv(context.Background(), env) + partition := &model.Partition{} var out bytes.Buffer - cmd := &ResourceEditCmd[resourcet.TestResource]{ + cmd := &ModelEditCmd[modelt.TestModel]{ Target: "test-edit", Cmd: "cat", // pass through unchanged DryRun: true, @@ -1418,21 +1418,21 @@ func TestEditCmdNoChangesDryRun(t *testing.T) { } func TestEditCmdWithChangesDryRun(t *testing.T) { - env := resourcet.NewTestEnv() - env.Add(resourcet.TestResource{ + env := modelt.NewTestEnv() + env.Add(modelt.TestModel{ ID: "id-edit", Name: "test-edit", URL: "https://example.com", - Settings: resourcet.TestSettings{ + Settings: modelt.TestSettings{ Foo: 10, Bar: "old-value", // maps to field name "settings.bar" }, }) - ctx := resourcet.WithTestEnv(context.Background(), env) - partition := &resource.Partition{} + ctx := modelt.WithTestEnv(context.Background(), env) + partition := &model.Partition{} var out bytes.Buffer - cmd := &ResourceEditCmd[resourcet.TestResource]{ + cmd := &ModelEditCmd[modelt.TestModel]{ Target: "test-edit", Cmd: `sed 's/old-value/new-value/'`, // change settings.bar DryRun: true, @@ -1456,7 +1456,7 @@ func TestEditPatchSpecFileArgs(t *testing.T) { addFile := tempFile(t, " new-entry\n") delFile := tempFile(t, " old-entry\n") - cmd := &ResourceEditCmd[resourcet.TestResource]{ + cmd := &ModelEditCmd[modelt.TestModel]{ Set: []map[string]string{{"settings.bar": "inline"}}, SetFile: []map[string]string{{"settings.foo": setFile}}, Add: []map[string]string{{"authors": "inline-entry"}}, @@ -1475,20 +1475,20 @@ func TestEditPatchSpecFileArgs(t *testing.T) { } func TestDelete(t *testing.T) { - env := resourcet.NewTestEnv() - env.Add(resourcet.TestResource{ + env := modelt.NewTestEnv() + env.Add(modelt.TestModel{ ID: "id-delete", Name: "test-delete", URL: "https://example.com", }) - env.Add(resourcet.TestResource{ + env.Add(modelt.TestModel{ ID: "id-keep", Name: "test-keep", URL: "https://example.org", }) - ctx := resourcet.WithTestEnv(context.Background(), env) + ctx := modelt.WithTestEnv(context.Background(), env) - empty := env.NewResource() + empty := env.NewModel() resources, err := empty.Get(ctx, []string{"test-delete"}) require.NoError(t, err) require.Len(t, resources, 1) @@ -1505,14 +1505,14 @@ func TestDelete(t *testing.T) { } func TestRemoveOutput(t *testing.T) { - partition := &resource.Partition{} + partition := &model.Partition{} t.Run("no_args", func(t *testing.T) { env := setupTestEnv() - ctx := resourcet.WithTestEnv(context.Background(), env) + ctx := modelt.WithTestEnv(context.Background(), env) var out bytes.Buffer - cmd := &ResourceRemoveCmd[resourcet.TestResource]{} + cmd := &ModelRemoveCmd[modelt.TestModel]{} err := cmd.Run(ctx, testStdio(&out), partition) require.Error(t, err) }) @@ -1520,10 +1520,10 @@ func TestRemoveOutput(t *testing.T) { runRemove := func(t *testing.T, printer Printer) string { t.Helper() env := setupTestEnv() - ctx := resourcet.WithTestEnv(context.Background(), env) + ctx := modelt.WithTestEnv(context.Background(), env) var out bytes.Buffer - cmd := &ResourceRemoveCmd[resourcet.TestResource]{ + cmd := &ModelRemoveCmd[modelt.TestModel]{ Targets: []string{"test1", "test2"}, Output: printer, } @@ -1582,12 +1582,12 @@ func testStdioWithInput(out io.Writer, in io.Reader) config.Stdio { } func TestValueCallback(t *testing.T) { - partition := &resource.Partition{} + partition := &model.Partition{} t.Run("list_without_lazy_field", func(t *testing.T) { - env := resourcet.NewTestEnv() - env.Add(resourcet.TestResource{ID: "1", Name: "res1", State: "running"}) - env.Add(resourcet.TestResource{ID: "2", Name: "res2", State: "stopped"}) + env := modelt.NewTestEnv() + env.Add(modelt.TestModel{ID: "1", Name: "res1", State: "running"}) + env.Add(modelt.TestModel{ID: "2", Name: "res2", State: "stopped"}) var resolvedMu sync.Mutex var resolved []string env.Hooks.OnLazyResolve = func(resourceName string) { @@ -1595,10 +1595,10 @@ func TestValueCallback(t *testing.T) { resolved = append(resolved, resourceName) resolvedMu.Unlock() } - ctx := resourcet.WithTestEnv(context.Background(), env) + ctx := modelt.WithTestEnv(context.Background(), env) var out bytes.Buffer - cmd := &ResourceListCmd[resourcet.TestResource]{} + cmd := &ModelListCmd[modelt.TestModel]{} err := cmd.Run(ctx, testStdio(&out), partition) require.NoError(t, err) @@ -1614,9 +1614,9 @@ func TestValueCallback(t *testing.T) { }) t.Run("list_with_lazy_field", func(t *testing.T) { - env := resourcet.NewTestEnv() - env.Add(resourcet.TestResource{ID: "1", Name: "res1", State: "running"}) - env.Add(resourcet.TestResource{ID: "2", Name: "res2", State: "stopped"}) + env := modelt.NewTestEnv() + env.Add(modelt.TestModel{ID: "1", Name: "res1", State: "running"}) + env.Add(modelt.TestModel{ID: "2", Name: "res2", State: "stopped"}) var resolvedMu sync.Mutex var resolved []string env.Hooks.OnLazyResolve = func(resourceName string) { @@ -1624,10 +1624,10 @@ func TestValueCallback(t *testing.T) { resolved = append(resolved, resourceName) resolvedMu.Unlock() } - ctx := resourcet.WithTestEnv(context.Background(), env) + ctx := modelt.WithTestEnv(context.Background(), env) var out bytes.Buffer - cmd := &ResourceListCmd[resourcet.TestResource]{ + cmd := &ModelListCmd[modelt.TestModel]{ Field: wkong.GreedyStrings{"+lazy"}, } err := cmd.Run(ctx, testStdio(&out), partition) @@ -1646,9 +1646,9 @@ func TestValueCallback(t *testing.T) { }) t.Run("get_with_lazy_field", func(t *testing.T) { - env := resourcet.NewTestEnv() - env.Add(resourcet.TestResource{ID: "1", Name: "res1", State: "running"}) - env.Add(resourcet.TestResource{ID: "2", Name: "res2", State: "stopped"}) + env := modelt.NewTestEnv() + env.Add(modelt.TestModel{ID: "1", Name: "res1", State: "running"}) + env.Add(modelt.TestModel{ID: "2", Name: "res2", State: "stopped"}) var resolvedMu sync.Mutex var resolved []string env.Hooks.OnLazyResolve = func(resourceName string) { @@ -1656,10 +1656,10 @@ func TestValueCallback(t *testing.T) { resolved = append(resolved, resourceName) resolvedMu.Unlock() } - ctx := resourcet.WithTestEnv(context.Background(), env) + ctx := modelt.WithTestEnv(context.Background(), env) var out bytes.Buffer - cmd := &ResourceGetCmd[resourcet.TestResource]{ + cmd := &ModelGetCmd[modelt.TestModel]{ Targets: []string{"res1"}, Field: wkong.GreedyStrings{"+lazy"}, } @@ -1676,9 +1676,9 @@ func TestValueCallback(t *testing.T) { }) t.Run("quiet_output_with_lazy_field", func(t *testing.T) { - env := resourcet.NewTestEnv() - env.Add(resourcet.TestResource{ID: "1", Name: "res1", State: "running"}) - env.Add(resourcet.TestResource{ID: "2", Name: "res2", State: "stopped"}) + env := modelt.NewTestEnv() + env.Add(modelt.TestModel{ID: "1", Name: "res1", State: "running"}) + env.Add(modelt.TestModel{ID: "2", Name: "res2", State: "stopped"}) var resolvedMu sync.Mutex var resolved []string env.Hooks.OnLazyResolve = func(resourceName string) { @@ -1686,10 +1686,10 @@ func TestValueCallback(t *testing.T) { resolved = append(resolved, resourceName) resolvedMu.Unlock() } - ctx := resourcet.WithTestEnv(context.Background(), env) + ctx := modelt.WithTestEnv(context.Background(), env) var out bytes.Buffer - cmd := &ResourceListCmd[resourcet.TestResource]{ + cmd := &ModelListCmd[modelt.TestModel]{ Output: Printer{Type: PrinterTypeQuiet}, Field: wkong.GreedyStrings{"name", "lazy"}, } @@ -1706,9 +1706,9 @@ func TestValueCallback(t *testing.T) { }) t.Run("filter_on_lazy_field_without_selecting_it", func(t *testing.T) { - env := resourcet.NewTestEnv() - env.Add(resourcet.TestResource{ID: "1", Name: "res1", State: "running"}) - env.Add(resourcet.TestResource{ID: "2", Name: "res2", State: "stopped"}) + env := modelt.NewTestEnv() + env.Add(modelt.TestModel{ID: "1", Name: "res1", State: "running"}) + env.Add(modelt.TestModel{ID: "2", Name: "res2", State: "stopped"}) var resolvedMu sync.Mutex var resolved []string env.Hooks.OnLazyResolve = func(resourceName string) { @@ -1716,10 +1716,10 @@ func TestValueCallback(t *testing.T) { resolved = append(resolved, resourceName) resolvedMu.Unlock() } - ctx := resourcet.WithTestEnv(context.Background(), env) + ctx := modelt.WithTestEnv(context.Background(), env) var out bytes.Buffer - cmd := &ResourceListCmd[resourcet.TestResource]{ + cmd := &ModelListCmd[modelt.TestModel]{ // Filter on lazy field, but don't select it for output Filter: []string{"lazy==computed-res1"}, } @@ -1738,9 +1738,9 @@ func TestValueCallback(t *testing.T) { }) t.Run("filter_and_select_lazy_field", func(t *testing.T) { - env := resourcet.NewTestEnv() - env.Add(resourcet.TestResource{ID: "1", Name: "res1", State: "running"}) - env.Add(resourcet.TestResource{ID: "2", Name: "res2", State: "stopped"}) + env := modelt.NewTestEnv() + env.Add(modelt.TestModel{ID: "1", Name: "res1", State: "running"}) + env.Add(modelt.TestModel{ID: "2", Name: "res2", State: "stopped"}) var resolvedMu sync.Mutex var resolved []string env.Hooks.OnLazyResolve = func(resourceName string) { @@ -1748,10 +1748,10 @@ func TestValueCallback(t *testing.T) { resolved = append(resolved, resourceName) resolvedMu.Unlock() } - ctx := resourcet.WithTestEnv(context.Background(), env) + ctx := modelt.WithTestEnv(context.Background(), env) var out bytes.Buffer - cmd := &ResourceListCmd[resourcet.TestResource]{ + cmd := &ModelListCmd[modelt.TestModel]{ // Filter on lazy field AND select it for output Filter: []string{"lazy==computed-res1"}, Field: wkong.GreedyStrings{"+lazy"}, @@ -1773,9 +1773,9 @@ func TestValueCallback(t *testing.T) { }) t.Run("list_filter_sort_select_lazy_once", func(t *testing.T) { - env := resourcet.NewTestEnv() - env.Add(resourcet.TestResource{ID: "1", Name: "res1", State: "running"}) - env.Add(resourcet.TestResource{ID: "2", Name: "res2", State: "stopped"}) + env := modelt.NewTestEnv() + env.Add(modelt.TestModel{ID: "1", Name: "res1", State: "running"}) + env.Add(modelt.TestModel{ID: "2", Name: "res2", State: "stopped"}) var resolvedMu sync.Mutex var resolved []string env.Hooks.OnLazyResolve = func(resourceName string) { @@ -1783,10 +1783,10 @@ func TestValueCallback(t *testing.T) { resolved = append(resolved, resourceName) resolvedMu.Unlock() } - ctx := resourcet.WithTestEnv(context.Background(), env) + ctx := modelt.WithTestEnv(context.Background(), env) var out bytes.Buffer - cmd := &ResourceListCmd[resourcet.TestResource]{ + cmd := &ModelListCmd[modelt.TestModel]{ Filter: []string{"lazy==computed-res1"}, Sort: wkong.GreedyStrings{"lazy"}, Output: Printer{Type: PrinterTypeQuiet}, @@ -1807,15 +1807,15 @@ func TestValueCallback(t *testing.T) { } func TestDeleteBulk(t *testing.T) { - partition := &resource.Partition{} + partition := &model.Partition{} t.Run("all_with_confirmation", func(t *testing.T) { env := setupTestEnv() - ctx := resourcet.WithTestEnv(context.Background(), env) + ctx := modelt.WithTestEnv(context.Background(), env) var out bytes.Buffer in := strings.NewReader("YES\n") - cmd := &ResourceBulkRemoveCmd[resourcet.TestResource]{ + cmd := &ModelBulkRemoveCmd[modelt.TestModel]{ All: true, } err := cmd.Run(ctx, testStdioWithInput(&out, in), partition) @@ -1831,10 +1831,10 @@ func TestDeleteBulk(t *testing.T) { t.Run("all_with_force", func(t *testing.T) { env := setupTestEnv() - ctx := resourcet.WithTestEnv(context.Background(), env) + ctx := modelt.WithTestEnv(context.Background(), env) var out bytes.Buffer - cmd := &ResourceBulkRemoveCmd[resourcet.TestResource]{ + cmd := &ModelBulkRemoveCmd[modelt.TestModel]{ All: true, Force: true, } @@ -1847,11 +1847,11 @@ func TestDeleteBulk(t *testing.T) { t.Run("all_cancelled", func(t *testing.T) { env := setupTestEnv() - ctx := resourcet.WithTestEnv(context.Background(), env) + ctx := modelt.WithTestEnv(context.Background(), env) var out bytes.Buffer in := strings.NewReader("no\n") - cmd := &ResourceBulkRemoveCmd[resourcet.TestResource]{ + cmd := &ModelBulkRemoveCmd[modelt.TestModel]{ All: true, } err := cmd.Run(ctx, testStdioWithInput(&out, in), partition) @@ -1863,11 +1863,11 @@ func TestDeleteBulk(t *testing.T) { }) t.Run("empty", func(t *testing.T) { - env := resourcet.NewTestEnv() - ctx := resourcet.WithTestEnv(context.Background(), env) + env := modelt.NewTestEnv() + ctx := modelt.WithTestEnv(context.Background(), env) var out bytes.Buffer - cmd := &ResourceBulkRemoveCmd[resourcet.TestResource]{ + cmd := &ModelBulkRemoveCmd[modelt.TestModel]{ All: true, Force: true, } @@ -1880,16 +1880,16 @@ func TestDeleteBulk(t *testing.T) { t.Run("filter_with_force", func(t *testing.T) { env := setupTestEnv() - env.Store["test1"] = resourcet.TestResource{ + env.Store["test1"] = modelt.TestModel{ ID: env.Store["test1"].ID, Name: env.Store["test1"].Name, State: "running", URL: env.Store["test1"].URL, } - ctx := resourcet.WithTestEnv(context.Background(), env) + ctx := modelt.WithTestEnv(context.Background(), env) var out bytes.Buffer - cmd := &ResourceBulkRemoveCmd[resourcet.TestResource]{ + cmd := &ModelBulkRemoveCmd[modelt.TestModel]{ Filter: []string{"state==running"}, Force: true, } @@ -1904,10 +1904,10 @@ func TestDeleteBulk(t *testing.T) { t.Run("filter_no_match", func(t *testing.T) { env := setupTestEnv() - ctx := resourcet.WithTestEnv(context.Background(), env) + ctx := modelt.WithTestEnv(context.Background(), env) var out bytes.Buffer - cmd := &ResourceBulkRemoveCmd[resourcet.TestResource]{ + cmd := &ModelBulkRemoveCmd[modelt.TestModel]{ Filter: []string{"state==nonexistent"}, Force: true, } @@ -1920,17 +1920,17 @@ func TestDeleteBulk(t *testing.T) { t.Run("filter_with_confirmation", func(t *testing.T) { env := setupTestEnv() - env.Store["test1"] = resourcet.TestResource{ + env.Store["test1"] = modelt.TestModel{ ID: env.Store["test1"].ID, Name: env.Store["test1"].Name, State: "running", URL: env.Store["test1"].URL, } - ctx := resourcet.WithTestEnv(context.Background(), env) + ctx := modelt.WithTestEnv(context.Background(), env) var out bytes.Buffer in := strings.NewReader("YES\n") - cmd := &ResourceBulkRemoveCmd[resourcet.TestResource]{ + cmd := &ModelBulkRemoveCmd[modelt.TestModel]{ Filter: []string{"state==running"}, } err := cmd.Run(ctx, testStdioWithInput(&out, in), partition) @@ -1944,13 +1944,13 @@ func TestDeleteBulk(t *testing.T) { func TestFilterComparisonOperators(t *testing.T) { env := setupTestEnv() - ctx := resourcet.WithTestEnv(context.Background(), env) - partition := &resource.Partition{} + ctx := modelt.WithTestEnv(context.Background(), env) + partition := &model.Partition{} runFilter := func(t *testing.T, filter string) (string, error) { t.Helper() var out bytes.Buffer - cmd := &ResourceListCmd[resourcet.TestResource]{ + cmd := &ModelListCmd[modelt.TestModel]{ Filter: []string{filter}, Output: Printer{Type: PrinterTypeQuiet}, } @@ -2199,16 +2199,16 @@ func TestFilterComparisonOperators(t *testing.T) { t.Run("ratio_field_equal_rounds_to_display", func(t *testing.T) { // 11/16 is 68.75%, which String rounds to display as "69%"; the // filter should match on that displayed value, not the exact ratio. - env := resourcet.NewTestEnv() - env.Add(resourcet.TestResource{ + env := modelt.NewTestEnv() + env.Add(modelt.TestModel{ ID: "id-test3", Name: "test3", Usage: types.MeterUsage[int]{Used: 11, Total: 16}, }) - ctx := resourcet.WithTestEnv(context.Background(), env) + ctx := modelt.WithTestEnv(context.Background(), env) var out bytes.Buffer - cmd := &ResourceListCmd[resourcet.TestResource]{ + cmd := &ModelListCmd[modelt.TestModel]{ Filter: []string{"usage==69%"}, Output: Printer{Type: PrinterTypeQuiet}, } diff --git a/internal/resource/cmd/complete.go b/internal/model/cmd/complete.go similarity index 89% rename from internal/resource/cmd/complete.go rename to internal/model/cmd/complete.go index 0d0243d1..47f12040 100644 --- a/internal/resource/cmd/complete.go +++ b/internal/model/cmd/complete.go @@ -14,24 +14,24 @@ import ( "github.com/posener/complete" "unikraft.com/cli/internal/config" - "unikraft.com/cli/internal/resource" + "unikraft.com/cli/internal/model" ) -func PredictResourceKey[R resource.ListableResource](ctx context.Context) complete.Predictor { +func PredictModelKey[R model.ListableModel](ctx context.Context) complete.Predictor { var empty R - return predictResourceKey{ + return predictModelKey{ ctx: ctx, resourceType: empty, } } -type predictResourceKey struct { +type predictModelKey struct { ctx context.Context - resourceType resource.ListableResource + resourceType model.ListableModel } -func (p predictResourceKey) Predict(a complete.Args) []string { +func (p predictModelKey) Predict(a complete.Args) []string { cfg, err := previewConfig() if err != nil { fmt.Fprintln(os.Stderr, "warning: could not load config for autocompletion:", err) diff --git a/internal/resource/cmd/complete_args.go b/internal/model/cmd/complete_args.go similarity index 100% rename from internal/resource/cmd/complete_args.go rename to internal/model/cmd/complete_args.go diff --git a/internal/resource/cmd/diff.go b/internal/model/cmd/diff.go similarity index 89% rename from internal/resource/cmd/diff.go rename to internal/model/cmd/diff.go index f7f11f81..e3a2594a 100644 --- a/internal/resource/cmd/diff.go +++ b/internal/model/cmd/diff.go @@ -14,14 +14,14 @@ import ( "github.com/charmbracelet/x/ansi" "github.com/sergi/go-diff/diffmatchpatch" "unikraft.com/cli/internal/kvwriter" + "unikraft.com/cli/internal/model" "unikraft.com/cli/internal/prettydiff" - "unikraft.com/cli/internal/resource" "unikraft.com/cli/internal/tabwriter" ) // Diff produces a pretty diff between two sets of resources, writing the // output to the provided writer. -func Diff(ctx context.Context, out io.Writer, format FormatOpts, empty resource.Resource, before []resource.Resource, after []resource.Resource) error { +func Diff(ctx context.Context, out io.Writer, format FormatOpts, empty model.Model, before []model.Model, after []model.Model) error { before, after = diffOrder(before, after) switch format.Output.Type { @@ -90,15 +90,15 @@ func Diff(ctx context.Context, out io.Writer, format FormatOpts, empty resource. // diffOrder reorders the after resources to match the order of the before // resources as much as possible. This should hopefully improve the diff // output. -func diffOrder(before []resource.Resource, after []resource.Resource) ([]resource.Resource, []resource.Resource) { - afterMap := make(map[string]resource.Resource, len(after)) +func diffOrder(before []model.Model, after []model.Model) ([]model.Model, []model.Model) { + afterMap := make(map[string]model.Model, len(after)) for _, r := range after { key := r.Key().String() afterMap[key] = r } afterFound := make(map[string]struct{}, len(after)) - var orderedAfter []resource.Resource + var orderedAfter []model.Model for _, r := range before { key := r.Key().String() if afterR, ok := afterMap[key]; ok { diff --git a/internal/resource/cmd/examples.go b/internal/model/cmd/examples.go similarity index 94% rename from internal/resource/cmd/examples.go rename to internal/model/cmd/examples.go index 580d2d45..eaf70b03 100644 --- a/internal/resource/cmd/examples.go +++ b/internal/model/cmd/examples.go @@ -19,6 +19,6 @@ const ( CmdTypeDelete CmdType = "delete" ) -type ExampledResource interface { +type ExampledModel interface { Examples() map[CmdType][]kingkong.Example } diff --git a/internal/model/cmd/fields.go b/internal/model/cmd/fields.go new file mode 100644 index 00000000..113f542f --- /dev/null +++ b/internal/model/cmd/fields.go @@ -0,0 +1,86 @@ +// 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 ( + "fmt" + + "unikraft.com/cli/internal/model" +) + +// FieldSpecAll is the special field spec value that selects all fields, +// regardless of verbosity or emptiness. +const FieldSpecAll = "all" + +func SelectFields(fields []model.Field, header bool, verbosity model.FieldVerbosity, fieldSpecs []string) ([]model.Field, error) { + var base []model.FieldPath + var include []model.FieldPath + var exclude []model.FieldPath + for _, field := range fieldSpecs { + if len(field) == 0 { + continue + } + if field == FieldSpecAll { + if base == nil { + base = []model.FieldPath{} + } + continue + } + switch field[0] { + case '+': + field = field[1:] + include = append(include, model.ParseFieldPath(field)) + case '-': + field = field[1:] + exclude = append(exclude, model.ParseFieldPath(field)) + default: + base = append(base, model.ParseFieldPath(field)) + } + } + + var missing []model.FieldPath + + result, missing := model.FilterFieldsByPath(fields, base, !header) + if base == nil { + result = model.FilterFields(result, func(field model.Field) model.FilterResult { + // remove fields that are too verbose + if field.Verbosity < verbosity { + return model.FilterExclude + } + // remove empty fields + if !header && field.IsEmpty() { + return model.FilterExclude + } + return model.FilterRecurse + }) + } else { + result = model.FilterFields(result, func(field model.Field) model.FilterResult { + // remove invisible fields + if field.Verbosity == model.FieldVerbosityInvisible { + return model.FilterExclude + } + return model.FilterRecurse + }) + } + + if len(include) > 0 { + included, includeMissing := model.FilterFieldsByPath(fields, include, !header) + result = model.MergeFields(result, included) + missing = append(missing, includeMissing...) + } + + if len(exclude) > 0 { + excluded, excludeMissing := model.FilterFieldsByPath(fields, exclude, !header) + result = model.RemoveFields(result, excluded) + missing = append(missing, excludeMissing...) + } + + if len(missing) > 0 { + return nil, fmt.Errorf("unknown fields: %v", missing) + } + + return result, nil +} diff --git a/internal/resource/cmd/filter.go b/internal/model/cmd/filter.go similarity index 91% rename from internal/resource/cmd/filter.go rename to internal/model/cmd/filter.go index 81de7700..c064bb4c 100644 --- a/internal/resource/cmd/filter.go +++ b/internal/model/cmd/filter.go @@ -13,19 +13,19 @@ import ( "github.com/charmbracelet/x/ansi" - "unikraft.com/cli/internal/resource" - "unikraft.com/cli/internal/resource/value" + "unikraft.com/cli/internal/model" + "unikraft.com/cli/internal/model/value" wtime "unikraft.com/cli/internal/w/time" "unikraft.com/x/filters" ) type fieldAdaptor struct { - children []resource.Field - field *resource.Field + children []model.Field + field *model.Field entries []string } -func newFieldAdaptor(fields []resource.Field) filters.Adaptor { +func newFieldAdaptor(fields []model.Field) filters.Adaptor { return &fieldAdaptor{children: fields} } @@ -34,21 +34,21 @@ func (a *fieldAdaptor) Select(key []string) (filters.Adaptor, bool) { return a, true } - matched := resource.GetFieldByPath(a.children, key) + matched := model.GetFieldByPath(a.children, key) if len(matched) == 0 { if len(key) >= 1 { parentKey := key[:len(key)-1] - var parent *resource.Field + var parent *model.Field if len(parentKey) == 0 { parent = a.field - } else if pm := resource.GetFieldByPath(a.children, parentKey); len(pm) == 1 { + } else if pm := model.GetFieldByPath(a.children, parentKey); len(pm) == 1 { parent = &pm[0] } if parent != nil { if slice, sok := getSliceValue(parent.Value); sok { idx, err := strconv.Atoi(key[len(key)-1]) if err == nil && idx >= 0 && idx < len(slice) { - f := resource.Field{Name: key[len(key)-1], Value: slice[idx]} + f := model.Field{Name: key[len(key)-1], Value: slice[idx]} return &fieldAdaptor{field: &f}, true } } diff --git a/internal/resource/cmd/flags.go b/internal/model/cmd/flags.go similarity index 94% rename from internal/resource/cmd/flags.go rename to internal/model/cmd/flags.go index 100efebc..9cfb9d02 100644 --- a/internal/resource/cmd/flags.go +++ b/internal/model/cmd/flags.go @@ -15,8 +15,8 @@ import ( "github.com/alecthomas/kong" "github.com/ettle/strcase" - "unikraft.com/cli/internal/resource" - "unikraft.com/cli/internal/resource/patch" + "unikraft.com/cli/internal/model" + "unikraft.com/cli/internal/model/patch" ) // FlagBuilder is implemented by commands whose flags come from a resource's @@ -54,12 +54,12 @@ func AttachGeneratedFlags(k *kong.Kong, opts ...kong.Option) error { // A runtime struct is handed to a throwaway kong parser, which does the tag // handling, mapper selection and help formatting. opts must mirror the real // parser's, so mappers and groups resolve the same way. -func GenerateFlags(fields []resource.Field, create bool, group string, opts ...kong.Option) (*FlagSet, error) { +func GenerateFlags(fields []model.Field, create bool, group string, opts ...kong.Option) (*FlagSet, error) { var structFields []reflect.StructField var slots []flagSlot seen := make(map[string]bool) - for key, field := range resource.IterFields(fields) { + for key, field := range model.IterFields(fields) { if field.Flag == nil { continue } @@ -204,7 +204,7 @@ func (s flagSlot) read() (any, error) { return out.Interface(), nil } -func patchTemplate(field resource.Field, create bool) any { +func patchTemplate(field model.Field, create bool) any { if create { if field.Create == nil { return nil @@ -237,7 +237,7 @@ func flagType(typ reflect.Type) (reflect.Type, bool) { // flagTag prepends the generated name and group to the field's own tag, // carrying its kong keys through. StructTag.Get takes the first match, so the // prepended keys win. -func flagTag(name, group string, spec *resource.FlagSpec) reflect.StructTag { +func flagTag(name, group string, spec *model.FlagSpec) reflect.StructTag { head := fmt.Sprintf("name:%q group:%q ", name, group) if spec.Arg { // Positionals carry no group, and stay optional so --set still works. diff --git a/internal/resource/cmd/flags_test.go b/internal/model/cmd/flags_test.go similarity index 90% rename from internal/resource/cmd/flags_test.go rename to internal/model/cmd/flags_test.go index 00cd7d3e..ea1c5ea4 100644 --- a/internal/resource/cmd/flags_test.go +++ b/internal/model/cmd/flags_test.go @@ -13,8 +13,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "unikraft.com/cli/internal/resource" - "unikraft.com/cli/internal/resource/patch" + "unikraft.com/cli/internal/model" + "unikraft.com/cli/internal/model/patch" ) type flagPort struct { @@ -48,7 +48,7 @@ type flagFixture struct { // command, and parses args against it, exactly as the real parser does. func parseGenerated(t *testing.T, create bool, args ...string) (*FlagSet, error) { t.Helper() - fields, err := resource.FieldsFromStruct(flagFixture{}) + fields, err := model.FieldsFromStruct(flagFixture{}) require.NoError(t, err) group := "flag-edit" @@ -143,7 +143,7 @@ func TestGenerateFlags(t *testing.T) { }) t.Run("typed values match the field's patch template", func(t *testing.T) { - fields, err := resource.FieldsFromStruct(flagFixture{}) + fields, err := model.FieldsFromStruct(flagFixture{}) require.NoError(t, err) set, err := parseGenerated(t, true, "--metro=fra", "--port=443:8080", "--tag=a") @@ -160,7 +160,7 @@ func TestGenerateFlags(t *testing.T) { // The shortcut flags merged with --set for collection fields, and the // integration tests rely on it: -e X=1 --set runtime.env=Y=2 keeps both. t.Run("a collection takes both a flag and --set", func(t *testing.T) { - fields, err := resource.FieldsFromStruct(flagFixture{}) + fields, err := model.FieldsFromStruct(flagFixture{}) require.NoError(t, err) set, err := parseGenerated(t, true, "--metro=fra", "--tag=from-flag") @@ -172,7 +172,7 @@ func TestGenerateFlags(t *testing.T) { patched, err := patch.PatchedFields(t.Context(), fields, spec) require.NoError(t, err) - tags, ok := resource.Field{Subfields: patched}.Get("tags") + tags, ok := model.Field{Subfields: patched}.Get("tags") require.True(t, ok) // --set lands first, the flag's entries after it. assert.Equal(t, []string{"from-set", "from-flag"}, tags.Create.Set) @@ -186,11 +186,11 @@ func TestGenerateFlags(t *testing.T) { }} require.NoError(t, set.Apply(&spec)) - fields, err := resource.FieldsFromStruct(flagFixture{}) + fields, err := model.FieldsFromStruct(flagFixture{}) require.NoError(t, err) patched, err := patch.PatchedFields(t.Context(), fields, spec) require.NoError(t, err) - metro, ok := resource.Field{Subfields: patched}.Get("metro") + metro, ok := model.Field{Subfields: patched}.Get("metro") require.True(t, ok) assert.Equal(t, "fra", metro.Create.Set) }) @@ -200,9 +200,9 @@ func TestGenerateFlags(t *testing.T) { // the backend it wraps both have a "type". The flag fits one; the rest are // dropped rather than failing the command. func TestTypedValueSkipsFieldsItDoesNotFit(t *testing.T) { - fields := []resource.Field{ - {Name: "type", Value: "", Create: &resource.Patch{Set: ""}}, - {Name: "type", Value: 0, Create: &resource.Patch{Set: 0}}, + fields := []model.Field{ + {Name: "type", Value: "", Create: &model.Patch{Set: ""}}, + {Name: "type", Value: 0, Create: &model.Patch{Set: 0}}, } patched, err := patch.PatchedFields(t.Context(), fields, patch.PatchSpec{ @@ -221,8 +221,8 @@ func TestTypedValueSkipsFieldsItDoesNotFit(t *testing.T) { } func TestTypedValueFittingNoFieldIsRejected(t *testing.T) { - fields := []resource.Field{ - {Name: "type", Value: 0, Create: &resource.Patch{Set: 0}}, + fields := []model.Field{ + {Name: "type", Value: 0, Create: &model.Patch{Set: 0}}, } _, err := patch.PatchedFields(t.Context(), fields, patch.PatchSpec{ @@ -245,7 +245,7 @@ func TestFlagOnUnsettableFieldIsRejected(t *testing.T) { type bad struct { A string `field:"a" flag:"a"` } - _, err := resource.FieldsFromStruct(bad{}) + _, err := model.FieldsFromStruct(bad{}) require.Error(t, err) assert.Contains(t, err.Error(), `has flag:"a" but is not settable`) } @@ -255,7 +255,7 @@ func TestGenerateFlagsRejectsDuplicateNames(t *testing.T) { A string `field:"a" create:"set" flag:"same"` B string `field:"b" create:"set" flag:"same"` } - fields, err := resource.FieldsFromStruct(dup{}) + fields, err := model.FieldsFromStruct(dup{}) require.NoError(t, err) _, err = GenerateFlags(fields, true, "flag-create") diff --git a/internal/resource/cmd/patches.go b/internal/model/cmd/patches.go similarity index 98% rename from internal/resource/cmd/patches.go rename to internal/model/cmd/patches.go index 1a82550c..c180b4b2 100644 --- a/internal/resource/cmd/patches.go +++ b/internal/model/cmd/patches.go @@ -11,7 +11,7 @@ import ( "os" "strings" - "unikraft.com/cli/internal/resource/patch" + "unikraft.com/cli/internal/model/patch" ) type SetArgs struct { diff --git a/internal/resource/cmd/printers.go b/internal/model/cmd/printers.go similarity index 85% rename from internal/resource/cmd/printers.go rename to internal/model/cmd/printers.go index 82417fa9..5caa2878 100644 --- a/internal/resource/cmd/printers.go +++ b/internal/model/cmd/printers.go @@ -24,8 +24,8 @@ import ( "unikraft.com/x/joinerrgroup" "unikraft.com/cli/internal/kvwriter" - "unikraft.com/cli/internal/resource" - "unikraft.com/cli/internal/resource/value" + "unikraft.com/cli/internal/model" + "unikraft.com/cli/internal/model/value" "unikraft.com/cli/internal/tabwriter" wslices "unikraft.com/cli/internal/w/slices" ) @@ -105,7 +105,7 @@ func (p Printer) WithDefault(tp PrinterType) Printer { return p } -func (p Printer) Print(ctx context.Context, out io.Writer, fieldSpecs []string, base resource.Resource, resources ...resource.Resource) error { +func (p Printer) Print(ctx context.Context, out io.Writer, fieldSpecs []string, base model.Model, resources ...model.Model) error { switch p.Type { case "": return fmt.Errorf("printer type not specified") @@ -144,13 +144,13 @@ func (p Printer) Print(ctx context.Context, out io.Writer, fieldSpecs []string, } } -func printKV(ctx context.Context, out io.Writer, specs []string, resources ...resource.Resource) error { +func printKV(ctx context.Context, out io.Writer, specs []string, resources ...model.Model) error { if len(resources) == 0 { return nil } // Resolve all resources in parallel - resolved := make([][]resource.Field, len(resources)) + resolved := make([][]model.Field, len(resources)) eg := joinerrgroup.Group{} for i, res := range resources { eg.Go(func() error { @@ -158,11 +158,11 @@ func printKV(ctx context.Context, out io.Writer, specs []string, resources ...re if err != nil { return err } - fields, err = SelectFields(fields, false, resource.FieldVerbosityLong, specs) + fields, err = SelectFields(fields, false, model.FieldVerbosityLong, specs) if err != nil { return err } - if err := resource.ResolveAllFields(ctx, fields); err != nil { + if err := model.ResolveAllFields(ctx, fields); err != nil { return err } resolved[i] = fields @@ -179,7 +179,7 @@ func printKV(ctx context.Context, out io.Writer, specs []string, resources ...re return err } } - fields = resource.DedupeFields(fields) + fields = model.DedupeFields(fields) if err := printKVFields(out, nil, fields, 0, 0); err != nil { return err } @@ -187,7 +187,7 @@ func printKV(ctx context.Context, out io.Writer, specs []string, resources ...re return nil } -func printKVFields(out io.Writer, parent *resource.Field, fields []resource.Field, current int, indent int) error { +func printKVFields(out io.Writer, parent *model.Field, fields []model.Field, current int, indent int) error { for i, field := range fields { var line bytes.Buffer nextCurrent := 0 @@ -261,21 +261,21 @@ func printKVValue(out io.Writer, v any, indent int) error { } } -func printTable(ctx context.Context, out io.Writer, fieldSpecs []string, base resource.Resource, resources ...resource.Resource) error { +func printTable(ctx context.Context, out io.Writer, fieldSpecs []string, base model.Model, resources ...model.Model) error { headers, err := base.Fields(ctx) if err != nil { return err } - headers, err = SelectFields(headers, true, resource.FieldVerbosityShort, fieldSpecs) + headers, err = SelectFields(headers, true, model.FieldVerbosityShort, fieldSpecs) if err != nil { return err } for i := range headers { - headers[i] = resource.PruneFields(headers[i]) + headers[i] = model.PruneFields(headers[i]) } - headerPaths, headerFields := wslices.Collect2(resource.IterFields(headers)) + headerPaths, headerFields := wslices.Collect2(model.IterFields(headers)) profile := lipgloss.Writer.Profile color := profile != colorprofile.NoTTY && profile != colorprofile.Ascii @@ -310,7 +310,7 @@ func printTable(ctx context.Context, out io.Writer, fieldSpecs []string, base re } // Resolve all resources in parallel - resolved, err := resolveResources(ctx, resources, headerPaths) + resolved, err := resolveModels(ctx, resources, headerPaths) if err != nil { return err } @@ -336,12 +336,12 @@ func printTable(ctx context.Context, out io.Writer, fieldSpecs []string, base re } firstCol = false - fields := resource.GetFieldByPath(fields, path) + fields := model.GetFieldByPath(fields, path) for i := range fields { - fields[i] = resource.PruneFields(fields[i]) + fields[i] = model.PruneFields(fields[i]) } fieldIdx := -1 - for _, field := range resource.IterFields(fields) { + for _, field := range model.IterFields(fields) { if field.Value == nil { continue } @@ -388,7 +388,7 @@ func printTable(ctx context.Context, out io.Writer, fieldSpecs []string, base re return nil } -func headerName(path resource.FieldPath) string { +func headerName(path model.FieldPath) string { for _, part := range slices.Backward(path) { if part == "*" { continue @@ -398,7 +398,7 @@ func headerName(path resource.FieldPath) string { return "" } -func printQuiet(ctx context.Context, out io.Writer, specs []string, resources ...resource.Resource) error { +func printQuiet(ctx context.Context, out io.Writer, specs []string, resources ...model.Model) error { if specs == nil { for _, res := range resources { if _, err := fmt.Fprintln(out, res.Key()); err != nil { @@ -413,7 +413,7 @@ func printQuiet(ctx context.Context, out io.Writer, specs []string, resources .. } // Resolve all resources in parallel - resolved := make([][]resource.Field, len(resources)) + resolved := make([][]model.Field, len(resources)) eg := joinerrgroup.Group{} for i, res := range resources { eg.Go(func() error { @@ -421,11 +421,11 @@ func printQuiet(ctx context.Context, out io.Writer, specs []string, resources .. if err != nil { return err } - fields, err = SelectFields(fields, false, resource.FieldVerbosityNone, specs) + fields, err = SelectFields(fields, false, model.FieldVerbosityNone, specs) if err != nil { return err } - if err := resource.ResolveAllFields(ctx, fields); err != nil { + if err := model.ResolveAllFields(ctx, fields); err != nil { return err } resolved[i] = fields @@ -438,7 +438,7 @@ func printQuiet(ctx context.Context, out io.Writer, specs []string, resources .. for _, fields := range resolved { i := 0 - for _, field := range resource.IterFields(fields) { + for _, field := range model.IterFields(fields) { if field.HasChildren() && field.Value == nil { continue } @@ -457,7 +457,7 @@ func printQuiet(ctx context.Context, out io.Writer, specs []string, resources .. return nil } -func printJSON(ctx context.Context, out io.Writer, specs []string, resources ...resource.Resource) error { +func printJSON(ctx context.Context, out io.Writer, specs []string, resources ...model.Model) error { input, err := fieldsToInput(ctx, specs, resources) if err != nil { return err @@ -470,7 +470,7 @@ func printJSON(ctx context.Context, out io.Writer, specs []string, resources ... return err } -func printYAML(ctx context.Context, out io.Writer, specs []string, resources ...resource.Resource) error { +func printYAML(ctx context.Context, out io.Writer, specs []string, resources ...model.Model) error { input, err := fieldsToInput(ctx, specs, resources) if err != nil { return err @@ -483,7 +483,7 @@ func printYAML(ctx context.Context, out io.Writer, specs []string, resources ... return err } -func fieldsToInput(ctx context.Context, specs []string, resources []resource.Resource) ([]any, error) { +func fieldsToInput(ctx context.Context, specs []string, resources []model.Model) ([]any, error) { if len(specs) == 0 { // json/yaml historically included every field by default, so // default to the equivalent of "-fall" rather than the @@ -499,14 +499,14 @@ func fieldsToInput(ctx context.Context, specs []string, resources []resource.Res if err != nil { return err } - fields, err = SelectFields(fields, false, resource.FieldVerbosityLong, specs) + fields, err = SelectFields(fields, false, model.FieldVerbosityLong, specs) if err != nil { return err } - if err := resource.ResolveAllFields(ctx, fields); err != nil { + if err := model.ResolveAllFields(ctx, fields); err != nil { return err } - input[i] = resource.FieldsToMap(fields) + input[i] = model.FieldsToMap(fields) return nil }) } @@ -516,7 +516,7 @@ func fieldsToInput(ctx context.Context, specs []string, resources []resource.Res return input, nil } -func printRaw(out io.Writer, resources ...resource.Resource) error { +func printRaw(out io.Writer, resources ...model.Model) error { input := make([]any, 0, len(resources)) for _, res := range resources { input = append(input, res.Raw()) @@ -529,7 +529,7 @@ func printRaw(out io.Writer, resources ...resource.Resource) error { return err } -func printTemplate(ctx context.Context, out io.Writer, tmplStr string, resources ...resource.Resource) error { +func printTemplate(ctx context.Context, out io.Writer, tmplStr string, resources ...model.Model) error { tmpl, err := template.New("out"). Funcs(sprig.TxtFuncMap()). Parse(tmplStr) @@ -537,7 +537,7 @@ func printTemplate(ctx context.Context, out io.Writer, tmplStr string, resources return err } - resolved, err := resolveAllResources(ctx, resources) + resolved, err := resolveAllModels(ctx, resources) if err != nil { return err } @@ -547,7 +547,7 @@ func printTemplate(ctx context.Context, out io.Writer, tmplStr string, resources if err != nil { return err } - input := resource.FieldsToMap(fields) + input := model.FieldsToMap(fields) var buf bytes.Buffer if err := tmpl.Execute(&buf, input); err != nil { return err @@ -564,8 +564,8 @@ func printTemplate(ctx context.Context, out io.Writer, tmplStr string, resources return nil } -func printDebug(ctx context.Context, out io.Writer, resources ...resource.Resource) error { - resolved, err := resolveAllResources(ctx, resources) +func printDebug(ctx context.Context, out io.Writer, resources ...model.Model) error { + resolved, err := resolveAllModels(ctx, resources) if err != nil { return err } @@ -587,14 +587,14 @@ func printDebug(ctx context.Context, out io.Writer, resources ...resource.Resour return nil } -func PrintPatches(out io.Writer, fields []resource.Field, create bool) error { +func PrintPatches(out io.Writer, fields []model.Field, create bool) error { tw := kvwriter.KeyValueWriter( out, kvwriter.WithSeparator(":=", "+=", "-="), kvwriter.WithAlignedSeparator(), ) - for path, field := range resource.IterFields(fields) { - var patch *resource.Patch + for path, field := range model.IterFields(fields) { + var patch *model.Patch if create { patch = field.Create } else { diff --git a/internal/model/cmd/resolve.go b/internal/model/cmd/resolve.go new file mode 100644 index 00000000..0f2c53a4 --- /dev/null +++ b/internal/model/cmd/resolve.go @@ -0,0 +1,114 @@ +// 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" + + "unikraft.com/cli/internal/model" + "unikraft.com/x/joinerrgroup" +) + +type resolvedModel struct { + model.Model + fields []model.Field +} + +func (r resolvedModel) Fields(ctx context.Context) ([]model.Field, error) { + return model.CloneFields(r.fields), nil +} + +func resolveModels(ctx context.Context, resources []model.Model, paths []model.FieldPath) ([]model.Model, error) { + if len(resources) == 0 { + return resources, nil + } + + resolved := make([]model.Model, len(resources)) + eg := joinerrgroup.Group{} + for i, res := range resources { + eg.Go(func() error { + resolvedRes, err := resolveModel(ctx, res, paths) + if err != nil { + return err + } + resolved[i] = resolvedRes + return nil + }) + } + if err := eg.Wait(); err != nil { + return nil, err + } + return resolved, nil +} + +func resolveModel(ctx context.Context, res model.Model, paths []model.FieldPath) (model.Model, error) { + fields, err := res.Fields(ctx) + if err != nil { + return nil, err + } + if len(paths) == 0 { + return res, nil + } + + resolved := model.CloneFields(fields) + if err := model.ResolveFields(ctx, resolved, paths); err != nil { + return nil, err + } + + return resolvedModel{Model: res, fields: resolved}, nil +} + +func resolveAllModels(ctx context.Context, resources []model.Model) ([]model.Model, error) { + if len(resources) == 0 { + return resources, nil + } + + resolved := make([]model.Model, len(resources)) + eg := joinerrgroup.Group{} + for i, res := range resources { + eg.Go(func() error { + resolvedRes, err := resolveAllModel(ctx, res) + if err != nil { + return err + } + resolved[i] = resolvedRes + return nil + }) + } + if err := eg.Wait(); err != nil { + return nil, err + } + return resolved, nil +} + +func resolveAllModel(ctx context.Context, res model.Model) (model.Model, error) { + fields, err := res.Fields(ctx) + if err != nil { + return nil, err + } + + resolved := model.CloneFields(fields) + if err := model.ResolveAllFields(ctx, resolved); err != nil { + return nil, err + } + + return resolvedModel{Model: res, fields: resolved}, nil +} + +func unwrapModel(r model.Model) model.Model { + if rr, ok := r.(resolvedModel); ok { + return rr.Model + } + return r +} + +func unwrapModels(resources []model.Model) []model.Model { + result := make([]model.Model, len(resources)) + for i, r := range resources { + result[i] = unwrapModel(r) + } + return result +} diff --git a/internal/resource/cmd/sort.go b/internal/model/cmd/sort.go similarity index 80% rename from internal/resource/cmd/sort.go rename to internal/model/cmd/sort.go index 8f9f22a2..3aff63da 100644 --- a/internal/resource/cmd/sort.go +++ b/internal/model/cmd/sort.go @@ -11,12 +11,12 @@ import ( "slices" "strings" - "unikraft.com/cli/internal/resource" + "unikraft.com/cli/internal/model" ) // SortSpec represents a single field sort specification with direction. type SortSpec struct { - Path resource.FieldPath + Path model.FieldPath Descending bool } @@ -54,7 +54,7 @@ func parseSortSpecs(inputs ...string) ([]SortSpec, error) { } specs = append(specs, SortSpec{ - Path: resource.ParseFieldPath(fieldName), + Path: model.ParseFieldPath(fieldName), Descending: descending, }) } @@ -62,10 +62,10 @@ func parseSortSpecs(inputs ...string) ([]SortSpec, error) { return specs, nil } -// sortResources sorts resources by multiple field values specified by the sort +// sortModels sorts resources by multiple field values specified by the sort // specs. Each spec includes a path and whether to sort descending. Resources // are sorted by the first spec, then ties are broken by subsequent specs. -func sortResources(ctx context.Context, resources []resource.Resource, specs []SortSpec) ([]resource.Resource, error) { +func sortModels(ctx context.Context, resources []model.Model, specs []SortSpec) ([]model.Model, error) { if len(specs) == 0 { return resources, nil } @@ -79,25 +79,25 @@ func sortResources(ctx context.Context, resources []resource.Resource, specs []S return nil, fmt.Errorf("failed to get fields for validation: %w", err) } for _, spec := range specs { - _, missing := resource.FilterFieldsByPath(schemaFields, []resource.FieldPath{spec.Path}, false) + _, missing := model.FilterFieldsByPath(schemaFields, []model.FieldPath{spec.Path}, false) if len(missing) > 0 { return nil, fmt.Errorf("unknown sort field: %s", spec.Path) } } // Collect all paths for resolution - paths := make([]resource.FieldPath, len(specs)) + paths := make([]model.FieldPath, len(specs)) for i, spec := range specs { paths[i] = spec.Path } // Pre-resolve field values for sorting type resourceWithValues struct { - res resource.Resource + res model.Model values []any } - resolved, err := resolveResources(ctx, resources, paths) + resolved, err := resolveModels(ctx, resources, paths) if err != nil { return nil, err } @@ -125,7 +125,7 @@ func sortResources(ctx context.Context, resources []resource.Resource, specs []S // Apply sort specs in order (first spec is primary, subsequent break ties) slices.SortStableFunc(items, func(a, b resourceWithValues) int { for i, spec := range specs { - result := resource.CompareFieldValues(a.values[i], b.values[i]) + result := model.CompareFieldValues(a.values[i], b.values[i]) if result != 0 { if spec.Descending { return -result @@ -137,15 +137,15 @@ func sortResources(ctx context.Context, resources []resource.Resource, specs []S }) // Extract sorted resources - sorted := make([]resource.Resource, len(items)) + sorted := make([]model.Model, len(items)) for i, item := range items { sorted[i] = item.res } return sorted, nil } -func fieldValueByPath(fields []resource.Field, path resource.FieldPath, key resource.Key) (any, error) { - matched := resource.GetFieldByPath(fields, path) +func fieldValueByPath(fields []model.Field, path model.FieldPath, key model.Key) (any, error) { + matched := model.GetFieldByPath(fields, path) if len(matched) == 0 { return nil, fmt.Errorf("sort field %s not found for resource %s", path, key) } diff --git a/internal/resource/ctx.go b/internal/model/ctx.go similarity index 97% rename from internal/resource/ctx.go rename to internal/model/ctx.go index a9bcb86a..8fe46c99 100644 --- a/internal/resource/ctx.go +++ b/internal/model/ctx.go @@ -3,7 +3,7 @@ // Licensed under the BSD-3-Clause License (the "License"). // You may not use this file except in compliance with the License. -package resource +package model import ( "context" diff --git a/internal/resource/resource.go b/internal/model/model.go similarity index 90% rename from internal/resource/resource.go rename to internal/model/model.go index f41b2f45..39187cc1 100644 --- a/internal/resource/resource.go +++ b/internal/model/model.go @@ -3,7 +3,7 @@ // Licensed under the BSD-3-Clause License (the "License"). // You may not use this file except in compliance with the License. -package resource +package model import ( "context" @@ -11,7 +11,7 @@ import ( "fmt" "reflect" - "unikraft.com/cli/internal/resource/value" + "unikraft.com/cli/internal/model/value" "unikraft.com/x/colors" ) @@ -25,46 +25,46 @@ type Key interface { Canonical() string } -type Resource interface { +type Model interface { Type() Type Key() Key Fields(ctx context.Context) ([]Field, error) Raw() any } -type GettableResource interface { - Resource - Get(ctx context.Context, keys []string) ([]Resource, error) +type GettableModel interface { + Model + Get(ctx context.Context, keys []string) ([]Model, error) } -type ListableResource interface { - Resource - List(ctx context.Context) ([]Resource, error) +type ListableModel interface { + Model + List(ctx context.Context) ([]Model, error) } -type GettableListableResource interface { - GettableResource - ListableResource +type GettableListableModel interface { + GettableModel + ListableModel } -type EditableResource interface { - GettableResource +type EditableModel interface { + GettableModel Edit(ctx context.Context, key string, fields []Field) error } -type CreatableResource interface { - GettableResource - Create(ctx context.Context, fields []Field) ([]Resource, error) +type CreatableModel interface { + GettableModel + Create(ctx context.Context, fields []Field) ([]Model, error) } -type DeletableResource interface { - GettableResource +type DeletableModel interface { + GettableModel Delete(ctx context.Context, keys []string) error } -type DefaultResource interface { - GettableResource - Default(ctx context.Context) (Resource, error) +type DefaultModel interface { + GettableModel + Default(ctx context.Context) (Model, error) } type Link interface { diff --git a/internal/resource/resource_helpers.go b/internal/model/model_helpers.go similarity index 99% rename from internal/resource/resource_helpers.go rename to internal/model/model_helpers.go index 625e3d79..3c9497b6 100644 --- a/internal/resource/resource_helpers.go +++ b/internal/model/model_helpers.go @@ -3,7 +3,7 @@ // Licensed under the BSD-3-Clause License (the "License"). // You may not use this file except in compliance with the License. -package resource +package model import ( "context" diff --git a/internal/resource/resource_path.go b/internal/model/model_path.go similarity index 99% rename from internal/resource/resource_path.go rename to internal/model/model_path.go index ce22ad4e..6d88bfb1 100644 --- a/internal/resource/resource_path.go +++ b/internal/model/model_path.go @@ -3,7 +3,7 @@ // Licensed under the BSD-3-Clause License (the "License"). // You may not use this file except in compliance with the License. -package resource +package model import ( "iter" diff --git a/internal/resource/partition.go b/internal/model/partition.go similarity index 67% rename from internal/resource/partition.go rename to internal/model/partition.go index 75e8f137..49731017 100644 --- a/internal/resource/partition.go +++ b/internal/model/partition.go @@ -3,7 +3,7 @@ // Licensed under the BSD-3-Clause License (the "License"). // You may not use this file except in compliance with the License. -package resource +package model import ( "context" @@ -28,12 +28,12 @@ import ( type Partition struct { Path string Keys map[string]map[string]struct{} - Cleanup []Resource + Cleanup []Model } const UnikraftPartitionEnv = "UNIKRAFT_X_PARTITION" -func LoadPartitionFromEnv(resources ...Resource) (*Partition, error) { +func LoadPartitionFromEnv(resources ...Model) (*Partition, error) { path, ok := os.LookupEnv(UnikraftPartitionEnv) if !ok { return nil, nil @@ -45,7 +45,7 @@ func LoadPartitionFromEnv(resources ...Resource) (*Partition, error) { return partition, nil } -func LoadPartition(path string, resources ...Resource) (*Partition, error) { +func LoadPartition(path string, resources ...Model) (*Partition, error) { p := Partition{Path: path, Cleanup: resources} p.Keys = make(map[string]map[string]struct{}) for _, r := range resources { @@ -115,7 +115,7 @@ func (p *Partition) Teardown(ctx context.Context) (rerr error) { Msg("tearing down partition") for _, r := range p.Cleanup { name := r.Type().Name - r, ok := r.(DeletableResource) + r, ok := r.(DeletableModel) if !ok { log.G(ctx).Debug(). Str("resource", name). @@ -144,7 +144,7 @@ func (p *Partition) Teardown(ctx context.Context) (rerr error) { return rerr } -func (p *Partition) Add(ctx context.Context, r Resource) error { +func (p *Partition) Add(ctx context.Context, r Model) error { if p == nil { return nil } @@ -155,7 +155,7 @@ func (p *Partition) Add(ctx context.Context, r Resource) error { return p.add(ctx, r, visited) } -func (p *Partition) add(ctx context.Context, r Resource, visited map[string]struct{}) error { +func (p *Partition) add(ctx context.Context, r Model, visited map[string]struct{}) error { if p == nil { return nil } @@ -199,7 +199,7 @@ func (p *Partition) add(ctx context.Context, r Resource, visited map[string]stru keys[key] = struct{}{} } - r, ok := r.(GettableResource) + r, ok := r.(GettableModel) if !ok { continue } @@ -230,7 +230,7 @@ func (p *Partition) Remove(typeName string, key string) { delete(p.Keys[typeName], key) } -func (p *Partition) Has(r Resource) bool { +func (p *Partition) Has(r Model) bool { if p == nil { return true } @@ -241,67 +241,67 @@ func (p *Partition) Has(r Resource) bool { return ok } -func (p *Partition) Missing(r Resource) bool { +func (p *Partition) Missing(r Model) bool { return !p.Has(r) } -func (p *Partition) WrapGettable(r GettableResource) GettableResource { +func (p *Partition) WrapGettable(r GettableModel) GettableModel { if p == nil { return r } - return partitionedGettableResource{ - GettableResource: r, - partition: p, + return partitionedGettableModel{ + GettableModel: r, + partition: p, } } -func (p *Partition) WrapListable(r ListableResource) ListableResource { +func (p *Partition) WrapListable(r ListableModel) ListableModel { if p == nil { return r } - return partitionedListableResource{ - ListableResource: r, - partition: p, + return partitionedListableModel{ + ListableModel: r, + partition: p, } } -func (p *Partition) WrapEditable(r EditableResource) EditableResource { +func (p *Partition) WrapEditable(r EditableModel) EditableModel { if p == nil { return r } - return partitionedEditableResource{ - EditableResource: r, - partition: p, + return partitionedEditableModel{ + EditableModel: r, + partition: p, } } -func (p *Partition) WrapCreatable(r CreatableResource) CreatableResource { +func (p *Partition) WrapCreatable(r CreatableModel) CreatableModel { if p == nil { return r } - return partitionedCreatableResource{ - CreatableResource: r, - partition: p, + return partitionedCreatableModel{ + CreatableModel: r, + partition: p, } } -func (p *Partition) WrapDeletable(r DeletableResource) DeletableResource { +func (p *Partition) WrapDeletable(r DeletableModel) DeletableModel { if p == nil { return r } - return partitionedDeletableResource{ - DeletableResource: r, - partition: p, + return partitionedDeletableModel{ + DeletableModel: r, + partition: p, } } -type partitionedGettableResource struct { - GettableResource +type partitionedGettableModel struct { + GettableModel partition *Partition } -func (r partitionedGettableResource) Get(ctx context.Context, keys []string) ([]Resource, error) { - resources, opErr := r.GettableResource.Get(ctx, keys) +func (r partitionedGettableModel) Get(ctx context.Context, keys []string) ([]Model, error) { + resources, opErr := r.GettableModel.Get(ctx, keys) if opErr != nil && len(resources) == 0 { return nil, opErr } @@ -315,13 +315,13 @@ func (r partitionedGettableResource) Get(ctx context.Context, keys []string) ([] return resources, opErr } -type partitionedListableResource struct { - ListableResource +type partitionedListableModel struct { + ListableModel partition *Partition } -func (r partitionedListableResource) List(ctx context.Context) ([]Resource, error) { - resources, opErr := r.ListableResource.List(ctx) +func (r partitionedListableModel) List(ctx context.Context) ([]Model, error) { + resources, opErr := r.ListableModel.List(ctx) if opErr != nil && len(resources) == 0 { return nil, opErr } @@ -329,25 +329,25 @@ func (r partitionedListableResource) List(ctx context.Context) ([]Resource, erro return resources, opErr } -type partitionedEditableResource struct { - EditableResource +type partitionedEditableModel struct { + EditableModel partition *Partition } -func (r partitionedEditableResource) Get(ctx context.Context, keys []string) ([]Resource, error) { - return partitionedGettableResource{ - GettableResource: r.EditableResource, - partition: r.partition, +func (r partitionedEditableModel) Get(ctx context.Context, keys []string) ([]Model, error) { + return partitionedGettableModel{ + GettableModel: r.EditableModel, + partition: r.partition, }.Get(ctx, keys) } -func (r partitionedEditableResource) Edit(ctx context.Context, key string, fields []Field) error { - if err := r.EditableResource.Edit(ctx, key, fields); err != nil { +func (r partitionedEditableModel) Edit(ctx context.Context, key string, fields []Field) error { + if err := r.EditableModel.Edit(ctx, key, fields); err != nil { return err } // re-fetch, since we may have found new strongly linked dependencies (e.g. // by creating a certificate) - resources, err := r.EditableResource.Get(ctx, []string{key}) + resources, err := r.EditableModel.Get(ctx, []string{key}) if err != nil { return err } @@ -359,20 +359,20 @@ func (r partitionedEditableResource) Edit(ctx context.Context, key string, field return nil } -type partitionedCreatableResource struct { - CreatableResource +type partitionedCreatableModel struct { + CreatableModel partition *Partition } -func (r partitionedCreatableResource) Get(ctx context.Context, keys []string) ([]Resource, error) { - return partitionedGettableResource{ - GettableResource: r.CreatableResource, - partition: r.partition, +func (r partitionedCreatableModel) Get(ctx context.Context, keys []string) ([]Model, error) { + return partitionedGettableModel{ + GettableModel: r.CreatableModel, + partition: r.partition, }.Get(ctx, keys) } -func (r partitionedCreatableResource) Create(ctx context.Context, fields []Field) ([]Resource, error) { - resources, opErr := r.CreatableResource.Create(ctx, fields) +func (r partitionedCreatableModel) Create(ctx context.Context, fields []Field) ([]Model, error) { + resources, opErr := r.CreatableModel.Create(ctx, fields) if opErr != nil && len(resources) == 0 { return nil, opErr } @@ -384,27 +384,27 @@ func (r partitionedCreatableResource) Create(ctx context.Context, fields []Field return resources, opErr } -type partitionedDeletableResource struct { - DeletableResource +type partitionedDeletableModel struct { + DeletableModel partition *Partition } -func (r partitionedDeletableResource) Get(ctx context.Context, keys []string) ([]Resource, error) { - return partitionedGettableResource{ - GettableResource: r.DeletableResource, - partition: r.partition, +func (r partitionedDeletableModel) Get(ctx context.Context, keys []string) ([]Model, error) { + return partitionedGettableModel{ + GettableModel: r.DeletableModel, + partition: r.partition, }.Get(ctx, keys) } -func (r partitionedDeletableResource) Delete(ctx context.Context, keys []string) error { - resources, getErr := r.DeletableResource.Get(ctx, keys) +func (r partitionedDeletableModel) Delete(ctx context.Context, keys []string) error { + resources, getErr := r.DeletableModel.Get(ctx, keys) - err := r.DeletableResource.Delete(ctx, keys) + err := r.DeletableModel.Delete(ctx, keys) if err != nil { return err } - typeName := r.DeletableResource.Type().Name + typeName := r.DeletableModel.Type().Name for _, res := range resources { r.partition.Remove(typeName, res.Key().Canonical()) } diff --git a/internal/resource/patch/editor_exec_js.go b/internal/model/patch/editor_exec_js.go similarity index 100% rename from internal/resource/patch/editor_exec_js.go rename to internal/model/patch/editor_exec_js.go diff --git a/internal/resource/patch/editor_exec_native.go b/internal/model/patch/editor_exec_native.go similarity index 100% rename from internal/resource/patch/editor_exec_native.go rename to internal/model/patch/editor_exec_native.go diff --git a/internal/resource/patch/editor_exec_test.go b/internal/model/patch/editor_exec_test.go similarity index 100% rename from internal/resource/patch/editor_exec_test.go rename to internal/model/patch/editor_exec_test.go diff --git a/internal/resource/patch/editor_other.go b/internal/model/patch/editor_other.go similarity index 100% rename from internal/resource/patch/editor_other.go rename to internal/model/patch/editor_other.go diff --git a/internal/resource/patch/editor_windows.go b/internal/model/patch/editor_windows.go similarity index 100% rename from internal/resource/patch/editor_windows.go rename to internal/model/patch/editor_windows.go diff --git a/internal/resource/patch/filter.go b/internal/model/patch/filter.go similarity index 52% rename from internal/resource/patch/filter.go rename to internal/model/patch/filter.go index 25c3c8e5..ca9e0088 100644 --- a/internal/resource/patch/filter.go +++ b/internal/model/patch/filter.go @@ -8,60 +8,60 @@ package patch import ( "reflect" - "unikraft.com/cli/internal/resource" + "unikraft.com/cli/internal/model" ) // FilterEditFields filters to fields that have a pending edit patch. -func FilterEditFields(fields []resource.Field) []resource.Field { - return resource.FilterFields(fields, func(field resource.Field) resource.FilterResult { +func FilterEditFields(fields []model.Field) []model.Field { + return model.FilterFields(fields, func(field model.Field) model.FilterResult { if field.Edit == nil { - return resource.FilterPrune + return model.FilterPrune } if field.Edit.Set == nil && field.Edit.Add == nil && field.Edit.Del == nil { - return resource.FilterPrune + return model.FilterPrune } - return resource.FilterInclude + return model.FilterInclude }) } // FilterCreateFields filters to fields that have a pending create patch. -func FilterCreateFields(fields []resource.Field) []resource.Field { - return resource.FilterFields(fields, func(field resource.Field) resource.FilterResult { +func FilterCreateFields(fields []model.Field) []model.Field { + return model.FilterFields(fields, func(field model.Field) model.FilterResult { if field.Create == nil { - return resource.FilterPrune + return model.FilterPrune } if field.Create.Set == nil && field.Create.Add == nil && field.Create.Del == nil { - return resource.FilterPrune + return model.FilterPrune } - return resource.FilterInclude + return model.FilterInclude }) } // FilterDisplayableEditFields filters fields for visual editing display. // Includes fields that have an Edit patch and a non-nil Value to show. -func FilterDisplayableEditFields(fields []resource.Field) []resource.Field { - return resource.FilterFields(fields, func(field resource.Field) resource.FilterResult { +func FilterDisplayableEditFields(fields []model.Field) []model.Field { + return model.FilterFields(fields, func(field model.Field) model.FilterResult { if field.Edit == nil { - return resource.FilterPrune + return model.FilterPrune } rv := reflect.ValueOf(field.Edit.Set) if !rv.IsValid() || rv.IsZero() { - return resource.FilterPrune + return model.FilterPrune } - return resource.FilterInclude + return model.FilterInclude }) } // FilterDisplayableCreateFields filters fields for visual creation display. // Includes all fields that have a Create patch (even with zero values, so users can fill them in). -func FilterDisplayableCreateFields(fields []resource.Field) []resource.Field { - return resource.FilterFields(fields, func(field resource.Field) resource.FilterResult { +func FilterDisplayableCreateFields(fields []model.Field) []model.Field { + return model.FilterFields(fields, func(field model.Field) model.FilterResult { if field.Create == nil { - return resource.FilterPrune + return model.FilterPrune } if !field.Create.Required { - return resource.FilterPrune + return model.FilterPrune } - return resource.FilterInclude + return model.FilterInclude }) } diff --git a/internal/resource/patch/merge.go b/internal/model/patch/merge.go similarity index 75% rename from internal/resource/patch/merge.go rename to internal/model/patch/merge.go index 856a0449..84b04c59 100644 --- a/internal/resource/patch/merge.go +++ b/internal/model/patch/merge.go @@ -8,20 +8,20 @@ package patch import ( "slices" - "unikraft.com/cli/internal/resource" + "unikraft.com/cli/internal/model" ) // MergePatches merges patch values from src into dest fields. // For each field in dest, if src contains a patch at the same path, // the src patch value overrides the dest patch value. // Fields in src that don't exist in dest are inserted at their own path. -func MergePatches(dest, src []resource.Field) []resource.Field { - dest = resource.CloneFields(dest) +func MergePatches(dest, src []model.Field) []model.Field { + dest = model.CloneFields(dest) // Build maps of src patches by path - srcEditPatches := make(map[string]*resource.Patch) - srcCreatePatches := make(map[string]*resource.Patch) - for key, field := range resource.IterFields(src) { + srcEditPatches := make(map[string]*model.Patch) + srcCreatePatches := make(map[string]*model.Patch) + for key, field := range model.IterFields(src) { keyStr := key.String() if field.Edit != nil { srcEditPatches[keyStr] = field.Edit @@ -35,7 +35,7 @@ func MergePatches(dest, src []resource.Field) []resource.Field { appliedPatches := make(map[string]bool) // Merge src patches into dest fields - for key, field := range resource.IterFields(dest) { + for key, field := range model.IterFields(dest) { keyStr := key.String() if srcPatch, ok := srcEditPatches[keyStr]; ok { @@ -53,7 +53,7 @@ func MergePatches(dest, src []resource.Field) []resource.Field { } // Insert src fields that weren't in dest - for key, field := range resource.IterFields(src) { + for key, field := range model.IterFields(src) { keyStr := key.String() if appliedPatches[keyStr] { continue @@ -74,15 +74,15 @@ func MergePatches(dest, src []resource.Field) []resource.Field { // where absent, creating) the fields the path passes through. Inserting on // name alone would move a nested field such as "service.domains" to the top // level, where nothing downstream can match it back to its real path. -func insertPatchAtPath(dest []resource.Field, path resource.FieldPath, src *resource.Field) []resource.Field { +func insertPatchAtPath(dest []model.Field, path model.FieldPath, src *model.Field) []model.Field { if len(path) == 0 { return dest } - fields, field := &dest, &resource.Field{} + fields, field := &dest, &model.Field{} for _, name := range path { - i := slices.IndexFunc(*fields, func(f resource.Field) bool { return f.Name == name }) + i := slices.IndexFunc(*fields, func(f model.Field) bool { return f.Name == name }) if i < 0 { - *fields = append(*fields, resource.Field{Name: name}) + *fields = append(*fields, model.Field{Name: name}) i = len(*fields) - 1 } field = &(*fields)[i] @@ -94,7 +94,7 @@ func insertPatchAtPath(dest []resource.Field, path resource.FieldPath, src *reso } // mergePatch merges src patch values into dest patch. -func mergePatch(dest, src *resource.Patch) { +func mergePatch(dest, src *model.Patch) { if src.Set != nil { dest.Set = src.Set } diff --git a/internal/resource/patch/merge_test.go b/internal/model/patch/merge_test.go similarity index 79% rename from internal/resource/patch/merge_test.go rename to internal/model/patch/merge_test.go index e30c9abc..294d5fb7 100644 --- a/internal/resource/patch/merge_test.go +++ b/internal/model/patch/merge_test.go @@ -11,14 +11,14 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "unikraft.com/cli/internal/resource" + "unikraft.com/cli/internal/model" ) // patchPaths keys every Set patch by its path, so a field inserted at the // wrong depth shows up as a differing key. -func patchPaths(fields []resource.Field) map[string]any { +func patchPaths(fields []model.Field) map[string]any { got := map[string]any{} - for path, field := range resource.IterFields(fields) { + for path, field := range model.IterFields(fields) { if field.Edit != nil && field.Edit.Set != nil { got[path.String()] = field.Edit.Set } @@ -30,13 +30,13 @@ func patchPaths(fields []resource.Field) map[string]any { } func TestMergePatches_NestedPathsPreserved(t *testing.T) { - nested := func(path ...string) []resource.Field { + nested := func(path ...string) []model.Field { var set any = "value" - field := resource.Field{Name: path[len(path)-1], Edit: &resource.Patch{Set: set}} + field := model.Field{Name: path[len(path)-1], Edit: &model.Patch{Set: set}} for i := len(path) - 2; i >= 0; i-- { - field = resource.Field{Name: path[i], Subfields: []resource.Field{field}} + field = model.Field{Name: path[i], Subfields: []model.Field{field}} } - return []resource.Field{field} + return []model.Field{field} } t.Run("absent parent is created", func(t *testing.T) { @@ -45,9 +45,9 @@ func TestMergePatches_NestedPathsPreserved(t *testing.T) { }) t.Run("existing parent is reused", func(t *testing.T) { - dest := []resource.Field{{ + dest := []model.Field{{ Name: "service", - Create: &resource.Patch{Set: "my-group"}, + Create: &model.Patch{Set: "my-group"}, }} got := MergePatches(dest, nested("service", "domains")) assert.Equal(t, map[string]any{ @@ -77,9 +77,9 @@ func TestMergePatches_NestedPathsPreserved(t *testing.T) { }) t.Run("matching path merges rather than inserts", func(t *testing.T) { - dest := []resource.Field{{ + dest := []model.Field{{ Name: "service", - Subfields: []resource.Field{{Name: "domains", Edit: &resource.Patch{Set: "old"}}}, + Subfields: []model.Field{{Name: "domains", Edit: &model.Patch{Set: "old"}}}, }} got := MergePatches(dest, nested("service", "domains")) assert.Equal(t, map[string]any{"service.domains": "value"}, patchPaths(got)) @@ -89,7 +89,7 @@ func TestMergePatches_NestedPathsPreserved(t *testing.T) { t.Run("src is not mutated", func(t *testing.T) { src := nested("service", "domains") - MergePatches([]resource.Field{{Name: "service"}}, src) + MergePatches([]model.Field{{Name: "service"}}, src) require.Len(t, src, 1) assert.Len(t, src[0].Subfields, 1) }) diff --git a/internal/resource/patch/patch.go b/internal/model/patch/patch.go similarity index 87% rename from internal/resource/patch/patch.go rename to internal/model/patch/patch.go index 82cf291e..1c77c849 100644 --- a/internal/resource/patch/patch.go +++ b/internal/model/patch/patch.go @@ -15,8 +15,8 @@ import ( "unikraft.com/x/log" - "unikraft.com/cli/internal/resource" - "unikraft.com/cli/internal/resource/value" + "unikraft.com/cli/internal/model" + "unikraft.com/cli/internal/model/value" wmaps "unikraft.com/cli/internal/w/maps" ) @@ -60,20 +60,20 @@ func (spec *PatchSpec) Keys() iter.Seq[string] { // PatchedFields applies the given PatchSpec to the provided fields, returning // only the modified fields or an error if the patching process encounters // issues. -func PatchedFields(ctx context.Context, fields []resource.Field, spec PatchSpec) ([]resource.Field, error) { +func PatchedFields(ctx context.Context, fields []model.Field, spec PatchSpec) ([]model.Field, error) { foundFields := make(map[string]struct{}) typedFields := make(map[string]struct{}) setForbiddenFields := make(map[string]struct{}) addForbiddenFields := make(map[string]struct{}) delForbiddenFields := make(map[string]struct{}) - fields = resource.CloneFields(fields) - for key, field := range resource.IterFields(fields) { + fields = model.CloneFields(fields) + for key, field := range model.IterFields(fields) { keyStr := key.String() foundFields[keyStr] = struct{}{} - var original *resource.Patch - var patch **resource.Patch + var original *model.Patch + var patch **model.Patch if spec.Create { original = field.Create field.Create = nil @@ -84,7 +84,7 @@ func PatchedFields(ctx context.Context, fields []resource.Field, spec PatchSpec) patch = &field.Edit } if original == nil { - original = &resource.Patch{} + original = &model.Patch{} } typed, hasTyped := spec.SetTyped[keyStr] @@ -119,9 +119,9 @@ func PatchedFields(ctx context.Context, fields []resource.Field, spec PatchSpec) typedFields[keyStr] = struct{}{} set = mergeSet(set, typed, hasStrs) } - *patch = &resource.Patch{Set: set} + *patch = &model.Patch{Set: set} case spec.Create && original.Set != nil && !value.IsZero(original.Set): - *patch = &resource.Patch{Set: original.Set} + *patch = &model.Patch{Set: original.Set} } if vs, ok := spec.Add[keyStr]; ok { @@ -130,12 +130,12 @@ func PatchedFields(ctx context.Context, fields []resource.Field, spec PatchSpec) if err != nil { return nil, fmt.Errorf("failed to unpack add value for %s: %w", keyStr, err) } - *patch = &resource.Patch{Add: add} + *patch = &model.Patch{Add: add} } else { addForbiddenFields[keyStr] = struct{}{} } } else if spec.Create && original.Add != nil && !value.IsZero(original.Add) { - *patch = &resource.Patch{Add: original.Add} + *patch = &model.Patch{Add: original.Add} } if vs, ok := spec.Del[keyStr]; ok { if original.Del != nil { @@ -143,12 +143,12 @@ func PatchedFields(ctx context.Context, fields []resource.Field, spec PatchSpec) if err != nil { return nil, fmt.Errorf("failed to unpack del value for %s: %w", keyStr, err) } - *patch = &resource.Patch{Del: del} + *patch = &model.Patch{Del: del} } else { delForbiddenFields[keyStr] = struct{}{} } } else if spec.Create && original.Del != nil && !value.IsZero(original.Del) { - *patch = &resource.Patch{Del: original.Del} + *patch = &model.Patch{Del: original.Del} } } @@ -227,11 +227,11 @@ func mergeSet(base, typed any, hasBase bool) any { // ValidateRequired checks that all required fields have a value set in the patches. // originalFields should contain the field definitions with Required flags, // patchedFields should contain the patches with values. -func ValidateRequired(originalFields, patchedFields []resource.Field, create bool) error { +func ValidateRequired(originalFields, patchedFields []model.Field, create bool) error { // Build a map of which fields have patches set patchedSet := make(map[string]struct{}) - for key, field := range resource.IterFields(patchedFields) { - var patch *resource.Patch + for key, field := range model.IterFields(patchedFields) { + var patch *model.Patch if create { patch = field.Create } else { @@ -244,8 +244,8 @@ func ValidateRequired(originalFields, patchedFields []resource.Field, create boo // Check all required fields in the original schema unsetFields := make(map[string]struct{}) - for key, field := range resource.IterFields(originalFields) { - var patch *resource.Patch + for key, field := range model.IterFields(originalFields) { + var patch *model.Patch if create { patch = field.Create } else { diff --git a/internal/resource/patch/visual.go b/internal/model/patch/visual.go similarity index 88% rename from internal/resource/patch/visual.go rename to internal/model/patch/visual.go index beefa839..73bcdf31 100644 --- a/internal/resource/patch/visual.go +++ b/internal/model/patch/visual.go @@ -17,7 +17,7 @@ import ( "sigs.k8s.io/yaml" - "unikraft.com/cli/internal/resource" + "unikraft.com/cli/internal/model" ) // EditorFunc is a callback that takes input YAML content and returns edited content. @@ -26,19 +26,19 @@ type EditorFunc func(ctx context.Context, input []byte) ([]byte, error) // Edit applies patches to fields using the provided editor function. // It serializes fields to YAML, calls the editor, and updates patch.Set values based on changes. -func Edit(ctx context.Context, res resource.Resource, fields []resource.Field, patches []resource.Field, editor EditorFunc) ([]resource.Field, error) { +func Edit(ctx context.Context, res model.Model, fields []model.Field, patches []model.Field, editor EditorFunc) ([]model.Field, error) { return edit(ctx, res, fields, patches, false, editor) } // Create applies patches to fields for creation using the provided editor function. // It serializes fields to YAML, calls the editor, and updates patch.Set values based on changes. -func Create(ctx context.Context, res resource.Resource, fields []resource.Field, patches []resource.Field, editor EditorFunc) ([]resource.Field, error) { +func Create(ctx context.Context, res model.Model, fields []model.Field, patches []model.Field, editor EditorFunc) ([]model.Field, error) { return edit(ctx, res, fields, patches, true, editor) } // SaveYAML writes fields to a writer as YAML. // If create is true, uses create patches; otherwise uses edit patches. -func SaveYAML(res resource.Resource, fields []resource.Field, patches []resource.Field, w io.Writer, create bool) error { +func SaveYAML(res model.Model, fields []model.Field, patches []model.Field, w io.Writer, create bool) error { data, err := saveFields(res, fields, patches, create) if err != nil { return fmt.Errorf("failed to serialize fields: %w", err) @@ -58,7 +58,7 @@ func ContentEditorFunc(content []byte) EditorFunc { } // edit is the core implementation that handles both edit and create operations. -func edit(ctx context.Context, res resource.Resource, fields []resource.Field, patches []resource.Field, create bool, editor EditorFunc) ([]resource.Field, error) { +func edit(ctx context.Context, res model.Model, fields []model.Field, patches []model.Field, create bool, editor EditorFunc) ([]model.Field, error) { data, err := saveFields(res, fields, patches, create) if err != nil { return nil, fmt.Errorf("failed to serialize fields: %w", err) @@ -82,9 +82,9 @@ func edit(ctx context.Context, res resource.Resource, fields []resource.Field, p // saveFields serializes fields to YAML for editing. // It collects patch.Set values and merges in any pending patches. -func saveFields(res resource.Resource, fields []resource.Field, patches []resource.Field, create bool) ([]byte, error) { +func saveFields(res model.Model, fields []model.Field, patches []model.Field, create bool) ([]byte, error) { // Filter to displayable fields and clear Add/Del templates - var displayableFields []resource.Field + var displayableFields []model.Field if create { displayableFields = FilterDisplayableCreateFields(fields) } else { @@ -92,7 +92,7 @@ func saveFields(res resource.Resource, fields []resource.Field, patches []resour } // Clear Add/Del on all fields - they come from struct tags and aren't real values - for _, field := range resource.IterFields(displayableFields) { + for _, field := range model.IterFields(displayableFields) { if field.Edit != nil { field.Edit.Add = nil field.Edit.Del = nil @@ -108,8 +108,8 @@ func saveFields(res resource.Resource, fields []resource.Field, patches []resour // Collect values from patch.Set into a map for YAML serialization result := make(map[string]any) - for key, field := range resource.IterFields(displayableFields) { - var patch *resource.Patch + for key, field := range model.IterFields(displayableFields) { + var patch *model.Patch if create { patch = field.Create } else { @@ -143,8 +143,8 @@ func saveFields(res resource.Resource, fields []resource.Field, patches []resour } // loadFieldPatches parses edited YAML and updates patch.Set values for changed fields. -func loadFieldPatches(fields []resource.Field, data []byte, create bool) ([]resource.Field, error) { - fields = resource.CloneFields(fields) +func loadFieldPatches(fields []model.Field, data []byte, create bool) ([]model.Field, error) { + fields = model.CloneFields(fields) var obj map[string]any if err := yaml.Unmarshal(data, &obj); err != nil { @@ -164,11 +164,11 @@ func loadFieldPatches(fields []resource.Field, data []byte, create bool) ([]reso // values already deleted and silently patch partial data, so a new // nested set field must claim values its descendants do not. type fieldEntry struct { - key resource.FieldPath - field *resource.Field + key model.FieldPath + field *model.Field } var entries []fieldEntry - for key, field := range resource.IterFields(fields) { + for key, field := range model.IterFields(fields) { entries = append(entries, fieldEntry{key, field}) } slices.SortStableFunc(entries, func(a, b fieldEntry) int { @@ -177,7 +177,7 @@ func loadFieldPatches(fields []resource.Field, data []byte, create bool) ([]reso for _, entry := range entries { key, field := entry.key, entry.field - var patch *resource.Patch + var patch *model.Patch if create { patch = field.Create } else { @@ -242,7 +242,7 @@ func loadFieldPatches(fields []resource.Field, data []byte, create bool) ([]reso } // getNestedValue retrieves a value from a nested map structure based on a field path. -func getNestedValue(m map[string]any, path resource.FieldPath) (any, bool) { +func getNestedValue(m map[string]any, path model.FieldPath) (any, bool) { if len(path) == 0 { return nil, false } @@ -261,7 +261,7 @@ func getNestedValue(m map[string]any, path resource.FieldPath) (any, bool) { } // setNestedValue sets a value in a nested map structure based on a field path. -func setNestedValue(m map[string]any, path resource.FieldPath, value any) error { +func setNestedValue(m map[string]any, path model.FieldPath, value any) error { if len(path) == 0 { return nil } @@ -306,7 +306,7 @@ func objectFields(v any) (map[string]any, error) { } // deleteNestedValue removes a value from a nested map structure and cleans up empty parents. -func deleteNestedValue(m map[string]any, path resource.FieldPath) { +func deleteNestedValue(m map[string]any, path model.FieldPath) { if len(path) == 0 { return } @@ -330,7 +330,7 @@ func deleteNestedValue(m map[string]any, path resource.FieldPath) { } // collectKeys collects all remaining keys in a nested map as field paths. -func collectKeys(m map[string]any, prefix resource.FieldPath) []string { +func collectKeys(m map[string]any, prefix model.FieldPath) []string { var keys []string for k, v := range m { path := append(prefix, k) diff --git a/internal/resource/patch/visual_test.go b/internal/model/patch/visual_test.go similarity index 83% rename from internal/resource/patch/visual_test.go rename to internal/model/patch/visual_test.go index df054303..ad4d00fa 100644 --- a/internal/resource/patch/visual_test.go +++ b/internal/model/patch/visual_test.go @@ -13,17 +13,17 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "unikraft.com/cli/internal/resource" - resourcet "unikraft.com/cli/internal/resource/testing" + "unikraft.com/cli/internal/model" + modelt "unikraft.com/cli/internal/model/testing" ) -// mockResource implements a simple test resource for visual editing tests. -type mockResource struct { +// mockModel implements a simple test resource for visual editing tests. +type mockModel struct { name string } -func (m mockResource) Type() resource.Type { - return resource.Type{Name: "mock", Names: "mocks"} +func (m mockModel) Type() model.Type { + return model.Type{Name: "mock", Names: "mocks"} } type mockKey string @@ -36,34 +36,34 @@ func (k mockKey) Canonical() string { return string(k) } -func (m mockResource) Key() resource.Key { +func (m mockModel) Key() model.Key { return mockKey(m.name) } -func (m mockResource) Fields(ctx context.Context) ([]resource.Field, error) { +func (m mockModel) Fields(ctx context.Context) ([]model.Field, error) { return nil, nil } -func (m mockResource) Raw() any { +func (m mockModel) Raw() any { return m } func TestEdit_Basic(t *testing.T) { - res := mockResource{name: "test-resource"} + res := mockModel{name: "test-resource"} // Define fields with actual values in patch.Set - fields := []resource.Field{ + fields := []model.Field{ { Name: "name", Value: "original-name", - Edit: &resource.Patch{ + Edit: &model.Patch{ Set: "original-name", }, }, { Name: "count", Value: 42, - Edit: &resource.Patch{ + Edit: &model.Patch{ Set: 42, }, }, @@ -83,7 +83,7 @@ count: 42 require.Len(t, result, 1) // Check that name was updated - var nameField *resource.Field + var nameField *model.Field for i := range result { if result[i].Name == "name" { nameField = &result[i] @@ -95,21 +95,21 @@ count: 42 } func TestCreate_Basic(t *testing.T) { - res := mockResource{name: ""} + res := mockModel{name: ""} // Define fields with empty/zero values for creation - fields := []resource.Field{ + fields := []model.Field{ { Name: "name", Value: "", - Create: &resource.Patch{ + Create: &model.Patch{ Set: "", }, }, { Name: "count", Value: 0, - Create: &resource.Patch{ + Create: &model.Patch{ Set: 0, }, }, @@ -128,7 +128,7 @@ count: 100 require.Len(t, result, 2) // Check values were set - var nameField, countField *resource.Field + var nameField, countField *model.Field for i := range result { switch result[i].Name { case "name": @@ -145,23 +145,23 @@ count: 100 } func TestEdit_NestedFields(t *testing.T) { - res := mockResource{name: "test"} + res := mockModel{name: "test"} - fields := []resource.Field{ + fields := []model.Field{ { Name: "settings", - Subfields: []resource.Field{ + Subfields: []model.Field{ { Name: "foo", Value: 10, - Edit: &resource.Patch{ + Edit: &model.Patch{ Set: 10, }, }, { Name: "bar", Value: "hello", - Edit: &resource.Patch{ + Edit: &model.Patch{ Set: "hello", }, }, @@ -185,7 +185,7 @@ settings: require.Equal(t, "settings", settings.Name) require.Len(t, settings.Subfields, 2) - var fooField, barField *resource.Field + var fooField, barField *model.Field for i := range settings.Subfields { switch settings.Subfields[i].Name { case "foo": @@ -202,24 +202,24 @@ settings: } func TestEdit_WithPendingPatches(t *testing.T) { - res := mockResource{name: "test"} + res := mockModel{name: "test"} // Fields with original values - fields := []resource.Field{ + fields := []model.Field{ { Name: "name", Value: "original", - Edit: &resource.Patch{ + Edit: &model.Patch{ Set: "original", }, }, } // Pending patches from previous operation - patches := []resource.Field{ + patches := []model.Field{ { Name: "name", - Edit: &resource.Patch{ + Edit: &model.Patch{ Set: "previously-set", }, }, @@ -246,13 +246,13 @@ name: final-value } func TestEdit_UnknownFieldsError(t *testing.T) { - res := mockResource{name: "test"} + res := mockModel{name: "test"} - fields := []resource.Field{ + fields := []model.Field{ { Name: "name", Value: "original", - Edit: &resource.Patch{ + Edit: &model.Patch{ Set: "original", }, }, @@ -272,13 +272,13 @@ unknown_field: should_fail } func TestEdit_EmptyDataError(t *testing.T) { - res := mockResource{name: "test"} + res := mockModel{name: "test"} - fields := []resource.Field{ + fields := []model.Field{ { Name: "name", Value: "original", - Edit: &resource.Patch{ + Edit: &model.Patch{ Set: "original", }, }, @@ -295,13 +295,13 @@ func TestEdit_EmptyDataError(t *testing.T) { } func TestEdit_WrongTypeError(t *testing.T) { - res := mockResource{name: "test"} + res := mockModel{name: "test"} - fields := []resource.Field{ + fields := []model.Field{ { Name: "count", Value: 42, - Edit: &resource.Patch{ + Edit: &model.Patch{ Set: 42, // int type }, }, @@ -320,20 +320,20 @@ count: not_a_number } func TestEdit_FieldRemoved(t *testing.T) { - res := mockResource{name: "test"} + res := mockModel{name: "test"} - fields := []resource.Field{ + fields := []model.Field{ { Name: "name", Value: "original", - Edit: &resource.Patch{ + Edit: &model.Patch{ Set: "original", }, }, { Name: "count", Value: 42, - Edit: &resource.Patch{ + Edit: &model.Patch{ Set: 42, }, }, @@ -353,25 +353,25 @@ name: original assert.Empty(t, result, "no fields should have changes when one is unchanged and one is removed") } -func TestEdit_WithTestResource(t *testing.T) { - // Test using the shared TestResource from testing package - env := resourcet.NewTestEnv() - env.Add(resourcet.TestResource{ +func TestEdit_WithTestModel(t *testing.T) { + // Test using the shared TestModel from testing package + env := modelt.NewTestEnv() + env.Add(modelt.TestModel{ ID: "id-test1", Name: "test1", - Settings: resourcet.TestSettings{ + Settings: modelt.TestSettings{ Foo: 42, Bar: "hello", }, }) - ctx := resourcet.WithTestEnv(context.Background(), env) + ctx := modelt.WithTestEnv(context.Background(), env) // Get the resource and its fields - resources, err := resourcet.TestResource{}.Get(ctx, []string{"test1"}) + resources, err := modelt.TestModel{}.Get(ctx, []string{"test1"}) require.NoError(t, err) require.Len(t, resources, 1) - res := resources[0].(resourcet.TestResource) + res := resources[0].(modelt.TestModel) fields, err := res.Fields(ctx) require.NoError(t, err) @@ -397,7 +397,7 @@ settings: require.NoError(t, err) // Find the settings fields in result - var settingsFoo, settingsBar *resource.Field + var settingsFoo, settingsBar *model.Field for _, f := range result { if f.Name == "settings" { for i := range f.Subfields { @@ -417,9 +417,9 @@ settings: assert.Equal(t, "world", settingsBar.Edit.Set) } -func TestCreate_WithTestResource(t *testing.T) { +func TestCreate_WithTestModel(t *testing.T) { // Get template fields from empty resource - res := resourcet.TestResource{} + res := modelt.TestModel{} fields, err := res.Fields(context.Background()) require.NoError(t, err) @@ -437,8 +437,8 @@ settings: require.NoError(t, err) // Find the name and settings fields in result - var nameField *resource.Field - var settingsFoo, settingsBar *resource.Field + var nameField *model.Field + var settingsFoo, settingsBar *model.Field for i := range result { switch result[i].Name { case "name": @@ -467,21 +467,21 @@ settings: func TestEdit_HiddenFieldAddedWithZeroValue(t *testing.T) { // Tests that when a user manually adds a hidden field with a zero value // (e.g., vsock: false), it should be preserved in the result. - res := mockResource{name: "test"} + res := mockModel{name: "test"} // Fields: 'name' is displayed (has Value), 'hidden' is not (Value is nil) - fields := []resource.Field{ + fields := []model.Field{ { Name: "name", Value: "original", - Edit: &resource.Patch{ + Edit: &model.Patch{ Set: "original", }, }, { Name: "hidden", Value: nil, // Not displayed because Value is nil - Edit: &resource.Patch{ + Edit: &model.Patch{ Set: false, // Template type is bool }, }, @@ -502,7 +502,7 @@ hidden: false require.NoError(t, err) // Find the hidden field - var hiddenField *resource.Field + var hiddenField *model.Field for i := range result { if result[i].Name == "hidden" { hiddenField = &result[i] @@ -518,21 +518,21 @@ hidden: false func TestEdit_HiddenFieldAddedWithNonZeroValue(t *testing.T) { // Tests that when a user manually adds a hidden field with a non-zero value // (e.g., vsock: true), it should be preserved in the result. - res := mockResource{name: "test"} + res := mockModel{name: "test"} // Fields: 'name' is displayed (has Value), 'hidden' is not (Value is nil) - fields := []resource.Field{ + fields := []model.Field{ { Name: "name", Value: "original", - Edit: &resource.Patch{ + Edit: &model.Patch{ Set: "original", }, }, { Name: "hidden", Value: nil, // Not displayed because Value is nil - Edit: &resource.Patch{ + Edit: &model.Patch{ Set: false, // Template type is bool }, }, @@ -550,7 +550,7 @@ hidden: true require.NoError(t, err) // Find the hidden field - var hiddenField *resource.Field + var hiddenField *model.Field for i := range result { if result[i].Name == "hidden" { hiddenField = &result[i] @@ -565,13 +565,13 @@ hidden: true func TestEdit_DisplayedFieldUnchanged(t *testing.T) { // Tests that when a displayed field is unchanged, it should NOT be in the result. - res := mockResource{name: "test"} + res := mockModel{name: "test"} - fields := []resource.Field{ + fields := []model.Field{ { Name: "visible", Value: false, // Displayed because Value is not nil - Edit: &resource.Patch{ + Edit: &model.Patch{ Set: false, }, }, @@ -594,32 +594,32 @@ visible: false func TestEdit_PendingPatchShownInEditor(t *testing.T) { // Tests that when a field is set via --set (pending patch), it appears in the editor // even if the field has Value: nil - res := mockResource{name: "test"} + res := mockModel{name: "test"} // Fields: 'name' is displayed (has Value), 'hidden' is not (Value is nil) // Using *bool for hidden so nil means "not set" vs explicit true/false - fields := []resource.Field{ + fields := []model.Field{ { Name: "name", Value: "original", - Edit: &resource.Patch{ + Edit: &model.Patch{ Set: "original", }, }, { Name: "hidden", Value: nil, // Not displayed by default - Edit: &resource.Patch{ + Edit: &model.Patch{ Set: (*bool)(nil), // Template type is *bool, nil means not set }, }, } // Pending patches - simulates --set hidden=true - patches := []resource.Field{ + patches := []model.Field{ { Name: "hidden", - Edit: &resource.Patch{ + Edit: &model.Patch{ Set: new(true), }, }, @@ -642,7 +642,7 @@ hidden: true assert.Contains(t, string(receivedInput), "hidden: true", "hidden field should appear in editor with pending patch value") // Find the hidden field in result - var hiddenField, nameField *resource.Field + var hiddenField, nameField *model.Field for i := range result { switch result[i].Name { case "hidden": @@ -665,23 +665,23 @@ hidden: true func TestEdit_PendingPatchChangedInEditor(t *testing.T) { // Tests that when a field is set via --set and then changed in the editor - res := mockResource{name: "test"} + res := mockModel{name: "test"} - fields := []resource.Field{ + fields := []model.Field{ { Name: "hidden", Value: nil, - Edit: &resource.Patch{ + Edit: &model.Patch{ Set: false, }, }, } // Pending patches - simulates --set hidden=true - patches := []resource.Field{ + patches := []model.Field{ { Name: "hidden", - Edit: &resource.Patch{ + Edit: &model.Patch{ Set: true, }, }, @@ -697,7 +697,7 @@ hidden: false result, err := Edit(context.Background(), res, fields, patches, editor) require.NoError(t, err) - var hiddenField *resource.Field + var hiddenField *model.Field for i := range result { if result[i].Name == "hidden" { hiddenField = &result[i] @@ -713,37 +713,37 @@ hidden: false func TestCreate_PendingPatchShownInEditor(t *testing.T) { // Tests that fields set via --set appear in visual editor during create // Simulates: unikraft instance create --set image=test --set vsock=true -e --dry-run - res := mockResource{name: ""} + res := mockModel{name: ""} // Fields for creation - using pointer types so nil means "not set" - fields := []resource.Field{ + fields := []model.Field{ { Name: "image", Value: "", - Create: &resource.Patch{ + Create: &model.Patch{ Set: (*string)(nil), // nil means not set }, }, { Name: "vsock", Value: nil, // Not shown by default - Create: &resource.Patch{ + Create: &model.Patch{ Set: (*bool)(nil), // nil means not set }, }, } // Pending patches - simulates --set image=test --set vsock=true - patches := []resource.Field{ + patches := []model.Field{ { Name: "image", - Create: &resource.Patch{ + Create: &model.Patch{ Set: new("test-image"), }, }, { Name: "vsock", - Create: &resource.Patch{ + Create: &model.Patch{ Set: new(true), }, }, @@ -768,7 +768,7 @@ vsock: true assert.Contains(t, string(receivedInput), "vsock: true", "vsock should appear in editor with pending patch value") // Find fields in result - var imageField, vsockField *resource.Field + var imageField, vsockField *model.Field for i := range result { switch result[i].Name { case "image": @@ -790,48 +790,48 @@ vsock: true func TestCreate_PendingPatchUnchangedInEditorReturnsInput(t *testing.T) { // Tests that when editor returns unchanged input, pending patches are preserved // This simulates what happens when user opens editor and just saves without changes - res := mockResource{name: ""} + res := mockModel{name: ""} - fields := []resource.Field{ + fields := []model.Field{ { Name: "image", Value: "", - Create: &resource.Patch{ + Create: &model.Patch{ Set: "", }, }, { Name: "metro", Value: "", - Create: &resource.Patch{ + Create: &model.Patch{ Set: "", }, }, { Name: "vsock", Value: nil, - Create: &resource.Patch{ + Create: &model.Patch{ Set: false, }, }, } - patches := []resource.Field{ + patches := []model.Field{ { Name: "image", - Create: &resource.Patch{ + Create: &model.Patch{ Set: "sklj", }, }, { Name: "metro", - Create: &resource.Patch{ + Create: &model.Patch{ Set: "dfalk", }, }, { Name: "vsock", - Create: &resource.Patch{ + Create: &model.Patch{ Set: true, }, }, @@ -868,20 +868,20 @@ func TestCreate_PendingPatchUnchangedInEditorReturnsInput(t *testing.T) { // (from struct tags like edit:"set,add,del=keys") work correctly when the // templates have empty (but non-nil) Add/Del values. func TestEdit_FieldWithAddDelTemplate(t *testing.T) { - res := mockResource{name: "test-instance"} + res := mockModel{name: "test-instance"} // Simulate fields like runtime.env which has edit:"set,add,del=keys" // The struct tag parser creates empty (non-nil) slices/maps for Add/Del - fields := []resource.Field{ + fields := []model.Field{ { Name: "name", Value: "test-instance", - Edit: &resource.Patch{Set: new(string)}, + Edit: &model.Patch{Set: new(string)}, }, { Name: "env", Value: map[string]string{"FOO": "bar"}, - Edit: &resource.Patch{ + Edit: &model.Patch{ Set: map[string]string{}, // set template Add: map[string]string{}, // add template (empty, from struct tag) Del: []string{}, // del template (empty, from struct tag) @@ -889,7 +889,7 @@ func TestEdit_FieldWithAddDelTemplate(t *testing.T) { }, } - patches := []resource.Field{} // No --set patches + patches := []model.Field{} // No --set patches editor := func(ctx context.Context, input []byte) ([]byte, error) { t.Logf("Editor input:\n%s", string(input)) @@ -905,9 +905,9 @@ func TestEdit_FieldWithAddDelTemplate(t *testing.T) { // TestEdit_FieldWithAddDelTemplateFromStruct tests visual editing with fields // generated from actual struct tags (using FieldsFromStruct). func TestEdit_FieldWithAddDelTemplateFromStruct(t *testing.T) { - res := mockResource{name: "test-instance"} + res := mockModel{name: "test-instance"} - // Use resourcet.TestResource which has fields with add/del support + // Use modelt.TestModel which has fields with add/del support type Runtime struct { Env map[string]string `field:",long" edit:"set,add,del=keys"` } @@ -923,18 +923,18 @@ func TestEdit_FieldWithAddDelTemplateFromStruct(t *testing.T) { }, } - fields, err := resource.FieldsFromStruct(inst) + fields, err := model.FieldsFromStruct(inst) require.NoError(t, err) // Log what we got - for path, field := range resource.IterFields(fields) { + for path, field := range model.IterFields(fields) { if field.Edit != nil { t.Logf("Field %s: Edit.Set=%v, Edit.Add=%v (nil=%v), Edit.Del=%v (nil=%v)", path, field.Edit.Set, field.Edit.Add, field.Edit.Add == nil, field.Edit.Del, field.Edit.Del == nil) } } - patches := []resource.Field{} // No --set patches + patches := []model.Field{} // No --set patches editor := func(ctx context.Context, input []byte) ([]byte, error) { t.Logf("Editor input:\n%s", string(input)) @@ -950,7 +950,7 @@ func TestEdit_FieldWithAddDelTemplateFromStruct(t *testing.T) { // TestEdit_SimulateActualCommand simulates the actual command flow: // `unikraft instance edit -e http-go121-yd6jz --dry-run --set vsock=false` func TestEdit_SimulateActualCommand(t *testing.T) { - res := mockResource{name: "test-instance"} + res := mockModel{name: "test-instance"} // Simulate the Instance struct's fields (simplified) type Runtime struct { @@ -971,11 +971,11 @@ func TestEdit_SimulateActualCommand(t *testing.T) { } // Step 1: Get fields from struct (like res.Fields()) - fields, err := resource.FieldsFromStruct(inst) + fields, err := model.FieldsFromStruct(inst) require.NoError(t, err) t.Log("Fields from struct:") - for path, field := range resource.IterFields(fields) { + for path, field := range model.IterFields(fields) { if field.Edit != nil { t.Logf(" %s: Edit.Set=%v, Edit.Add=%v, Edit.Del=%v", path, field.Edit.Set, field.Edit.Add, field.Edit.Del) @@ -994,7 +994,7 @@ func TestEdit_SimulateActualCommand(t *testing.T) { require.NoError(t, err) t.Log("Patched fields (from --set):") - for path, field := range resource.IterFields(patched) { + for path, field := range model.IterFields(patched) { if field.Edit != nil { t.Logf(" %s: Edit.Set=%v, Edit.Add=%v, Edit.Del=%v", path, field.Edit.Set, field.Edit.Add, field.Edit.Del) @@ -1028,20 +1028,20 @@ func (t *imageRef) UnmarshalText(b []byte) error { // types like ImageRef where the value might have different internal state // after YAML round-tripping but should be considered equal. func TestEdit_TextMarshalerEquality(t *testing.T) { - res := mockResource{name: "test"} + res := mockModel{name: "test"} // The current value and template both use imageRef with "nginx" currentValue := imageRef{ref: "nginx"} - fields := []resource.Field{ + fields := []model.Field{ { Name: "image", Value: currentValue, - Edit: &resource.Patch{Set: currentValue}, // Set contains current value + Edit: &model.Patch{Set: currentValue}, // Set contains current value }, } - patches := []resource.Field{} + patches := []model.Field{} editor := func(ctx context.Context, input []byte) ([]byte, error) { // Return the same value - simulates `--cmd cat` @@ -1058,19 +1058,19 @@ func TestEdit_TextMarshalerEquality(t *testing.T) { // TestEdit_TextMarshalerInequality tests that fields with TextMarshaler values // that marshal to different text are considered different. func TestEdit_TextMarshalerInequality(t *testing.T) { - res := mockResource{name: "test"} + res := mockModel{name: "test"} currentValue := imageRef{ref: "nginx"} - fields := []resource.Field{ + fields := []model.Field{ { Name: "image", Value: currentValue, - Edit: &resource.Patch{Set: currentValue}, + Edit: &model.Patch{Set: currentValue}, }, } - patches := []resource.Field{} + patches := []model.Field{} editor := func(ctx context.Context, input []byte) ([]byte, error) { // Change the value @@ -1094,8 +1094,8 @@ func TestSetNestedValue_AncestorWithOwnValue(t *testing.T) { t.Run("expands so the descendant lands", func(t *testing.T) { m := map[string]any{} - require.NoError(t, setNestedValue(m, resource.FieldPath{"service"}, link{Name: "my-group"})) - require.NoError(t, setNestedValue(m, resource.FieldPath{"service", "domains"}, []string{"a.com"})) + require.NoError(t, setNestedValue(m, model.FieldPath{"service"}, link{Name: "my-group"})) + require.NoError(t, setNestedValue(m, model.FieldPath{"service", "domains"}, []string{"a.com"})) assert.Equal(t, map[string]any{"service": map[string]any{ "name": "my-group", @@ -1105,8 +1105,8 @@ func TestSetNestedValue_AncestorWithOwnValue(t *testing.T) { t.Run("descendant wins over the ancestor's own key", func(t *testing.T) { m := map[string]any{} - require.NoError(t, setNestedValue(m, resource.FieldPath{"service"}, link{Name: "from-ancestor"})) - require.NoError(t, setNestedValue(m, resource.FieldPath{"service", "name"}, "from-descendant")) + require.NoError(t, setNestedValue(m, model.FieldPath{"service"}, link{Name: "from-ancestor"})) + require.NoError(t, setNestedValue(m, model.FieldPath{"service", "name"}, "from-descendant")) assert.Equal(t, map[string]any{"service": map[string]any{ "name": "from-descendant", @@ -1115,8 +1115,8 @@ func TestSetNestedValue_AncestorWithOwnValue(t *testing.T) { t.Run("ancestor without an object form still nests", func(t *testing.T) { m := map[string]any{} - require.NoError(t, setNestedValue(m, resource.FieldPath{"service"}, "my-group")) - require.NoError(t, setNestedValue(m, resource.FieldPath{"service", "domains"}, []string{"a.com"})) + require.NoError(t, setNestedValue(m, model.FieldPath{"service"}, "my-group")) + require.NoError(t, setNestedValue(m, model.FieldPath{"service", "domains"}, []string{"a.com"})) assert.Equal(t, map[string]any{"service": map[string]any{ "domains": []string{"a.com"}, @@ -1127,7 +1127,7 @@ func TestSetNestedValue_AncestorWithOwnValue(t *testing.T) { // value while still emitting valid YAML. t.Run("ancestor that cannot marshal errors", func(t *testing.T) { m := map[string]any{"service": failingMarshaler{}} - err := setNestedValue(m, resource.FieldPath{"service", "domains"}, []string{"a.com"}) + err := setNestedValue(m, model.FieldPath{"service", "domains"}, []string{"a.com"}) require.ErrorContains(t, err, "no compact form") }) } diff --git a/internal/resource/registry.go b/internal/model/registry.go similarity index 57% rename from internal/resource/registry.go rename to internal/model/registry.go index 156a266f..ebb60f7b 100644 --- a/internal/resource/registry.go +++ b/internal/model/registry.go @@ -2,32 +2,32 @@ // Copyright (c) 2026, Unikraft GmbH and The Unikraft CLI Authors. // Licensed under the BSD-3-Clause License (the "License"). -package resource +package model type Registry struct { - entries []*ResourceDescriptor - byName map[string]*ResourceDescriptor + entries []*ModelDescriptor + byName map[string]*ModelDescriptor } -type ResourceDescriptor struct { +type ModelDescriptor struct { Name string Names string - List ListableResource - Get GettableResource + List ListableModel + Get GettableModel } func NewRegistry() *Registry { return &Registry{ - byName: make(map[string]*ResourceDescriptor), + byName: make(map[string]*ModelDescriptor), } } -func (r *Registry) Register(list ListableResource, get GettableResource) *ResourceDescriptor { +func (r *Registry) Register(list ListableModel, get GettableModel) *ModelDescriptor { if list == nil { return nil } info := list.Type() - desc := &ResourceDescriptor{ + desc := &ModelDescriptor{ Name: info.Name, Names: info.Names, List: list, @@ -39,11 +39,11 @@ func (r *Registry) Register(list ListableResource, get GettableResource) *Resour return desc } -func (r *Registry) Entries() []*ResourceDescriptor { +func (r *Registry) Entries() []*ModelDescriptor { return r.entries } -func (r *Registry) Resolve(name string) (*ResourceDescriptor, bool) { +func (r *Registry) Resolve(name string) (*ModelDescriptor, bool) { if name == "" { return nil, false } diff --git a/internal/resource/struct.go b/internal/model/struct.go similarity index 99% rename from internal/resource/struct.go rename to internal/model/struct.go index 6b4d7275..5e22cada 100644 --- a/internal/resource/struct.go +++ b/internal/model/struct.go @@ -3,7 +3,7 @@ // Licensed under the BSD-3-Clause License (the "License"). // You may not use this file except in compliance with the License. -package resource +package model import ( "cmp" diff --git a/internal/resource/struct_test.go b/internal/model/struct_test.go similarity index 99% rename from internal/resource/struct_test.go rename to internal/model/struct_test.go index c3044aec..ee1b8d70 100644 --- a/internal/resource/struct_test.go +++ b/internal/model/struct_test.go @@ -3,7 +3,7 @@ // Licensed under the BSD-3-Clause License (the "License"). // You may not use this file except in compliance with the License. -package resource +package model import ( "testing" diff --git a/internal/resource/testing/resource.go b/internal/model/testing/model.go similarity index 61% rename from internal/resource/testing/resource.go rename to internal/model/testing/model.go index 46a19bbc..005b5251 100644 --- a/internal/resource/testing/resource.go +++ b/internal/model/testing/model.go @@ -12,33 +12,33 @@ import ( "strings" "time" - "unikraft.com/cli/internal/resource" + "unikraft.com/cli/internal/model" "unikraft.com/cli/internal/types" ) -// TestEnv holds all test state for TestResource operations. +// TestEnv holds all test state for TestModel operations. // Store it in context using WithTestEnv. type TestEnv struct { - Store map[string]TestResource + Store map[string]TestModel Hooks Hooks } // NewTestEnv creates a new test environment with an empty store. func NewTestEnv() *TestEnv { return &TestEnv{ - Store: make(map[string]TestResource), + Store: make(map[string]TestModel), } } -// Add adds a TestResource to the environment's store. -func (e *TestEnv) Add(r TestResource) { +// Add adds a TestModel to the environment's store. +func (e *TestEnv) Add(r TestModel) { e.Store[r.Name] = r } -// NewResource returns a new zero-value TestResource. +// NewModel returns a new zero-value TestModel. // The actual test state is stored in context via WithTestEnv. -func (e *TestEnv) NewResource() TestResource { - return TestResource{} +func (e *TestEnv) NewModel() TestModel { + return TestModel{} } type testEnvKey struct{} @@ -57,7 +57,7 @@ func testEnvFrom(ctx context.Context) *TestEnv { return env } -type TestResource struct { +type TestModel struct { ID string Name string State string @@ -73,11 +73,11 @@ type TestResource struct { } var ( - _ resource.Resource = (*TestResource)(nil) - _ resource.GettableResource = (*TestResource)(nil) - _ resource.EditableResource = (*TestResource)(nil) - _ resource.CreatableResource = (*TestResource)(nil) - _ resource.DeletableResource = (*TestResource)(nil) + _ model.Model = (*TestModel)(nil) + _ model.GettableModel = (*TestModel)(nil) + _ model.EditableModel = (*TestModel)(nil) + _ model.CreatableModel = (*TestModel)(nil) + _ model.DeletableModel = (*TestModel)(nil) ) type TestSettings struct { @@ -93,15 +93,15 @@ type TestAuthor struct { } type Hooks struct { - List func(context.Context, func(context.Context) ([]resource.Resource, error)) ([]resource.Resource, error) - Get func(context.Context, []string, func(context.Context, []string) ([]resource.Resource, error)) ([]resource.Resource, error) - Create func(context.Context, []resource.Field, func(context.Context, []resource.Field) ([]resource.Resource, error)) ([]resource.Resource, error) + List func(context.Context, func(context.Context) ([]model.Model, error)) ([]model.Model, error) + Get func(context.Context, []string, func(context.Context, []string) ([]model.Model, error)) ([]model.Model, error) + Create func(context.Context, []model.Field, func(context.Context, []model.Field) ([]model.Model, error)) ([]model.Model, error) Delete func(context.Context, []string, func(context.Context, []string) error) error OnLazyResolve func(resourceName string) } -func (TestResource) Type() resource.Type { - return resource.Type{ +func (TestModel) Type() model.Type { + return model.Type{ Name: "test", Names: "tests", } @@ -117,60 +117,60 @@ func (k staticKey) Canonical() string { return string(k) } -func (t TestResource) Key() resource.Key { +func (t TestModel) Key() model.Key { return staticKey(t.Name) } -func (t TestResource) Raw() any { +func (t TestModel) Raw() any { return t } -func (t TestResource) Fields(ctx context.Context) ([]resource.Field, error) { +func (t TestModel) Fields(ctx context.Context) ([]model.Field, error) { // Note: ValueCallback receives context and can access TestEnv from there - return []resource.Field{ + return []model.Field{ { Name: "id", Value: t.ID, - Verbosity: resource.FieldVerbosityShort, + Verbosity: model.FieldVerbosityShort, }, { Name: "name", Value: t.Name, - Verbosity: resource.FieldVerbosityShort, - Create: &resource.Patch{ + Verbosity: model.FieldVerbosityShort, + Create: &model.Patch{ Set: t.Name, // Use actual value, not empty string }, }, { Name: "state", Value: t.State, - Verbosity: resource.FieldVerbosityShort, + Verbosity: model.FieldVerbosityShort, Help: "Lifecycle state: running or stopped.", }, { Name: "url", Value: t.URL, - Verbosity: resource.FieldVerbosityShort, + Verbosity: model.FieldVerbosityShort, }, { Name: "created", Value: t.Created, - Verbosity: resource.FieldVerbosityLong, + Verbosity: model.FieldVerbosityLong, }, { Name: "hidden", Value: t.Hidden, - Verbosity: resource.FieldVerbosityHidden, + Verbosity: model.FieldVerbosityHidden, }, { Name: "invisible", Value: t.Invisible, - Verbosity: resource.FieldVerbosityInvisible, + Verbosity: model.FieldVerbosityInvisible, }, { Name: "lazy", Value: t.Lazy, - Verbosity: resource.FieldVerbosityHidden, + Verbosity: model.FieldVerbosityHidden, ValueCallback: func(ctx context.Context) (any, error) { env := testEnvFrom(ctx) if env.Hooks.OnLazyResolve != nil { @@ -183,8 +183,8 @@ func (t TestResource) Fields(ctx context.Context) ([]resource.Field, error) { { Name: "create_only", Value: nil, - Verbosity: resource.FieldVerbosityLong, - Create: &resource.Patch{ + Verbosity: model.FieldVerbosityLong, + Create: &model.Patch{ Set: "", // Type template only - no actual value since Value is nil }, }, @@ -192,84 +192,84 @@ func (t TestResource) Fields(ctx context.Context) ([]resource.Field, error) { { Name: "edit_only", Value: nil, - Verbosity: resource.FieldVerbosityLong, - Edit: &resource.Patch{ + Verbosity: model.FieldVerbosityLong, + Edit: &model.Patch{ Set: "", // Type template only - no actual value since Value is nil }, }, { Name: "settings", - Verbosity: resource.FieldVerbosityLong, - Subfields: []resource.Field{ + Verbosity: model.FieldVerbosityLong, + Subfields: []model.Field{ { Name: "foo", Value: t.Settings.Foo, - Verbosity: resource.FieldVerbosityLong, + Verbosity: model.FieldVerbosityLong, Help: "Foo setting.", - Flag: &resource.FlagSpec{Name: "foo", Tag: `help:"Foo setting."`}, - Create: &resource.Patch{ + Flag: &model.FlagSpec{Name: "foo", Tag: `help:"Foo setting."`}, + Create: &model.Patch{ Set: t.Settings.Foo, // Use actual value }, - Edit: &resource.Patch{ + Edit: &model.Patch{ Set: t.Settings.Foo, // Use actual value }, }, { Name: "bar", Value: t.Settings.Bar, - Verbosity: resource.FieldVerbosityLong, + Verbosity: model.FieldVerbosityLong, Help: "Bar setting.", - Flag: &resource.FlagSpec{Name: "bar", Tag: `help:"Bar setting."`}, - Create: &resource.Patch{ + Flag: &model.FlagSpec{Name: "bar", Tag: `help:"Bar setting."`}, + Create: &model.Patch{ Set: t.Settings.Bar, // Use actual value }, - Edit: &resource.Patch{ + Edit: &model.Patch{ Set: t.Settings.Bar, // Use actual value }, }, { Name: "score", Value: t.Settings.Score, - Verbosity: resource.FieldVerbosityLong, + Verbosity: model.FieldVerbosityLong, }, { Name: "flag", Value: t.Settings.Flag, - Verbosity: resource.FieldVerbosityLong, + Verbosity: model.FieldVerbosityLong, }, }, }, { Name: "authors", - Verbosity: resource.FieldVerbosityLong, - Elem: &resource.Field{ - Subfields: []resource.Field{ + Verbosity: model.FieldVerbosityLong, + Elem: &model.Field{ + Subfields: []model.Field{ { Name: "name", - Verbosity: resource.FieldVerbosityLong, + Verbosity: model.FieldVerbosityLong, }, { Name: "email", - Verbosity: resource.FieldVerbosityLong, + Verbosity: model.FieldVerbosityLong, }, }, }, - Subfields: func() []resource.Field { - var fields []resource.Field + Subfields: func() []model.Field { + var fields []model.Field for i, author := range t.Authors { - fields = append(fields, resource.Field{ + fields = append(fields, model.Field{ Name: fmt.Sprintf("%d", i), - Verbosity: resource.FieldVerbosityLong, - Subfields: []resource.Field{ + Verbosity: model.FieldVerbosityLong, + Subfields: []model.Field{ { Name: "name", Value: author.Name, - Verbosity: resource.FieldVerbosityLong, + Verbosity: model.FieldVerbosityLong, }, { Name: "email", Value: author.Email, - Verbosity: resource.FieldVerbosityLong, + Verbosity: model.FieldVerbosityLong, }, }, }) @@ -280,26 +280,26 @@ func (t TestResource) Fields(ctx context.Context) ([]resource.Field, error) { { Name: "tags", Value: t.Tags, - Verbosity: resource.FieldVerbosityLong, + Verbosity: model.FieldVerbosityLong, }, { Name: "usage", Value: t.Usage, - Verbosity: resource.FieldVerbosityLong, + Verbosity: model.FieldVerbosityLong, }, }, nil } -func (TestResource) List(ctx context.Context) ([]resource.Resource, error) { +func (TestModel) List(ctx context.Context) ([]model.Model, error) { env := testEnvFrom(ctx) - original := func(context.Context) ([]resource.Resource, error) { - var resources []resource.Resource + original := func(context.Context) ([]model.Model, error) { + var resources []model.Model for _, r := range env.Store { resources = append(resources, r) } // Sort by ID for deterministic output - slices.SortFunc(resources, func(a, b resource.Resource) int { - return strings.Compare(a.(TestResource).ID, b.(TestResource).ID) + slices.SortFunc(resources, func(a, b model.Model) int { + return strings.Compare(a.(TestModel).ID, b.(TestModel).ID) }) return resources, nil } @@ -309,11 +309,11 @@ func (TestResource) List(ctx context.Context) ([]resource.Resource, error) { return original(ctx) } -func (TestResource) Get(ctx context.Context, keys []string) ([]resource.Resource, error) { +func (TestModel) Get(ctx context.Context, keys []string) ([]model.Model, error) { env := testEnvFrom(ctx) - original := func(_ context.Context, keys []string) ([]resource.Resource, error) { + original := func(_ context.Context, keys []string) ([]model.Model, error) { // Build a map for lookup - resourceMap := make(map[string]TestResource) + resourceMap := make(map[string]TestModel) for _, key := range keys { if r, ok := env.Store[key]; ok { resourceMap[key] = r @@ -321,7 +321,7 @@ func (TestResource) Get(ctx context.Context, keys []string) ([]resource.Resource } // Return resources in the order of keys provided - var resources []resource.Resource + var resources []model.Model for _, key := range keys { if r, ok := resourceMap[key]; ok { resources = append(resources, r) @@ -335,14 +335,14 @@ func (TestResource) Get(ctx context.Context, keys []string) ([]resource.Resource return original(ctx, keys) } -func (TestResource) Create(ctx context.Context, fields []resource.Field) ([]resource.Resource, error) { +func (TestModel) Create(ctx context.Context, fields []model.Field) ([]model.Model, error) { env := testEnvFrom(ctx) - original := func(_ context.Context, fields []resource.Field) ([]resource.Resource, error) { - r := TestResource{ + original := func(_ context.Context, fields []model.Field) ([]model.Model, error) { + r := TestModel{ Settings: TestSettings{}, } - for key, field := range resource.IterFields(fields) { + for key, field := range model.IterFields(fields) { if field.Create == nil || field.Create.Set == nil { continue } @@ -357,7 +357,7 @@ func (TestResource) Create(ctx context.Context, fields []resource.Field) ([]reso } env.Store[r.Name] = r - return []resource.Resource{r}, nil + return []model.Model{r}, nil } if env.Hooks.Create != nil { return env.Hooks.Create(ctx, fields, original) @@ -365,14 +365,14 @@ func (TestResource) Create(ctx context.Context, fields []resource.Field) ([]reso return original(ctx, fields) } -func (TestResource) Edit(ctx context.Context, key string, fields []resource.Field) error { +func (TestModel) Edit(ctx context.Context, key string, fields []model.Field) error { env := testEnvFrom(ctx) r, ok := env.Store[key] if !ok { return fmt.Errorf("resource %q not found", key) } - for key, field := range resource.IterFields(fields) { + for key, field := range model.IterFields(fields) { if field.Edit == nil || field.Edit.Set == nil { continue } @@ -388,7 +388,7 @@ func (TestResource) Edit(ctx context.Context, key string, fields []resource.Fiel return nil } -func (TestResource) Delete(ctx context.Context, keys []string) error { +func (TestModel) Delete(ctx context.Context, keys []string) error { env := testEnvFrom(ctx) original := func(_ context.Context, keys []string) error { for _, key := range keys { diff --git a/internal/resource/testing/store_seed.go b/internal/model/testing/store_seed.go similarity index 93% rename from internal/resource/testing/store_seed.go rename to internal/model/testing/store_seed.go index c74e0548..11d9433a 100644 --- a/internal/resource/testing/store_seed.go +++ b/internal/model/testing/store_seed.go @@ -9,8 +9,8 @@ package testing // // Note: this is intentionally a function (not a var) so callers get a new map // and slices each time. -func BaseTestStore() map[string]TestResource { - return map[string]TestResource{ +func BaseTestStore() map[string]TestModel { + return map[string]TestModel{ "test1": { ID: "id-test1", Name: "test1", diff --git a/internal/resource/tui/actions.go b/internal/model/tui/actions.go similarity index 100% rename from internal/resource/tui/actions.go rename to internal/model/tui/actions.go diff --git a/internal/resource/tui/helpers.go b/internal/model/tui/helpers.go similarity index 100% rename from internal/resource/tui/helpers.go rename to internal/model/tui/helpers.go diff --git a/internal/resource/tui/panel_detail.go b/internal/model/tui/panel_detail.go similarity index 90% rename from internal/resource/tui/panel_detail.go rename to internal/model/tui/panel_detail.go index 2eb94403..a5b35993 100644 --- a/internal/resource/tui/panel_detail.go +++ b/internal/model/tui/panel_detail.go @@ -16,21 +16,21 @@ import ( "github.com/charmbracelet/x/ansi" "unikraft.com/x/colors" - "unikraft.com/cli/internal/resource" - resourcecmd "unikraft.com/cli/internal/resource/cmd" - "unikraft.com/cli/internal/resource/value" + "unikraft.com/cli/internal/model" + modelcmd "unikraft.com/cli/internal/model/cmd" + "unikraft.com/cli/internal/model/value" "unikraft.com/cli/internal/tui/styles" "unikraft.com/cli/internal/tui/uitui" ) type detailPanel struct { ctx context.Context - registry *resource.Registry - desc *resource.ResourceDescriptor + registry *model.Registry + desc *model.ModelDescriptor key string table table.Model - rowLinks []resource.Link + rowLinks []model.Link err error loading bool @@ -40,11 +40,11 @@ type detailPanel struct { } type kvDataMsg struct { - resource resource.Resource + resource model.Model err error } -func NewDetailPanel(ctx context.Context, registry *resource.Registry, desc *resource.ResourceDescriptor, key string) *detailPanel { +func NewDetailPanel(ctx context.Context, registry *model.Registry, desc *model.ModelDescriptor, key string) *detailPanel { return &detailPanel{ ctx: ctx, registry: registry, @@ -129,7 +129,7 @@ func (p *detailPanel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return p, nil } p.err = nil - p.renderResource(msg.resource) + p.renderModel(msg.resource) p.layout() return p, nil case actionOpen: @@ -159,7 +159,7 @@ func (p *detailPanel) View() tea.View { return tea.NewView(p.table.View()) } -func (p *detailPanel) renderResource(res resource.Resource) { +func (p *detailPanel) renderModel(res model.Model) { fields, err := res.Fields(p.ctx) if err != nil { p.err = err @@ -167,17 +167,17 @@ func (p *detailPanel) renderResource(res resource.Resource) { p.rowLinks = nil return } - fields, err = resourcecmd.SelectFields(fields, false, resource.FieldVerbosityLong, nil) + fields, err = modelcmd.SelectFields(fields, false, model.FieldVerbosityLong, nil) if err != nil { p.err = err p.table.SetRows(nil) p.rowLinks = nil return } - fields = resource.DedupeFields(fields) + fields = model.DedupeFields(fields) rows := make([]table.Row, 0) - links := make([]resource.Link, 0) + links := make([]model.Link, 0) if err := appendKVRows(&rows, &links, nil, fields, 0); err != nil { p.err = err p.table.SetRows(nil) @@ -225,7 +225,7 @@ func (p *detailPanel) openSelected() tea.Cmd { } } -func (p *detailPanel) currentLink() resource.Link { +func (p *detailPanel) currentLink() model.Link { idx := p.table.Cursor() if idx < 0 || idx >= len(p.rowLinks) { return nil @@ -251,7 +251,7 @@ func (p *detailPanel) layout() { p.configureTable(p.width, p.height, p.focused) } -func appendKVRows(rows *[]table.Row, links *[]resource.Link, parent *resource.Field, fields []resource.Field, indent int) error { +func appendKVRows(rows *[]table.Row, links *[]model.Link, parent *model.Field, fields []model.Field, indent int) error { linkColor := compat.AdaptiveColor{Light: colors.Slate600, Dark: colors.Slate400} linkSeq := ansi.NewStyle(ansi.AttrItalic, ansi.AttrUnderline).ForegroundColor(compat.Profile.Convert(linkColor)).String() linkReset := ansi.NewStyle(ansi.AttrNoItalic, ansi.AttrNoUnderline).ForegroundColor(nil).String() @@ -348,7 +348,7 @@ func appendKVRows(rows *[]table.Row, links *[]resource.Link, parent *resource.Fi return nil } -func firstLink(field resource.Field) resource.Link { +func firstLink(field model.Field) model.Link { if len(field.Links) == 0 { return nil } diff --git a/internal/resource/tui/panel_home.go b/internal/model/tui/panel_home.go similarity index 92% rename from internal/resource/tui/panel_home.go rename to internal/model/tui/panel_home.go index fdad75c6..8ab9048b 100644 --- a/internal/resource/tui/panel_home.go +++ b/internal/model/tui/panel_home.go @@ -15,7 +15,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/charmbracelet/x/ansi" - "unikraft.com/cli/internal/resource" + "unikraft.com/cli/internal/model" "unikraft.com/cli/internal/tui/monkey" "unikraft.com/cli/internal/tui/uitui" "unikraft.com/x/colors" @@ -33,9 +33,9 @@ var homeArt = heredoc.Docf(` type homePanel struct { ctx context.Context - registry *resource.Registry + registry *model.Registry - entries []*resource.ResourceDescriptor + entries []*model.ModelDescriptor cursor int monkey monkey.Model @@ -45,13 +45,13 @@ type homePanel struct { focused bool } -func NewHomePanel(ctx context.Context, registry *resource.Registry) tea.Model { +func NewHomePanel(ctx context.Context, registry *model.Registry) tea.Model { panel := &homePanel{ ctx: ctx, registry: registry, monkey: monkey.New(), } - panel.syncResources() + panel.syncModels() return panel } @@ -136,7 +136,7 @@ func (p *homePanel) View() tea.View { return tea.NewView(body) } -func (p *homePanel) syncResources() { +func (p *homePanel) syncModels() { if p.registry == nil { p.entries = nil p.cursor = 0 @@ -144,7 +144,7 @@ func (p *homePanel) syncResources() { } entries := slices.Clone(p.registry.Entries()) - slices.SortFunc(entries, func(a, b *resource.ResourceDescriptor) int { + slices.SortFunc(entries, func(a, b *model.ModelDescriptor) int { return strings.Compare(a.Name, b.Name) }) p.entries = entries @@ -166,7 +166,7 @@ func (p *homePanel) openSelected() tea.Cmd { } } -func (p *homePanel) currentDescriptor() *resource.ResourceDescriptor { +func (p *homePanel) currentDescriptor() *model.ModelDescriptor { if p.cursor < 0 || p.cursor >= len(p.entries) { return nil } diff --git a/internal/resource/tui/panel_list.go b/internal/model/tui/panel_list.go similarity index 84% rename from internal/resource/tui/panel_list.go rename to internal/model/tui/panel_list.go index bff4c0d8..3dea23c6 100644 --- a/internal/resource/tui/panel_list.go +++ b/internal/model/tui/panel_list.go @@ -12,9 +12,9 @@ import ( "charm.land/bubbles/v2/table" tea "charm.land/bubbletea/v2" - "unikraft.com/cli/internal/resource" - resourcecmd "unikraft.com/cli/internal/resource/cmd" - "unikraft.com/cli/internal/resource/value" + "unikraft.com/cli/internal/model" + modelcmd "unikraft.com/cli/internal/model/cmd" + "unikraft.com/cli/internal/model/value" "unikraft.com/cli/internal/tui/styles" "unikraft.com/cli/internal/tui/uitui" wslices "unikraft.com/cli/internal/w/slices" @@ -22,8 +22,8 @@ import ( type listPanel struct { ctx context.Context - registry *resource.Registry - desc *resource.ResourceDescriptor + registry *model.Registry + desc *model.ModelDescriptor table table.Model headers []string @@ -37,11 +37,11 @@ type listPanel struct { } type listDataMsg struct { - resources []resource.Resource + resources []model.Model err error } -func NewListPanel(ctx context.Context, registry *resource.Registry, desc *resource.ResourceDescriptor) *listPanel { +func NewListPanel(ctx context.Context, registry *model.Registry, desc *model.ModelDescriptor) *listPanel { return &listPanel{ ctx: ctx, registry: registry, @@ -118,7 +118,7 @@ func (p *listPanel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return p, nil } p.err = nil - p.applyResources(msg.resources) + p.applyModels(msg.resources) p.layout() return p, nil case actionOpen: @@ -148,7 +148,7 @@ func (p *listPanel) View() tea.View { return tea.NewView(p.table.View()) } -func (p *listPanel) applyResources(resources []resource.Resource) { +func (p *listPanel) applyModels(resources []model.Model) { if p.desc == nil { p.headers = nil p.rowKeys = nil @@ -161,18 +161,18 @@ func (p *listPanel) applyResources(resources []resource.Resource) { p.table.SetRows(nil) return } - fields, err = resourcecmd.SelectFields(fields, true, resource.FieldVerbosityShort, nil) + fields, err = modelcmd.SelectFields(fields, true, model.FieldVerbosityShort, nil) if err != nil { p.err = err p.table.SetRows(nil) return } for i := range fields { - fields[i] = resource.PruneFields(fields[i]) + fields[i] = model.PruneFields(fields[i]) } - paths, headers := wslices.Collect2(resource.IterFields(fields)) - colPaths := make([]resource.FieldPath, 0, len(headers)) + paths, headers := wslices.Collect2(model.IterFields(fields)) + colPaths := make([]model.FieldPath, 0, len(headers)) colHeaders := make([]string, 0, len(headers)) for i, header := range headers { if header.HasChildren() && header.Value == nil { @@ -241,17 +241,17 @@ func (p *listPanel) layout() { p.configureTable(p.width, p.height, p.focused) } -func (p *listPanel) renderField(res resource.Resource, path resource.FieldPath) string { +func (p *listPanel) renderField(res model.Model, path model.FieldPath) string { fields, err := res.Fields(p.ctx) if err != nil { return "" } - fields = resource.GetFieldByPath(fields, path) + fields = model.GetFieldByPath(fields, path) for i := range fields { - fields[i] = resource.PruneFields(fields[i]) + fields[i] = model.PruneFields(fields[i]) } values := make([]string, 0) - for _, field := range resource.IterFields(fields) { + for _, field := range model.IterFields(fields) { if field.Value == nil { continue } diff --git a/internal/resource/tui/panel_reader.go b/internal/model/tui/panel_reader.go similarity index 100% rename from internal/resource/tui/panel_reader.go rename to internal/model/tui/panel_reader.go diff --git a/internal/resource/tui/subpanels.go b/internal/model/tui/subpanels.go similarity index 100% rename from internal/resource/tui/subpanels.go rename to internal/model/tui/subpanels.go diff --git a/internal/resource/tui/testdata/TestTUIBasic b/internal/model/tui/testdata/TestTUIBasic similarity index 100% rename from internal/resource/tui/testdata/TestTUIBasic rename to internal/model/tui/testdata/TestTUIBasic diff --git a/internal/resource/tui/testdata/TestTUIDetailWithSubpanel b/internal/model/tui/testdata/TestTUIDetailWithSubpanel similarity index 100% rename from internal/resource/tui/testdata/TestTUIDetailWithSubpanel rename to internal/model/tui/testdata/TestTUIDetailWithSubpanel diff --git a/internal/resource/tui/testdata/TestTUIHomePanel b/internal/model/tui/testdata/TestTUIHomePanel similarity index 100% rename from internal/resource/tui/testdata/TestTUIHomePanel rename to internal/model/tui/testdata/TestTUIHomePanel diff --git a/internal/resource/tui/testdata/TestTUIWidePanels b/internal/model/tui/testdata/TestTUIWidePanels similarity index 100% rename from internal/resource/tui/testdata/TestTUIWidePanels rename to internal/model/tui/testdata/TestTUIWidePanels diff --git a/internal/resource/tui/tui_test.go b/internal/model/tui/tui_test.go similarity index 79% rename from internal/resource/tui/tui_test.go rename to internal/model/tui/tui_test.go index 8d08d6ba..eeb38c33 100644 --- a/internal/resource/tui/tui_test.go +++ b/internal/model/tui/tui_test.go @@ -20,9 +20,9 @@ import ( "gotest.tools/v3/golden" "unikraft.com/cli/internal/cmd" - "unikraft.com/cli/internal/resource" - resourcet "unikraft.com/cli/internal/resource/testing" - tui "unikraft.com/cli/internal/resource/tui" + "unikraft.com/cli/internal/model" + modelt "unikraft.com/cli/internal/model/testing" + tui "unikraft.com/cli/internal/model/tui" "unikraft.com/cli/internal/tui/testkit" "unikraft.com/cli/internal/tui/uitui" ) @@ -32,20 +32,20 @@ func TestMain(m *testing.M) { os.Exit(m.Run()) } -type subpanelTestResource struct { - resourcet.TestResource +type subpanelTestModel struct { + modelt.TestModel } -func (subpanelTestResource) Subpanels(ctx context.Context, key string) []tea.Model { +func (subpanelTestModel) Subpanels(ctx context.Context, key string) []tea.Model { readerFunc := func(ctx context.Context) (io.ReadCloser, error) { return io.NopCloser(strings.NewReader("sample logs\n")), nil } return []tea.Model{tui.NewReaderPanelFunc(ctx, "Logs", readerFunc, true)} } -func setupTestEnv() *resourcet.TestEnv { - env := resourcet.NewTestEnv() - for _, r := range resourcet.BaseTestStore() { +func setupTestEnv() *modelt.TestEnv { + env := modelt.NewTestEnv() + for _, r := range modelt.BaseTestStore() { env.Add(r) } return env @@ -53,14 +53,14 @@ func setupTestEnv() *resourcet.TestEnv { func TestTUIBasic(t *testing.T) { env := setupTestEnv() - ctx := resourcet.WithTestEnv(context.Background(), env) + ctx := modelt.WithTestEnv(context.Background(), env) - registry := resource.NewRegistry() - desc := registry.Register(resourcet.TestResource{}, resourcet.TestResource{}) + registry := model.NewRegistry() + desc := registry.Register(modelt.TestModel{}, modelt.TestModel{}) panel := tui.NewListPanel(ctx, registry, desc) - model := uitui.NewModel(panel) + m := uitui.NewModel(panel) - runner := testkit.New(model, 80, 18) + runner := testkit.New(m, 80, 18) defer runner.Stop() listView, err := runner.WaitUntilSnapshot(2*time.Second, 0, func(s string) bool { @@ -87,14 +87,14 @@ func TestTUIBasic(t *testing.T) { func TestTUIWidePanels(t *testing.T) { env := setupTestEnv() - ctx := resourcet.WithTestEnv(context.Background(), env) + ctx := modelt.WithTestEnv(context.Background(), env) - registry := resource.NewRegistry() - desc := registry.Register(resourcet.TestResource{}, resourcet.TestResource{}) + registry := model.NewRegistry() + desc := registry.Register(modelt.TestModel{}, modelt.TestModel{}) panel := tui.NewListPanel(ctx, registry, desc) - model := uitui.NewModel(panel) + m := uitui.NewModel(panel) - runner := testkit.New(model, 140, 18) + runner := testkit.New(m, 140, 18) defer runner.Stop() listView, err := runner.WaitUntilSnapshot(2*time.Second, 0, func(s string) bool { @@ -122,10 +122,10 @@ func TestTUIWidePanels(t *testing.T) { func TestTUIHomePanel(t *testing.T) { ctx := context.Background() - model, err := cmd.NewTUIModel(ctx, "", "") + m, err := cmd.NewTUIModel(ctx, "", "") require.NoError(t, err) - runner := testkit.New(model, 80, 22) + runner := testkit.New(m, 80, 22) defer runner.Stop() view, err := runner.WaitUntilSnapshot(3*time.Second, 0, func(s string) bool { @@ -144,13 +144,13 @@ func TestTUIHomePanel(t *testing.T) { func TestTUIDetailWithSubpanel(t *testing.T) { env := setupTestEnv() - ctx := resourcet.WithTestEnv(context.Background(), env) + ctx := modelt.WithTestEnv(context.Background(), env) - registry := resource.NewRegistry() - desc := registry.Register(subpanelTestResource{}, subpanelTestResource{}) + registry := model.NewRegistry() + desc := registry.Register(subpanelTestModel{}, subpanelTestModel{}) listPanel := tui.NewListPanel(ctx, registry, desc) - model := uitui.NewModel(listPanel) - runner := testkit.New(model, 80, 30) + m := uitui.NewModel(listPanel) + runner := testkit.New(m, 80, 30) defer runner.Stop() listView, err := runner.WaitUntilSnapshot(2*time.Second, 0, func(s string) bool { diff --git a/internal/resource/value/compare.go b/internal/model/value/compare.go similarity index 100% rename from internal/resource/value/compare.go rename to internal/model/value/compare.go diff --git a/internal/resource/value/format.go b/internal/model/value/format.go similarity index 100% rename from internal/resource/value/format.go rename to internal/model/value/format.go diff --git a/internal/resource/value/format_test.go b/internal/model/value/format_test.go similarity index 100% rename from internal/resource/value/format_test.go rename to internal/model/value/format_test.go diff --git a/internal/resource/value/parse.go b/internal/model/value/parse.go similarity index 100% rename from internal/resource/value/parse.go rename to internal/model/value/parse.go diff --git a/internal/resource/value/parse_test.go b/internal/model/value/parse_test.go similarity index 100% rename from internal/resource/value/parse_test.go rename to internal/model/value/parse_test.go diff --git a/internal/multimetro/filter.go b/internal/multimetro/filter.go index 45a57317..d09e9064 100644 --- a/internal/multimetro/filter.go +++ b/internal/multimetro/filter.go @@ -10,11 +10,11 @@ import ( "unikraft.com/x/filters" - "unikraft.com/cli/internal/resource" + "unikraft.com/cli/internal/model" ) func filterMetrosFromCtx(ctx context.Context, names []string) []string { - return filterMetros(names, resource.FilterFromContext(ctx)) + return filterMetros(names, model.FilterFromContext(ctx)) } func filterMetros(names []string, spec filters.Filter) []string { diff --git a/internal/resource/cmd/fields.go b/internal/resource/cmd/fields.go deleted file mode 100644 index d118fda2..00000000 --- a/internal/resource/cmd/fields.go +++ /dev/null @@ -1,86 +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 ( - "fmt" - - "unikraft.com/cli/internal/resource" -) - -// FieldSpecAll is the special field spec value that selects all fields, -// regardless of verbosity or emptiness. -const FieldSpecAll = "all" - -func SelectFields(fields []resource.Field, header bool, verbosity resource.FieldVerbosity, fieldSpecs []string) ([]resource.Field, error) { - var base []resource.FieldPath - var include []resource.FieldPath - var exclude []resource.FieldPath - for _, field := range fieldSpecs { - if len(field) == 0 { - continue - } - if field == FieldSpecAll { - if base == nil { - base = []resource.FieldPath{} - } - continue - } - switch field[0] { - case '+': - field = field[1:] - include = append(include, resource.ParseFieldPath(field)) - case '-': - field = field[1:] - exclude = append(exclude, resource.ParseFieldPath(field)) - default: - base = append(base, resource.ParseFieldPath(field)) - } - } - - var missing []resource.FieldPath - - result, missing := resource.FilterFieldsByPath(fields, base, !header) - if base == nil { - result = resource.FilterFields(result, func(field resource.Field) resource.FilterResult { - // remove fields that are too verbose - if field.Verbosity < verbosity { - return resource.FilterExclude - } - // remove empty fields - if !header && field.IsEmpty() { - return resource.FilterExclude - } - return resource.FilterRecurse - }) - } else { - result = resource.FilterFields(result, func(field resource.Field) resource.FilterResult { - // remove invisible fields - if field.Verbosity == resource.FieldVerbosityInvisible { - return resource.FilterExclude - } - return resource.FilterRecurse - }) - } - - if len(include) > 0 { - included, includeMissing := resource.FilterFieldsByPath(fields, include, !header) - result = resource.MergeFields(result, included) - missing = append(missing, includeMissing...) - } - - if len(exclude) > 0 { - excluded, excludeMissing := resource.FilterFieldsByPath(fields, exclude, !header) - result = resource.RemoveFields(result, excluded) - missing = append(missing, excludeMissing...) - } - - if len(missing) > 0 { - return nil, fmt.Errorf("unknown fields: %v", missing) - } - - return result, nil -} diff --git a/internal/resource/cmd/resolve.go b/internal/resource/cmd/resolve.go deleted file mode 100644 index 71f1e7e3..00000000 --- a/internal/resource/cmd/resolve.go +++ /dev/null @@ -1,114 +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" - - "unikraft.com/cli/internal/resource" - "unikraft.com/x/joinerrgroup" -) - -type resolvedResource struct { - resource.Resource - fields []resource.Field -} - -func (r resolvedResource) Fields(ctx context.Context) ([]resource.Field, error) { - return resource.CloneFields(r.fields), nil -} - -func resolveResources(ctx context.Context, resources []resource.Resource, paths []resource.FieldPath) ([]resource.Resource, error) { - if len(resources) == 0 { - return resources, nil - } - - resolved := make([]resource.Resource, len(resources)) - eg := joinerrgroup.Group{} - for i, res := range resources { - eg.Go(func() error { - resolvedRes, err := resolveResource(ctx, res, paths) - if err != nil { - return err - } - resolved[i] = resolvedRes - return nil - }) - } - if err := eg.Wait(); err != nil { - return nil, err - } - return resolved, nil -} - -func resolveResource(ctx context.Context, res resource.Resource, paths []resource.FieldPath) (resource.Resource, error) { - fields, err := res.Fields(ctx) - if err != nil { - return nil, err - } - if len(paths) == 0 { - return res, nil - } - - resolved := resource.CloneFields(fields) - if err := resource.ResolveFields(ctx, resolved, paths); err != nil { - return nil, err - } - - return resolvedResource{Resource: res, fields: resolved}, nil -} - -func resolveAllResources(ctx context.Context, resources []resource.Resource) ([]resource.Resource, error) { - if len(resources) == 0 { - return resources, nil - } - - resolved := make([]resource.Resource, len(resources)) - eg := joinerrgroup.Group{} - for i, res := range resources { - eg.Go(func() error { - resolvedRes, err := resolveAllResource(ctx, res) - if err != nil { - return err - } - resolved[i] = resolvedRes - return nil - }) - } - if err := eg.Wait(); err != nil { - return nil, err - } - return resolved, nil -} - -func resolveAllResource(ctx context.Context, res resource.Resource) (resource.Resource, error) { - fields, err := res.Fields(ctx) - if err != nil { - return nil, err - } - - resolved := resource.CloneFields(fields) - if err := resource.ResolveAllFields(ctx, resolved); err != nil { - return nil, err - } - - return resolvedResource{Resource: res, fields: resolved}, nil -} - -func unwrapResource(r resource.Resource) resource.Resource { - if rr, ok := r.(resolvedResource); ok { - return rr.Resource - } - return r -} - -func unwrapResources(resources []resource.Resource) []resource.Resource { - result := make([]resource.Resource, len(resources)) - for i, r := range resources { - result[i] = unwrapResource(r) - } - return result -} diff --git a/internal/types/image.go b/internal/types/image.go index 79763ad7..dc105a46 100644 --- a/internal/types/image.go +++ b/internal/types/image.go @@ -9,9 +9,9 @@ import ( "unikraft.com/x/image-spec/reference" "unikraft.com/cli/internal/images" + "unikraft.com/cli/internal/model" + "unikraft.com/cli/internal/model/value" "unikraft.com/cli/internal/multimetro" - "unikraft.com/cli/internal/resource" - "unikraft.com/cli/internal/resource/value" ) // ImageRef is a resource field holding an image reference, which is either an @@ -67,7 +67,7 @@ func (ir ImageRef) String() string { return ir.ref.String() } -func (ir ImageRef) Link() (string, resource.Key, bool) { +func (ir ImageRef) Link() (string, model.Key, bool) { if ir.ref.IsZero() { return "", nil, false } diff --git a/internal/types/quota.go b/internal/types/quota.go index 77478785..572da72e 100644 --- a/internal/types/quota.go +++ b/internal/types/quota.go @@ -12,7 +12,7 @@ import ( "strconv" "strings" - "unikraft.com/cli/internal/resource/value" + "unikraft.com/cli/internal/model/value" "unikraft.com/cli/internal/tui/meter" ) diff --git a/pkg/shellbuiltins/shellbuiltins.go b/pkg/shellbuiltins/shellbuiltins.go index 414c405a..96a36b15 100644 --- a/pkg/shellbuiltins/shellbuiltins.go +++ b/pkg/shellbuiltins/shellbuiltins.go @@ -23,7 +23,7 @@ import ( xstdio "unikraft.com/x/stdio" "unikraft.com/cli/internal/config" - "unikraft.com/cli/internal/resource/cmd" + "unikraft.com/cli/internal/model/cmd" "unikraft.com/cli/pkg/types" ) diff --git a/pkg/shellbuiltins/shellbuiltins_test.go b/pkg/shellbuiltins/shellbuiltins_test.go index 3bba2f54..1acf71cb 100644 --- a/pkg/shellbuiltins/shellbuiltins_test.go +++ b/pkg/shellbuiltins/shellbuiltins_test.go @@ -21,7 +21,7 @@ import ( "unikraft.com/x/stdio" "unikraft.com/cli/internal/config" - "unikraft.com/cli/internal/resource/cmd" + "unikraft.com/cli/internal/model/cmd" wkong "unikraft.com/cli/internal/w/kong" "unikraft.com/cli/pkg/types" ) diff --git a/tools/gendocs/man.go b/tools/gendocs/man.go index b841247e..4f9613bc 100644 --- a/tools/gendocs/man.go +++ b/tools/gendocs/man.go @@ -24,8 +24,8 @@ import ( "github.com/charmbracelet/colorprofile" "github.com/charmbracelet/x/ansi" "github.com/cpuguy83/go-md2man/v2/md2man" - "unikraft.com/cli/internal/resource" - "unikraft.com/cli/internal/resource/cmd" + "unikraft.com/cli/internal/model" + "unikraft.com/cli/internal/model/cmd" "unikraft.com/x/kingkong" "unikraft.com/x/log" ) @@ -276,7 +276,7 @@ func manPrintOptions(buf *bytes.Buffer, node *kong.Node) { } func manPrintFields(buf *bytes.Buffer, node *kong.Node) error { - target, ok := node.Target.Interface().(cmd.ResourceCmdInterface) + target, ok := node.Target.Interface().(cmd.ModelCmdInterface) if !ok { return nil } @@ -286,12 +286,12 @@ func manPrintFields(buf *bytes.Buffer, node *kong.Node) error { if err != nil { return fmt.Errorf("could not get fields for %s: %w", NodePath(node), err) } - fields, _ = resource.FilterFieldsByPath(fields, nil, false) - fields = resource.FilterFields(fields, func(field resource.Field) resource.FilterResult { - if field.Verbosity < resource.FieldVerbosityHidden { - return resource.FilterExclude + fields, _ = model.FilterFieldsByPath(fields, nil, false) + fields = model.FilterFields(fields, func(field model.Field) model.FilterResult { + if field.Verbosity < model.FieldVerbosityHidden { + return model.FilterExclude } - return resource.FilterRecurse + return model.FilterRecurse }) if len(fields) == 0 { return nil @@ -314,7 +314,7 @@ func manPrintField(buf *bytes.Buffer, path string, description string) { fmt.Fprintf(buf, format, description) } -func manFieldGroupDescription(field *resource.Field) string { +func manFieldGroupDescription(field *model.Field) string { lines := []string{} if detail := manFieldDetail(field); detail != "" { lines = append(lines, detail) @@ -326,13 +326,13 @@ func manFieldGroupDescription(field *resource.Field) string { return strings.Join(lines, "\n\t\t") } -func manFieldDescendants(field *resource.Field) []string { +func manFieldDescendants(field *model.Field) []string { if len(field.Subfields) == 0 { return nil } descendants := []string{} - for path, subfield := range resource.IterFields(field.Subfields) { + for path, subfield := range model.IterFields(field.Subfields) { fullPath := field.Name + "." + path.String() line := manFieldLine(fullPath, subfield) if line == "" { @@ -343,7 +343,7 @@ func manFieldDescendants(field *resource.Field) []string { return descendants } -func manFieldLine(path string, field *resource.Field) string { +func manFieldLine(path string, field *model.Field) string { detail := manFieldDetail(field) if detail == "" { if field.HasChildren() { @@ -354,7 +354,7 @@ func manFieldLine(path string, field *resource.Field) string { return fmt.Sprintf("%s: %s", path, detail) } -func manFieldDetail(field *resource.Field) string { +func manFieldDetail(field *model.Field) string { var parts []string if field.Value != nil { diff --git a/tools/gendocs/mdx.go b/tools/gendocs/mdx.go index d6d87f26..58e6d868 100644 --- a/tools/gendocs/mdx.go +++ b/tools/gendocs/mdx.go @@ -21,8 +21,8 @@ import ( "github.com/charmbracelet/x/ansi" "sigs.k8s.io/yaml" - "unikraft.com/cli/internal/resource" - "unikraft.com/cli/internal/resource/cmd" + "unikraft.com/cli/internal/model" + "unikraft.com/cli/internal/model/cmd" "unikraft.com/cli/internal/tablewriter" "unikraft.com/x/kingkong" "unikraft.com/x/log" @@ -101,13 +101,13 @@ func generateMarkdown(ctx context.Context, node *kong.Node, dir string) error { printDocsOptions(buf, node) - if target, ok := node.Target.Interface().(cmd.ResourceCmdInterface); ok { + if target, ok := node.Target.Interface().(cmd.ModelCmdInterface); ok { src := target.Underlying() fields, err := src.Fields(ctx) if err != nil { return fmt.Errorf("could not get fields for %s: %w", name, err) } - fields, _ = resource.FilterFieldsByPath(fields, nil, true) + fields, _ = model.FilterFieldsByPath(fields, nil, true) printDocsFields(buf, fields) } @@ -197,7 +197,7 @@ func printDocsOptions(buf *bytes.Buffer, node *kong.Node) { } } -func printDocsFields(buf *bytes.Buffer, fields []resource.Field) { +func printDocsFields(buf *bytes.Buffer, fields []model.Field) { w := tablewriter.TableWriter(buf) defer w.Flush() @@ -205,7 +205,7 @@ func printDocsFields(buf *bytes.Buffer, fields []resource.Field) { fmt.Fprintln(w) fmt.Fprintln(w, "| Field | Type | Operations |") fmt.Fprintln(w, "|-------|------|------------|") - for path, field := range resource.IterFields(fields) { + for path, field := range model.IterFields(fields) { var typeStr string if field.Value != nil { typeStr = formatType(reflect.TypeOf(field.Value)) From 28a2eb2df26bb445605614ba7200851a4f27dbdc Mon Sep 17 00:00:00 2001 From: Dragos Gheorghioiu Date: Wed, 4 Nov 2026 15:40:31 +0200 Subject: [PATCH 17/35] test(cmd): Move the build tests to x/build The tests check only x/build, which now runs them in its own CI. The integration helpers that only they used go with them. Signed-off-by: Dragos Gheorghioiu --- .github/copilot-instructions.md | 6 +- Taskfile.yml | 2 +- go.mod | 2 +- internal/cmd/build_integration_test.go | 1031 ------------------------ internal/integration/image.go | 76 -- internal/integration/skip.go | 13 +- 6 files changed, 5 insertions(+), 1125 deletions(-) delete mode 100644 internal/cmd/build_integration_test.go diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 758a032c..e0aced1c 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -8,11 +8,9 @@ When reviewing a change, first determine whether it adds or changes user-facing - New, removed, or changed positional arguments, flags, options, aliases, defaults, or validation. - Changes to command behavior, output, exit status, authentication, configuration, resource workflows, or other documented CLI interactions. -For every user-facing change, check whether the change has appropriate integration-test coverage. Repository integration tests live in: -- `cmd/unikraft/integration/` — end-to-end CLI tests, organised as one file per command area, with shared fixtures in `helpers_test.go`. -- `internal/cmd/build_integration_test.go` — integration-level builder tests that exercise real builds through `unikraft.com/x/build` rather than the CLI surface. +For every user-facing change, check whether the change has appropriate integration-test coverage. Repository integration tests live in `cmd/unikraft/integration/`: end-to-end CLI tests, organised as one file per command area, with shared fixtures in `helpers_test.go`. The builder tests that exercise real builds through `unikraft.com/x/build` live in that module. -Both use the shared runner/helpers in the `internal/integration/` package; that package holds the harness itself, not test cases. All of them run through `task integration`, which targets `./cmd/unikraft/integration` and `./internal/cmd`. +They use the shared runner/helpers in the `internal/integration/` package; that package holds the harness itself, not test cases. They run through `task integration`, which targets `./cmd/unikraft/integration`. If integration tests are missing or do not cover the changed user-facing behavior, leave one **medium-level** review comment. The comment must: diff --git a/Taskfile.yml b/Taskfile.yml index 2617c50c..fca64438 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -131,7 +131,7 @@ tasks: - task: go:test vars: go_tags: "integration" - package: ./cmd/unikraft/integration ./internal/cmd + package: ./cmd/unikraft/integration CLI_ARGS: '{{if .TEST}}-run "{{.TEST}}"{{end}}' golden: diff --git a/go.mod b/go.mod index a1270f36..b0c97708 100644 --- a/go.mod +++ b/go.mod @@ -41,7 +41,6 @@ require ( 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 golang.org/x/sync v0.23.0 golang.org/x/sys v0.49.0 @@ -176,6 +175,7 @@ require ( 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 + github.com/unikraft/go-cpio v0.0.0-20260415131742-4f1984ca41f3 // indirect github.com/xo/terminfo v1.0.0 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect diff --git a/internal/cmd/build_integration_test.go b/internal/cmd/build_integration_test.go deleted file mode 100644 index 78b8cba2..00000000 --- a/internal/cmd/build_integration_test.go +++ /dev/null @@ -1,1031 +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" - "errors" - "io" - "io/fs" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/containerd/platforms" - "github.com/moby/buildkit/client" - ocispec "github.com/opencontainers/image-spec/specs-go/v1" - "github.com/stretchr/testify/require" - archivefs "github.com/unikraft/go-archivefs" - goerofs "github.com/unikraft/go-archivefs/erofs" - "github.com/unikraft/go-cpio" - "unikraft.com/x/build" - "unikraft.com/x/build/buildflags" - "unikraft.com/x/build/progress" - "unikraft.com/x/buildkit" - imagespec "unikraft.com/x/image-spec" - "unikraft.com/x/kraftfile" - "unikraft.com/x/log" - - "unikraft.com/cli/internal/config" - "unikraft.com/cli/internal/integration" -) - -func TestBuildSinglePlatformIntegration(t *testing.T) { - ctx := buildIntegrationContext(t) - dockerfile := ` -FROM scratch - -COPY <<'EOF' /hello.txt -hello -EOF -` - rootfsPath := writeBuildDockerfile(t, dockerfile) - opts := build.BuildOpts{ - Runtime: "unikraft.io/official/base-compat", - Rootfs: build.FSOpts{ - Format: kraftfile.FsTypeCpio, - Type: kraftfile.SourceTypeDockerfile, - Path: rootfsPath, - }, - Platform: []ocispec.Platform{{OS: "kraftcloud", Architecture: "x86_64"}}, - } - - imgs := runIntegrationBuild(t, ctx, opts) - require.Len(t, imgs, 1) - assertBuildPlatforms(t, imgs, []string{"kraftcloud/x86_64"}) -} - -func TestBuildSinglePlatformErofsIntegration(t *testing.T) { - ctx := buildIntegrationContext(t) - dockerfile := ` -FROM scratch - -COPY <<'EOF' /hello.txt -hello -EOF -` - rootfsPath := writeBuildDockerfile(t, dockerfile) - opts := build.BuildOpts{ - Runtime: "unikraft.io/official/base-compat", - Rootfs: build.FSOpts{ - Format: kraftfile.FsTypeErofs, - Type: kraftfile.SourceTypeDockerfile, - Path: rootfsPath, - }, - Platform: []ocispec.Platform{{OS: "kraftcloud", Architecture: "x86_64"}}, - } - - imgs := runIntegrationBuild(t, ctx, opts) - require.Len(t, imgs, 1) - assertBuildPlatforms(t, imgs, []string{"kraftcloud/x86_64"}) -} - -func TestBuildDefaultErofsFormatBaseCompatIntegration(t *testing.T) { - ctx := buildIntegrationContext(t) - dockerfile := ` -FROM scratch - -COPY <<'EOF' /hello.txt -hello -EOF -` - rootfsPath := writeBuildDockerfile(t, dockerfile) - opts := build.BuildOpts{ - Runtime: "unikraft.io/official/base-compat", - Rootfs: build.FSOpts{ - Type: kraftfile.SourceTypeDockerfile, - Path: rootfsPath, - }, - Platform: []ocispec.Platform{{OS: "kraftcloud", Architecture: "x86_64"}}, - } - - imgs := runIntegrationBuild(t, ctx, opts) - require.Len(t, imgs, 1) - assertBuildPlatforms(t, imgs, []string{"kraftcloud/x86_64"}) - - files := readErofsInitrd(t, imgs[0]) - require.Contains(t, files, "hello.txt") - require.Equal(t, "hello\n", files["hello.txt"]) -} - -func TestBuildAbsoluteSymlinksIntegration(t *testing.T) { - ctx := buildIntegrationContext(t) - // Reproducer for https://github.com/moby/buildkit/issues/6684 - // This test ensures that absolute symlinks are correctly preserved in the rootfs - // and not incorrectly rewritten with platform-specific prefixes. - // The debian:bookworm-slim image contains absolute symlinks like /etc/alternatives/awk -> /usr/bin/mawk - dockerfile := ` -FROM debian:bookworm-slim - -# Create test symlinks to verify correct behavior -RUN ln -s /usr/bin/bash /test-symlink -RUN ln -s /etc/passwd /another-test-link -` - rootfsPath := writeBuildDockerfile(t, dockerfile) - opts := build.BuildOpts{ - Runtime: "unikraft.io/official/base-compat", - Rootfs: build.FSOpts{ - Format: kraftfile.FsTypeCpio, - Type: kraftfile.SourceTypeDockerfile, - Path: rootfsPath, - }, - Platform: []ocispec.Platform{{OS: "kraftcloud", Architecture: "x86_64"}}, - } - - imgs := runIntegrationBuild(t, ctx, opts) - require.Len(t, imgs, 1) - assertBuildPlatforms(t, imgs, []string{"kraftcloud/x86_64"}) - - // Inspect the CPIO archive to verify symlink targets - initrd := imgs[0].Initrd - require.NotNil(t, initrd) - - f, _, err := initrd.Open(ctx) - require.NoError(t, err) - defer f.Close() - - cr := cpio.NewReader(f) - - // Track found symlinks and their targets - foundSymlinks := make(map[string]string) - - for { - hdr, err := cr.Next() - if errors.Is(err, io.EOF) { - break - } - require.NoError(t, err) - - if hdr.Mode&cpio.TypeSymlink != 0 { - foundSymlinks[hdr.Name] = hdr.Linkname - } - } - - // Verify our test symlinks are correct - require.Contains(t, foundSymlinks, "./test-symlink") - require.Equal(t, "/usr/bin/bash", foundSymlinks["./test-symlink"], - "test-symlink should point to /usr/bin/bash, not a rewritten path") - - require.Contains(t, foundSymlinks, "./another-test-link") - require.Equal(t, "/etc/passwd", foundSymlinks["./another-test-link"], - "another-test-link should point to /etc/passwd, not a rewritten path") - - // Verify a symlink from the debian image itself - require.Contains(t, foundSymlinks, "./etc/alternatives/awk") - require.Equal(t, "/usr/bin/mawk", foundSymlinks["./etc/alternatives/awk"], - "Debian's awk symlink should point to /usr/bin/mawk, not a rewritten path") - - // Ensure no symlinks have been incorrectly rewritten with platform prefixes - for name, target := range foundSymlinks { - require.NotContains(t, target, "fc_x86_64", - "symlink %s should not contain platform prefix in target: %s", name, target) - require.NotContains(t, target, "/fc_x86_64", - "symlink %s should not contain platform prefix in target: %s", name, target) - } -} - -func TestBuildMultiPlatformIntegration(t *testing.T) { - ctx := buildIntegrationContext(t) - dockerfile := ` -FROM scratch - -COPY <<'EOF' /hello.txt -hello -EOF -` - rootfsPath := writeBuildDockerfile(t, dockerfile) - opts := build.BuildOpts{ - Runtime: "unikraft.io/unikraft.org/base", - Rootfs: build.FSOpts{ - Format: kraftfile.FsTypeCpio, - Type: kraftfile.SourceTypeDockerfile, - Path: rootfsPath, - }, - Platform: []ocispec.Platform{ - {OS: "fc", Architecture: "x86_64"}, - {OS: "qemu", Architecture: "x86_64"}, - }, - } - - imgs := runIntegrationBuild(t, ctx, opts) - require.Len(t, imgs, 2) - assertBuildPlatforms(t, imgs, []string{"fc/x86_64", "qemu/x86_64"}) -} - -func TestBuildSecretsIntegration(t *testing.T) { - ctx := buildIntegrationContext(t) - dockerfile := ` -FROM busybox:latest -RUN --mount=type=secret,id=api_key cat /run/secrets/api_key | grep -q s3cr3t -` - rootfsPath := writeBuildDockerfile(t, dockerfile) - secretPath := writeBuildSecret(t, "s3cr3t\n") - secrets, err := buildflags.ParseSecretSpecs([]string{"id=api_key,src=" + secretPath}) - require.NoError(t, err) - - opts := build.BuildOpts{ - Runtime: "unikraft.io/official/base-compat", - Rootfs: build.FSOpts{ - Format: kraftfile.FsTypeCpio, - Type: kraftfile.SourceTypeDockerfile, - Path: rootfsPath, - }, - Secrets: secrets, - Platform: []ocispec.Platform{{OS: "kraftcloud", Architecture: "x86_64"}}, - } - imgs := runIntegrationBuild(t, ctx, opts) - require.Len(t, imgs, 1) - assertBuildPlatforms(t, imgs, []string{"kraftcloud/x86_64"}) -} - -func TestBuildCmdEnvLabelsIntegration(t *testing.T) { - ctx := buildIntegrationContext(t) - dockerfile := ` -FROM scratch -LABEL com.example.source=dockerfile com.example.only=dockerfile -ENV DOCKERFILE_ENV=from-dockerfile -CMD ["/dockerfile-cmd"] -` - rootfsPath := writeBuildDockerfile(t, dockerfile) - opts := build.BuildOpts{ - Runtime: "unikraft.io/official/base-compat", - Rootfs: build.FSOpts{ - Format: kraftfile.FsTypeCpio, - Type: kraftfile.SourceTypeDockerfile, - Path: rootfsPath, - }, - Platform: []ocispec.Platform{{OS: "kraftcloud", Architecture: "x86_64"}}, - Cmd: []string{"/unikraft-cmd", "--from-opts"}, - Env: kraftfile.Map{ - {Key: "OPTS_ENV", Value: "from-opts"}, - {Key: "OPTS_FLAG", Value: "1"}, - }, - Labels: map[string]string{ - "com.example.source": "opts", - "com.example.extra": "true", - }, - } - - imgs := runIntegrationBuild(t, ctx, opts) - require.Len(t, imgs, 1) - require.NotNil(t, imgs[0]) - require.NotNil(t, imgs[0].Image) - require.Equal(t, opts.Cmd, imgs[0].Image.Config.Cmd) - require.Equal(t, opts.Labels, imgs[0].Image.Config.Labels) - require.Contains(t, imgs[0].Image.Config.Env, "OPTS_ENV=from-opts") - require.Contains(t, imgs[0].Image.Config.Env, "OPTS_FLAG=1") - require.Contains(t, imgs[0].Image.Config.Env, "DOCKERFILE_ENV=from-dockerfile") -} - -func TestBuildDockerfileLabelsIntegration(t *testing.T) { - ctx := buildIntegrationContext(t) - dockerfile := ` -FROM scratch -LABEL com.example.source=dockerfile com.example.only=dockerfile -` - imgs := runIntegrationBuild(t, ctx, build.BuildOpts{ - Runtime: "unikraft.io/official/base-compat", - Rootfs: build.FSOpts{ - Format: kraftfile.FsTypeCpio, - Type: kraftfile.SourceTypeDockerfile, - Path: writeBuildDockerfile(t, dockerfile), - }, - Platform: []ocispec.Platform{{OS: "kraftcloud", Architecture: "x86_64"}}, - }) - require.Len(t, imgs, 1) - require.NotNil(t, imgs[0]) - require.NotNil(t, imgs[0].Image) - require.Equal(t, "dockerfile", imgs[0].Image.Config.Labels["com.example.source"]) - require.Equal(t, "dockerfile", imgs[0].Image.Config.Labels["com.example.only"]) -} - -func buildIntegrationContext(t *testing.T) context.Context { - t.Helper() - integration.SkipUnlessIntegration(t) - integration.RequireBuildkit(t) - t.Setenv("BUILDKIT_PROGRESS", "quiet") - - ctx := t.Context() - logger, err := log.New(ctx, log.Config{Sink: t.Output(), Type: log.TextType, Level: log.InfoLevel}) - require.NoError(t, err) - ctx = log.WithLogger(ctx, logger) - cfg, err := integration.LoadRemote(t) - if errors.Is(err, os.ErrNotExist) { - t.Skip("integration config not found") - } - require.NoError(t, err) - if cfg == nil || cfg.Config == nil { - t.Skip("integration config unavailable") - } - ctx = config.WithConfig(ctx, cfg.Config) - return ctx -} - -func writeBuildDockerfile(t *testing.T, dockerfile string) string { - t.Helper() - dir := t.TempDir() - dockerfilePath := filepath.Join(dir, "Dockerfile") - require.NoError(t, os.WriteFile(dockerfilePath, []byte(dockerfile), 0o644)) - return dockerfilePath -} - -func writeBuildSecret(t *testing.T, contents string) string { - t.Helper() - path := filepath.Join(t.TempDir(), "secret.txt") - require.NoError(t, os.WriteFile(path, []byte(contents), 0o600)) - return path -} - -func runIntegrationBuild(t *testing.T, ctx context.Context, opts build.BuildOpts) []*imagespec.Image { - t.Helper() - imgs, err := integrationBuilder(t, ctx).Build(ctx, opts) - require.NoError(t, err) - t.Cleanup(func() { - for _, img := range imgs { - _ = img.Close() - } - }) - - return imgs -} - -// runIntegrationRootfs calls BuildRootfs with the builder of the CLI. -func runIntegrationRootfs(t *testing.T, ctx context.Context, opts build.BuildOpts) []*imagespec.Image { - t.Helper() - imgs, err := integrationBuilder(t, ctx).BuildRootfs(ctx, opts) - require.NoError(t, err) - t.Cleanup(func() { - for _, img := range imgs { - _ = img.Close() - } - }) - return imgs -} - -// integrationBuilder gives the builder of the CLI on one BuildKit connection, -// and skips the test when no daemon answers. -func integrationBuilder(t *testing.T, ctx context.Context) build.Builder { - t.Helper() - d, err := localBuildkit.Acquire(ctx, buildkit.Key{}) - if err != nil { - t.Skipf("buildkit unavailable: %v", err) - } - t.Cleanup(func() { _ = d.Release() }) - - b := newBuilder(progress.Discard) - b.Buildkit = func(context.Context) (*client.Client, func(), error) { - return d.Client, nil, nil - } - return b -} - -func assertBuildPlatforms(t *testing.T, imgs []*imagespec.Image, expected []string) { - t.Helper() - platformsFound := make([]string, 0, len(imgs)) - for _, img := range imgs { - require.NotNil(t, img) - require.NotNil(t, img.Image) - platformsFound = append(platformsFound, platforms.Format(img.Image.Platform)) - } - require.ElementsMatch(t, expected, platformsFound) -} - -func TestBuildCharDeviceErofsIntegration(t *testing.T) { - ctx := buildIntegrationContext(t) - dockerfile := ` -FROM busybox:latest -RUN mknod /testfile c 220 220 -` - rootfsPath := writeBuildDockerfile(t, dockerfile) - opts := build.BuildOpts{ - Runtime: "unikraft.io/official/base-compat", - Rootfs: build.FSOpts{ - Format: kraftfile.FsTypeErofs, - Type: kraftfile.SourceTypeDockerfile, - Path: rootfsPath, - }, - Platform: []ocispec.Platform{{OS: "kraftcloud", Architecture: "x86_64"}}, - } - - imgs := runIntegrationBuild(t, ctx, opts) - require.Len(t, imgs, 1) - assertBuildPlatforms(t, imgs, []string{"kraftcloud/x86_64"}) - - erofsImg := openErofsInitrdImage(t, imgs[0]) - inodes := walkErofsInodes(t, erofsImg) - - require.Contains(t, inodes, "testfile") - ino := inodes["testfile"] - require.True(t, ino.IsCharDev(), "testfile should be a character device node") - require.Equal(t, uint32(220), ino.GetDevMajor(), "char device major number should be preserved") - require.Equal(t, uint32(220), ino.GetDevMinor(), "char device minor number should be preserved") -} - -func TestBuildHiddenFileErofsIntegration(t *testing.T) { - ctx := buildIntegrationContext(t) - dockerfile := ` -FROM scratch -COPY <<'EOF' /.hidden -hidden content -EOF -` - rootfsPath := writeBuildDockerfile(t, dockerfile) - opts := build.BuildOpts{ - Runtime: "unikraft.io/official/base-compat", - Rootfs: build.FSOpts{ - Format: kraftfile.FsTypeErofs, - Type: kraftfile.SourceTypeDockerfile, - Path: rootfsPath, - }, - Platform: []ocispec.Platform{{OS: "kraftcloud", Architecture: "x86_64"}}, - } - - imgs := runIntegrationBuild(t, ctx, opts) - require.Len(t, imgs, 1) - assertBuildPlatforms(t, imgs, []string{"kraftcloud/x86_64"}) - - files := readErofsInitrd(t, imgs[0]) - require.Contains(t, files, ".hidden", "hidden file must be preserved with its leading dot") - require.Equal(t, "hidden content\n", files[".hidden"]) -} - -func TestBuildCustomDockerfileNameViaPathIntegration(t *testing.T) { - ctx := buildIntegrationContext(t) - dockerfile := ` -FROM scratch - -COPY <<'EOF' /hello.txt -hello -EOF -` - dir := t.TempDir() - dockerfilePath := filepath.Join(dir, "MyDockerfile") - require.NoError(t, os.WriteFile(dockerfilePath, []byte(dockerfile), 0o644)) - - opts := build.BuildOpts{ - Runtime: "unikraft.io/official/base-compat", - Rootfs: build.FSOpts{ - Format: kraftfile.FsTypeErofs, - Type: kraftfile.SourceTypeDockerfile, - Path: dockerfilePath, - }, - Platform: []ocispec.Platform{{OS: "kraftcloud", Architecture: "x86_64"}}, - } - - imgs := runIntegrationBuild(t, ctx, opts) - require.Len(t, imgs, 1) - assertBuildPlatforms(t, imgs, []string{"kraftcloud/x86_64"}) - - files := readErofsInitrd(t, imgs[0]) - require.Contains(t, files, "hello.txt") - require.Equal(t, "hello\n", files["hello.txt"]) -} - -// openErofsInitrdImage opens the initrd from img as an erofs.Image. -func openErofsInitrdImage(t *testing.T, img *imagespec.Image) *goerofs.Image { - t.Helper() - require.NotNil(t, img.Initrd) - - ctx := t.Context() - rc, _, err := img.Initrd.Open(ctx) - require.NoError(t, err) - defer rc.Close() - - tmp, err := os.CreateTemp(t.TempDir(), "erofs-*.img") - require.NoError(t, err) - t.Cleanup(func() { tmp.Close() }) - - _, err = io.Copy(tmp, rc) - require.NoError(t, err) - - erofsImg, err := goerofs.OpenImage(tmp) - require.NoError(t, err) - - return erofsImg -} - -// walkErofsInodes walks inodes in erofsImg and returns a path -> Inode map. -func walkErofsInodes(t *testing.T, erofsImg *goerofs.Image) map[string]goerofs.Inode { - t.Helper() - result := make(map[string]goerofs.Inode) - - var walk func(prefix string, nid uint64) - walk = func(prefix string, nid uint64) { - ino, err := erofsImg.Inode(nid) - require.NoError(t, err) - - err = ino.IterDirents(func(name string, _ uint8, childNid uint64) error { - if name == "." || name == ".." { - return nil - } - - path := name - if prefix != "" { - path = prefix + "/" + name - } - - childIno, err := erofsImg.Inode(childNid) - if err != nil { - return err - } - result[path] = childIno - if childIno.IsDir() { - walk(path, childNid) - } - return nil - }) - require.NoError(t, err) - } - - walk("", erofsImg.RootNid()) - return result -} - -func TestRootfsDockerfileCpioIntegration(t *testing.T) { - ctx := buildIntegrationContext(t) - dockerfile := ` -FROM scratch - -COPY <<'EOF' /hello.txt -hello -EOF -` - rootfsPath := writeBuildDockerfile(t, dockerfile) - imgs := runIntegrationRootfs(t, ctx, build.BuildOpts{ - Rootfs: build.FSOpts{ - Format: kraftfile.FsTypeCpio, - Path: rootfsPath, - Type: kraftfile.SourceTypeDockerfile, - }, - Platform: []ocispec.Platform{{OS: "fc", Architecture: "x86_64"}}, - }) - require.Len(t, imgs, 1) - - files := readCpioInitrd(t, imgs[0]) - require.Contains(t, files, "./hello.txt") - require.Equal(t, "hello\n", files["./hello.txt"]) -} - -func TestRootfsDockerfileLabelsIntegration(t *testing.T) { - dockerfile := ` -FROM scratch -LABEL com.example.source=dockerfile com.example.only=dockerfile -` - - t.Run("preserved when no labels are given", func(t *testing.T) { - ctx := buildIntegrationContext(t) - imgs := runIntegrationRootfs(t, ctx, build.BuildOpts{ - Rootfs: build.FSOpts{ - Format: kraftfile.FsTypeCpio, - Path: writeBuildDockerfile(t, dockerfile), - Type: kraftfile.SourceTypeDockerfile, - }, - Platform: []ocispec.Platform{{OS: "fc", Architecture: "x86_64"}}, - }) - require.Len(t, imgs, 1) - require.Equal(t, "dockerfile", imgs[0].Image.Config.Labels["com.example.source"]) - require.Equal(t, "dockerfile", imgs[0].Image.Config.Labels["com.example.only"]) - }) - - t.Run("replaced by the given labels", func(t *testing.T) { - ctx := buildIntegrationContext(t) - imgs := runIntegrationRootfs(t, ctx, build.BuildOpts{ - Rootfs: build.FSOpts{ - Format: kraftfile.FsTypeCpio, - Path: writeBuildDockerfile(t, dockerfile), - Type: kraftfile.SourceTypeDockerfile, - }, - Platform: []ocispec.Platform{{OS: "fc", Architecture: "x86_64"}}, - Labels: map[string]string{"com.example.source": "opts"}, - }) - require.Len(t, imgs, 1) - require.Equal(t, map[string]string{"com.example.source": "opts"}, imgs[0].Image.Config.Labels) - }) -} - -func TestRootfsDockerfileErofsIntegration(t *testing.T) { - ctx := buildIntegrationContext(t) - dockerfile := ` -FROM scratch - -COPY <<'EOF' /hello.txt -hello -EOF -` - rootfsPath := writeBuildDockerfile(t, dockerfile) - imgs := runIntegrationRootfs(t, ctx, build.BuildOpts{ - Rootfs: build.FSOpts{ - Format: kraftfile.FsTypeErofs, - Path: rootfsPath, - Type: kraftfile.SourceTypeDockerfile, - }, - Platform: []ocispec.Platform{{OS: "fc", Architecture: "x86_64"}}, - }) - require.Len(t, imgs, 1) - - files := readErofsInitrd(t, imgs[0]) - require.Contains(t, files, "hello.txt") - require.Equal(t, "hello\n", files["hello.txt"]) -} - -func TestRootfsDockerfileCustomNameIntegration(t *testing.T) { - ctx := buildIntegrationContext(t) - dockerfile := ` -FROM scratch - -COPY <<'EOF' /hello.txt -hello -EOF -` - contextDir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(contextDir, "MyDockerfile"), []byte(dockerfile), 0o644)) - - imgs := runIntegrationRootfs(t, ctx, build.BuildOpts{ - Rootfs: build.FSOpts{ - Format: kraftfile.FsTypeCpio, - Path: contextDir, - Dockerfile: "MyDockerfile", - Type: kraftfile.SourceTypeDockerfile, - }, - Platform: []ocispec.Platform{{OS: "fc", Architecture: "x86_64"}}, - }) - require.Len(t, imgs, 1) - - files := readCpioInitrd(t, imgs[0]) - require.Contains(t, files, "./hello.txt") - require.Equal(t, "hello\n", files["./hello.txt"]) -} - -func TestRootfsDockerfileWithDeviceNodeIntegration(t *testing.T) { - ctx := buildIntegrationContext(t) - // Build an image that contains a device node. The local exporter - // used during solve must not fail with "failed to create device" - // when unpacking the result. See TOOL-791. - dockerfile := ` -FROM busybox:latest -RUN mknod /testdevice c 1 3 - -COPY <<'EOF' /hello.txt -hello -EOF -` - rootfsPath := writeBuildDockerfile(t, dockerfile) - - for _, format := range []kraftfile.FsType{kraftfile.FsTypeCpio, kraftfile.FsTypeErofs} { - t.Run(string(format), func(t *testing.T) { - imgs := runIntegrationRootfs(t, ctx, build.BuildOpts{ - Rootfs: build.FSOpts{ - Format: format, - Path: rootfsPath, - Type: kraftfile.SourceTypeDockerfile, - }, - Platform: []ocispec.Platform{{OS: "fc", Architecture: "x86_64"}}, - }) - require.Len(t, imgs, 1) - - switch format { - case kraftfile.FsTypeCpio: - files := readCpioInitrd(t, imgs[0]) - require.Contains(t, files, "./hello.txt") - require.Equal(t, "hello\n", files["./hello.txt"]) - case kraftfile.FsTypeErofs: - files := readErofsInitrd(t, imgs[0]) - require.Contains(t, files, "hello.txt") - require.Equal(t, "hello\n", files["hello.txt"]) - } - }) - } -} - -func TestRootfsDockerfileKeepsOwnersIntegration(t *testing.T) { - ctx := buildIntegrationContext(t) - dockerfile := ` -FROM scratch - -COPY --chown=1000:1000 <<'EOF' /owned.txt -owned -EOF -` - rootfsPath := writeBuildDockerfile(t, dockerfile) - imgs := runIntegrationRootfs(t, ctx, build.BuildOpts{ - Rootfs: build.FSOpts{ - Format: kraftfile.FsTypeErofs, - Path: rootfsPath, - Type: kraftfile.SourceTypeDockerfile, - }, - Platform: []ocispec.Platform{{OS: "fc", Architecture: "x86_64"}}, - }) - require.Len(t, imgs, 1) - - owners := readOwners(t, imgs[0].Initrd, kraftfile.FsTypeErofs) - require.Equal(t, owner{1000, 1000}, owners["owned.txt"]) -} - -func TestRootfsMultiPlatform(t *testing.T) { - ctx := buildIntegrationContext(t) - // $BUILDPLATFORM avoids emulation; TARGETARCH still varies per platform. - dockerfile := ` -FROM --platform=$BUILDPLATFORM busybox AS base -ARG TARGETARCH -RUN echo "$TARGETARCH" > /arch.txt - -FROM scratch -COPY --from=base /arch.txt /arch.txt -` - rootfsPath := writeBuildDockerfile(t, dockerfile) - imgs := runIntegrationRootfs(t, ctx, build.BuildOpts{ - Rootfs: build.FSOpts{ - Format: kraftfile.FsTypeCpio, - Path: rootfsPath, - Type: kraftfile.SourceTypeDockerfile, - }, - Platform: []ocispec.Platform{ - {OS: "fc", Architecture: "x86_64"}, - {OS: "fc", Architecture: "arm64"}, - }, - }) - require.Len(t, imgs, 2) - - // Each platform must produce distinct content, proving they were - // solved and exported independently rather than merged into one export. - content := make(map[string]string) - for _, img := range imgs { - files := readCpioInitrd(t, img) - require.Contains(t, files, "./arch.txt") - content[platforms.Format(img.Image.Platform)] = files["./arch.txt"] - } - require.Equal(t, "amd64\n", content["fc/x86_64"]) - require.Equal(t, "arm64\n", content["fc/arm64"]) -} - -func TestRootfsMultiPlatformEmulation(t *testing.T) { - t.Skip("QEMU emulation is not reliably available across CI runners") - - ctx := buildIntegrationContext(t) - // Unlike above, RUN executes under the target platform here, requiring - // QEMU emulation rather than just varying TARGETARCH. - dockerfile := ` -FROM busybox AS base -RUN uname -m > /arch.txt - -FROM scratch -COPY --from=base /arch.txt /arch.txt -` - rootfsPath := writeBuildDockerfile(t, dockerfile) - imgs := runIntegrationRootfs(t, ctx, build.BuildOpts{ - Rootfs: build.FSOpts{ - Format: kraftfile.FsTypeCpio, - Path: rootfsPath, - Type: kraftfile.SourceTypeDockerfile, - }, - Platform: []ocispec.Platform{ - {OS: "fc", Architecture: "x86_64"}, - {OS: "fc", Architecture: "arm64"}, - }, - }) - require.Len(t, imgs, 2) - - content := make(map[string]string) - for _, img := range imgs { - files := readCpioInitrd(t, img) - require.Contains(t, files, "./arch.txt") - content[platforms.Format(img.Image.Platform)] = files["./arch.txt"] - } - require.Equal(t, "x86_64\n", content["fc/x86_64"]) - require.Equal(t, "aarch64\n", content["fc/arm64"]) -} - -// TestRootfsOCIRegularImageIntegration reads a regular OCI image (plain layers, -// no unikraft components) from a registry and verifies that BuildKit flattens -// the layers and that the result is re-packaged into the requested rootfs -// format. -func TestRootfsOCIRegularImageIntegration(t *testing.T) { - ctx := buildIntegrationContext(t) - ref := integration.HelloWorld.Mirror(t, ctx) - - for _, format := range []kraftfile.FsType{kraftfile.FsTypeCpio, kraftfile.FsTypeErofs} { - t.Run(string(format), func(t *testing.T) { - imgs := runIntegrationRootfs(t, ctx, build.BuildOpts{ - Rootfs: build.FSOpts{ - Path: ref, - Type: kraftfile.SourceTypeOCI, - Format: format, - }, - Platform: []ocispec.Platform{{OS: "fc", Architecture: "x86_64"}}, - }) - require.Len(t, imgs, 1) - - switch format { - case kraftfile.FsTypeCpio: - files := readCpioInitrd(t, imgs[0]) - require.Contains(t, files, "./hello") - case kraftfile.FsTypeErofs: - files := readErofsInitrd(t, imgs[0]) - require.Contains(t, files, "hello") - } - }) - } -} - -// TestRootfsOCIRegularImageNoCacheIntegration guards the options that carry -// --no-cache into a raw LLB solve, which does not see the frontend attribute the -// Dockerfile path uses. -func TestRootfsOCIRegularImageNoCacheIntegration(t *testing.T) { - ctx := buildIntegrationContext(t) - imgs := runIntegrationRootfs(t, ctx, build.BuildOpts{ - Rootfs: build.FSOpts{ - Path: integration.HelloWorld.Mirror(t, ctx), - Type: kraftfile.SourceTypeOCI, - Format: kraftfile.FsTypeCpio, - }, - Platform: []ocispec.Platform{{OS: "fc", Architecture: "x86_64"}}, - NoCache: true, - }) - require.Len(t, imgs, 1) - require.Contains(t, readCpioInitrd(t, imgs[0]), "./hello") -} - -// TestRootfsOCIRegularImagePerArchIntegration covers two platforms that resolve -// to different manifests of one multi-arch image: each must be flattened on its -// own rather than sharing the first one's filesystem. -func TestRootfsOCIRegularImagePerArchIntegration(t *testing.T) { - ctx := buildIntegrationContext(t) - imgs := runIntegrationRootfs(t, ctx, build.BuildOpts{ - Rootfs: build.FSOpts{ - Path: integration.HelloWorld.Mirror(t, ctx), - Type: kraftfile.SourceTypeOCI, - Format: kraftfile.FsTypeCpio, - }, - Platform: []ocispec.Platform{ - {OS: "fc", Architecture: "x86_64"}, - {OS: "fc", Architecture: "arm64"}, - }, - }) - require.Len(t, imgs, 2) - require.NotEqual(t, readCpioInitrd(t, imgs[0]), readCpioInitrd(t, imgs[1]), - "each architecture must get its own flattened filesystem") - assertBuildPlatforms(t, imgs, []string{"fc/x86_64", "fc/arm64"}) -} - -// TestRootfsOCIRegularImageSharedFlattenIntegration covers two unikraft -// platforms that normalise onto the same linux platform: they share one source -// image, so the flatten is done once and reused rather than solved per platform. -func TestRootfsOCIRegularImageSharedFlattenIntegration(t *testing.T) { - ctx := buildIntegrationContext(t) - imgs := runIntegrationRootfs(t, ctx, build.BuildOpts{ - Rootfs: build.FSOpts{ - Path: integration.HelloWorld.Mirror(t, ctx), - Type: kraftfile.SourceTypeOCI, - Format: kraftfile.FsTypeCpio, - }, - Platform: []ocispec.Platform{ - {OS: "fc", Architecture: "x86_64"}, - {OS: "qemu", Architecture: "x86_64"}, - }, - }) - require.Len(t, imgs, 2) - require.Equal(t, readCpioInitrd(t, imgs[0]), readCpioInitrd(t, imgs[1])) - assertBuildPlatforms(t, imgs, []string{"fc/x86_64", "qemu/x86_64"}) -} - -// readCpioInitrd opens the initrd from img and returns a map of file name to -// content for all regular files in the CPIO archive. -func readCpioInitrd(t *testing.T, img *imagespec.Image) map[string]string { - t.Helper() - require.NotNil(t, img.Initrd) - - ctx := t.Context() - f, _, err := img.Initrd.Open(ctx) - require.NoError(t, err) - defer f.Close() - - cr := cpio.NewReader(f) - files := make(map[string]string) - for { - hdr, err := cr.Next() - if errors.Is(err, io.EOF) { - break - } - require.NoError(t, err) - - if hdr.Mode&cpio.TypeRegular != 0 { - data, err := io.ReadAll(cr) - require.NoError(t, err) - files[hdr.Name] = string(data) - } - } - return files -} - -// readErofsInitrd opens the initrd from img and returns a map of file name to -// content for all regular files in the EROFS archive. -func readErofsInitrd(t *testing.T, img *imagespec.Image) map[string]string { - t.Helper() - require.NotNil(t, img.Initrd) - - ctx := t.Context() - rc, _, err := img.Initrd.Open(ctx) - require.NoError(t, err) - defer rc.Close() - - // EROFS needs io.ReaderAt; copy the stream into a temporary file. - tmp, err := os.CreateTemp(t.TempDir(), "erofs-*.img") - require.NoError(t, err) - defer tmp.Close() - - _, err = io.Copy(tmp, rc) - require.NoError(t, err) - - fsys, err := goerofs.Open(tmp) - require.NoError(t, err) - - files := make(map[string]string) - readErofsDir(t, fsys, ".", files) - return files -} - -// readErofsDir recursively reads all regular files from an EROFS filesystem. -func readErofsDir(t *testing.T, fsys *goerofs.Filesystem, dir string, files map[string]string) { - t.Helper() - entries, err := fsys.ReadDir(dir) - require.NoError(t, err) - - for _, entry := range entries { - name := dir + "/" + entry.Name() - if dir == "." { - name = entry.Name() - } - if entry.IsDir() { - readErofsDir(t, fsys, name, files) - continue - } - if !entry.Type().IsRegular() { - continue - } - f, err := fsys.Open(name) - require.NoError(t, err) - data, err := io.ReadAll(f) - f.Close() - require.NoError(t, err) - files[name] = string(data) - } -} - -// owner is the uid and gid of an entry in a built root filesystem. -type owner struct{ uid, gid int } - -// readOwners returns the owner of every entry in the filesystem image f. -func readOwners(t *testing.T, f imagespec.File, format kraftfile.FsType) map[string]owner { - t.Helper() - require.NotNil(t, f) - - rc, _, err := f.Open(t.Context()) - require.NoError(t, err) - defer rc.Close() - - owners := make(map[string]owner) - switch format { - case kraftfile.FsTypeCpio: - cr := cpio.NewReader(rc) - for { - hdr, err := cr.Next() - if errors.Is(err, io.EOF) { - break - } - require.NoError(t, err) - _, err = io.Copy(io.Discard, cr) - require.NoError(t, err) - owners[strings.TrimPrefix(hdr.Name, "./")] = owner{hdr.Uid, hdr.Guid} - } - case kraftfile.FsTypeErofs: - // EROFS needs io.ReaderAt; copy the stream into a temporary file. - tmp, err := os.CreateTemp(t.TempDir(), "erofs-*.img") - require.NoError(t, err) - defer tmp.Close() - _, err = io.Copy(tmp, rc) - require.NoError(t, err) - - fsys, err := goerofs.Open(tmp) - require.NoError(t, err) - require.NoError(t, fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error { - if err != nil || path == "." { - return err - } - info, err := d.Info() - if err != nil { - return err - } - owners[path] = owner{archivefs.GetUID(info.Sys()), archivefs.GetGID(info.Sys())} - return nil - })) - default: - t.Fatalf("unknown format %q", format) - } - return owners -} diff --git a/internal/integration/image.go b/internal/integration/image.go index 45f5a415..dcdaa389 100644 --- a/internal/integration/image.go +++ b/internal/integration/image.go @@ -18,7 +18,6 @@ import ( "github.com/stretchr/testify/require" - "github.com/moby/buildkit/util/contentutil" imagespec "unikraft.com/x/image-spec" "unikraft.com/cli/internal/config" @@ -84,81 +83,6 @@ func (i *Image) Build(t *testing.T, env *TestEnv, ref string, opts ...CmdOption) return nil } -// HelloWorld is a regular OCI image, with plain layers and no unikraft -// components. -var HelloWorld = &MirroredImage{ - Name: "hello-world", - Source: "index.docker.io/library/hello-world:latest", -} - -// MirroredImage is a public image that tests copy into the organization of the -// profile, so that each user runs against their own project. Mirror copies it -// one time, and the cleanup deletes it when the test binary ends. -type MirroredImage struct { - // Name is the image name, without the organization and the tag. - Name string - // Source is the full reference of the image to copy. - Source string - - once sync.Once - ref string - err error -} - -// Mirror copies the image one time and gives the full reference. Later calls -// give the reference of the first copy. -func (m *MirroredImage) Mirror(t *testing.T, ctx context.Context) string { - t.Helper() - m.once.Do(func() { - m.ref, m.err = m.mirror(ctx) - }) - require.NoError(t, m.err) - return m.ref -} - -// mirror copies every manifest and blob of the source image, so that a -// multi-platform image keeps all of its platforms. -func (m *MirroredImage) mirror(ctx context.Context) (string, error) { - cfg := config.FromContextOrDefault(ctx) - profile, err := cfg.CurrentProfile() - if err != nil { - return "", err - } - if profile.Organization == "" { - return "", fmt.Errorf("profile %q has no organization to mirror %s into", profile.Name, m.Name) - } - parsed, err := images.ParseRef(profile.Organization + "/" + m.Name + ":" + sharedImageTag) - if err != nil { - return "", fmt.Errorf("parsing mirror reference of %s: %w", m.Name, err) - } - ref := parsed.String() - - resolver, err := images.Resolver(ctx) - if err != nil { - return "", err - } - - _, desc, err := resolver.Resolve(ctx, m.Source) - if err != nil { - return "", fmt.Errorf("resolving %s: %w", m.Source, err) - } - fetcher, err := resolver.Fetcher(ctx, m.Source) - if err != nil { - return "", fmt.Errorf("fetching %s: %w", m.Source, err) - } - pusher, err := resolver.Pusher(ctx, ref) - if err != nil { - return "", fmt.Errorf("pushing to %s: %w", ref, err) - } - - if err := contentutil.CopyChain(ctx, contentutil.FromPusher(pusher), contentutil.FromFetcher(fetcher), desc); err != nil { - return "", fmt.Errorf("mirroring %s to %s: %w", m.Source, ref, err) - } - - registerImageConfig(cfg, ref) - return ref, nil -} - // SharedImage is an image that tests use. Build makes the image one time, // and Cleanup deletes it when the test binary ends. type SharedImage struct { diff --git a/internal/integration/skip.go b/internal/integration/skip.go index 162ccb6c..3043bd93 100644 --- a/internal/integration/skip.go +++ b/internal/integration/skip.go @@ -5,10 +5,7 @@ package integration -import ( - "runtime" - "testing" -) +import "testing" // SkipUnlessIntegration skips the test when the integration tag is not set. func SkipUnlessIntegration(t testing.TB) { @@ -18,14 +15,6 @@ func SkipUnlessIntegration(t testing.TB) { } } -// RequireBuildkit skips the test on Windows, which has no BuildKit for Linux images yet. -func RequireBuildkit(t testing.TB) { - t.Helper() - if runtime.GOOS == "windows" { - t.Skip("skipping test (no BuildKit on Windows)") - } -} - // RequireSupportedServer skips the test when the server is // not supported. func RequireSupportedServer(t testing.TB, servers []string) { From 04d7b9fe5639d9ffc3009b56df1ad33cb659d3d1 Mon Sep 17 00:00:00 2001 From: Cezar Craciunoiu Date: Thu, 5 Nov 2026 13:30:16 +0200 Subject: [PATCH 18/35] gomod(deps): Bump the Go SDK The pinned SDK does not report the instance that owns the relay interface of a network interface. Bump the SDK to v0.4.1-staging.3. It adds the relay instance and the node fields, and renames ID to ResourceID. Signed-off-by: Cezar Craciunoiu --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index a1270f36..42c8c9f5 100644 --- a/go.mod +++ b/go.mod @@ -51,7 +51,7 @@ require ( mvdan.cc/sh/v3 v3.14.1 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 v0.4.1-staging.3 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 diff --git a/go.sum b/go.sum index d0b05743..ee0ea450 100644 --- a/go.sum +++ b/go.sum @@ -477,8 +477,8 @@ tailscale.com v1.102.5 h1:2jK9VxQU4Vq/tyR7f2U2NqINxU0pV7R14TDBFsfguHE= tailscale.com v1.102.5/go.mod h1:47bv91Xbg4K1p5wti7F1dmKvUVWV5BXF78d9EWJ+d6c= unikraft.com/cloud/plugins/sandbox v0.0.0-20260916204449-fe2a902ae04c h1:ZNaDV2bBfC1FQO5YgKQsXgaJ+jc5sM9oVwV7EjPdEmg= 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 v0.4.1-staging.3 h1:k7E4HUcnRnqJ5Ev8Jefnmmm9Ah9MqnkXeR/+Lx6IyzE= +unikraft.com/cloud/sdk v0.4.1-staging.3/go.mod h1:/wX3Ltbi+rSzpS6zn2v6qLSP/7OLQqDWDp5VJ5ZVHlg= 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= From d403d4f15963e67abb38027a9f7cadf5704d952c Mon Sep 17 00:00:00 2001 From: Justin Chadwell Date: Mon, 31 Aug 2026 15:00:42 +0100 Subject: [PATCH 19/35] feat(model/value): Parse dotted keys The key=value form can set a nested struct field only when the outer field has a text form that holds all of its subfields. Split a key on its first dot and go into the named struct field. Render nested fields with the same dotted keys, so Parse reads them back. A bare word now fails. Before, a bare field name set the field to its zero value. Signed-off-by: Justin Chadwell Signed-off-by: Cezar Craciunoiu --- internal/model/value/fields.go | 81 ++++++++++++++++++++++ internal/model/value/format.go | 54 +++++++++------ internal/model/value/format_test.go | 49 +++++++++++++ internal/model/value/parse.go | 61 +++++++++-------- internal/model/value/parse_test.go | 102 ++++++++++++++++++++++++++++ 5 files changed, 297 insertions(+), 50 deletions(-) create mode 100644 internal/model/value/fields.go diff --git a/internal/model/value/fields.go b/internal/model/value/fields.go new file mode 100644 index 00000000..da722f3e --- /dev/null +++ b/internal/model/value/fields.go @@ -0,0 +1,81 @@ +// 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 value + +import ( + "iter" + "reflect" + + "github.com/ettle/strcase" +) + +type namedField struct { + Name string + Value reflect.Value + Anonymous bool +} + +// namedFields gives the exported fields of struct v by their "name" tag. +// Parse and Render both use it, so they use the same keys. +func namedFields(v reflect.Value) iter.Seq[namedField] { + return func(yield func(namedField) bool) { + t := v.Type() + for i := range t.NumField() { + field := t.Field(i) + if !field.IsExported() { + continue + } + name := field.Tag.Get("name") + if name == "-" { + continue + } + if name == "" { + name = strcase.ToKebab(field.Name) + } + if !yield(namedField{ + Name: name, + Value: v.Field(i), + Anonymous: field.Anonymous, + }) { + return + } + } + } +} + +// structValue returns f as a struct that a dotted key can go into. +// It returns false for an embedded field, because it has no key. +func (f namedField) structValue(alloc bool) (reflect.Value, bool) { + if f.Anonymous { + return reflect.Value{}, false + } + v := f.Value + for v.Kind() == reflect.Pointer { + if v.IsNil() { + if !alloc { + return reflect.Value{}, false + } + v.Set(reflect.New(v.Type().Elem())) + } + v = v.Elem() + } + if v.Kind() != reflect.Struct { + return reflect.Value{}, false + } + return v, true +} + +// implements reports if f, or a pointer to f, implements one of ifaces. +// A field that converts to or from text is one value with no subkeys. +func (f namedField) implements(ifaces ...reflect.Type) bool { + t := f.Value.Type() + for _, iface := range ifaces { + if t.Implements(iface) || reflect.PointerTo(t).Implements(iface) { + return true + } + } + return false +} diff --git a/internal/model/value/format.go b/internal/model/value/format.go index 9f9e9d6c..0e57d9b5 100644 --- a/internal/model/value/format.go +++ b/internal/model/value/format.go @@ -8,12 +8,11 @@ package value import ( "encoding" "fmt" + "iter" "reflect" "slices" "strconv" "strings" - - "github.com/ettle/strcase" ) // RenderOpts controls how a value is rendered to a string via Render. @@ -103,33 +102,46 @@ func Render(value any, opt RenderOpts) (string, error) { return strings.Join(result, ", "), nil case reflect.Struct: var result []string - for i := range v.NumField() { - field := v.Type().Field(i) - if !field.IsExported() { - continue - } - val := v.Field(i) - valStr, err := Render(val.Interface(), opt) + for path, leaf := range walkFields(v, "") { + str, err := Render(leaf.Interface(), opt) if err != nil { return "", err } - if valStr == "" { + if str == "" { continue } - // Use "name" tag for value formatting, separate from "field" tag - // This allows fields to be excluded from the field system (field:"-") - // while still being formattable for --set values - name := field.Tag.Get("name") - if name == "-" { - continue - } - if name == "" { - name = strcase.ToKebab(field.Name) - } - result = append(result, fmt.Sprintf("%s=%s", name, valStr)) + result = append(result, fmt.Sprintf("%s=%s", path, str)) } return strings.Join(result, ", "), nil default: return fmt.Sprintf("%v", value), nil } } + +var ( + textMarshaler = reflect.TypeFor[encoding.TextMarshaler]() + stringer = reflect.TypeFor[fmt.Stringer]() + renderer = reflect.TypeFor[Renderer]() +) + +// walkFields gives each leaf field of struct v with its dotted key. +// Parse can then read the rendered nested fields back. +func walkFields(v reflect.Value, prefix string) iter.Seq2[string, reflect.Value] { + return func(yield func(string, reflect.Value) bool) { + for field := range namedFields(v) { + path := prefix + field.Name + if sub, ok := field.structValue(false); ok && + !field.implements(renderer, stringer, textMarshaler) { + for path, leaf := range walkFields(sub, path+".") { + if !yield(path, leaf) { + return + } + } + continue + } + if !yield(path, field.Value) { + return + } + } + } +} diff --git a/internal/model/value/format_test.go b/internal/model/value/format_test.go index 15dd1983..fb262426 100644 --- a/internal/model/value/format_test.go +++ b/internal/model/value/format_test.go @@ -93,3 +93,52 @@ func TestFormat(t *testing.T) { }) } } + +// TestFormatNestedPaths checks that nested struct fields render as dotted keys. +func TestFormatNestedPaths(t *testing.T) { + t.Run("nested struct flattens to dotted keys", func(t *testing.T) { + v := testStructNested{Name: "outer"} + v.Inner.Label = "x" + v.Ptr = &testStructInner{Flag: new(true), Label: "y"} + + got, err := Render(&v, RenderOpts{}) + require.NoError(t, err) + assert.Equal(t, "name=outer, inner.label=x, ptr.flag=true, ptr.label=y", got) + }) + + t.Run("round-trips through Parse", func(t *testing.T) { + want := testStructNested{Name: "outer"} + want.Ptr = &testStructInner{Flag: new(false), Label: "y"} + want.Text.Raw = "whole value" + + rendered, err := Render(&want, RenderOpts{}) + require.NoError(t, err) + + got, err := Parse[testStructNested]([]string{rendered}) + require.NoError(t, err, "rendered as %q", rendered) + assert.Equal(t, want, got, "rendered as %q", rendered) + }) + + t.Run("opacity follows MarshalText alone", func(t *testing.T) { + v := testStructNested{} + v.WriteTo.Raw = "opaque" + v.ReadTo.Raw = "flattened" + + got, err := Render(&v, RenderOpts{}) + require.NoError(t, err) + assert.Equal(t, "read-to.raw=flattened, write-to=opaque", got) + }) + + t.Run("embedded fields render whole, under their type name", func(t *testing.T) { + var v testStructNested + v.Label = "x" + + got, err := Render(&v, RenderOpts{}) + require.NoError(t, err) + assert.Equal(t, "test-struct-embedded=label=x", got) + + back, err := Parse[testStructNested]([]string{got}) + require.NoError(t, err) + assert.Equal(t, v, back) + }) +} diff --git a/internal/model/value/parse.go b/internal/model/value/parse.go index fad9ce10..5aee4e0e 100644 --- a/internal/model/value/parse.go +++ b/internal/model/value/parse.go @@ -13,8 +13,6 @@ import ( "strconv" "strings" - "github.com/ettle/strcase" - wmaps "unikraft.com/cli/internal/w/maps" ) @@ -261,40 +259,23 @@ func parseReflect(input []string, value reflect.Value) error { if err != nil { return err } - process: for _, item := range items { item = strings.TrimSpace(item) if item == "" { continue } - k, v, _ := strings.Cut(item, "=") + k, v, hasValue := strings.Cut(item, "=") + if !hasValue { + return fmt.Errorf("invalid value %q: expected =", item) + } - for i := range s.NumField() { - field := s.Type().Field(i) - if !field.IsExported() { - continue - } - // Use "name" tag for value parsing, separate from "field" tag - // This allows fields to be excluded from the field system (field:"-") - // while still being parseable for --set values - name := field.Tag.Get("name") - if name == "-" { - continue - } - if name == "" { - name = field.Name - name = strcase.ToKebab(name) - } - if k == name { - fieldVal := s.Field(i) - err := parseReflect([]string{v}, fieldVal) - if err != nil { - return err - } - continue process - } + ok, err := assignField(s, k, v) + if err != nil { + return err + } + if !ok { + notFound[k] = struct{}{} } - notFound[k] = struct{}{} } } @@ -307,3 +288,25 @@ func parseReflect(input []string, value reflect.Value) error { return fmt.Errorf("unsupported type: %T", value.Interface()) } } + +var textUnmarshaler = reflect.TypeFor[encoding.TextUnmarshaler]() + +// assignField sets one key=value pair on struct s. A dotted key goes into the +// named struct field. It reports if a field matched the key. +func assignField(s reflect.Value, k, v string) (bool, error) { + head, rest, dotted := strings.Cut(k, ".") + + for field := range namedFields(s) { + if k == field.Name { + return true, parseReflect([]string{v}, field.Value) + } + if !dotted || head != field.Name || field.implements(textUnmarshaler) { + continue + } + if sub, ok := field.structValue(true); ok { + return assignField(sub, rest, v) + } + } + + return false, nil +} diff --git a/internal/model/value/parse_test.go b/internal/model/value/parse_test.go index 98e7a2a5..0e57f1be 100644 --- a/internal/model/value/parse_test.go +++ b/internal/model/value/parse_test.go @@ -115,6 +115,108 @@ func TestParseStructJSONValue(t *testing.T) { }) } +type testStructNested struct { + Name string `name:"name"` + Inner testStructInner `name:"inner"` + Ptr *testStructInner `name:"ptr"` + Hidden testStructInner `name:"-"` + Text testStructWithText `name:"text"` + ReadTo testStructReadOnly `name:"read-to"` + WriteTo testStructWriteOnly `name:"write-to"` + TestStructEmbedded +} + +// TestStructEmbedded is exported, so a field that embeds it is exported. +type TestStructEmbedded struct { + Label string `name:"label"` +} + +// testStructReadOnly converts only from text. Parse sets it as one value. +type testStructReadOnly struct { + Raw string `name:"raw"` +} + +func (t *testStructReadOnly) UnmarshalText(text []byte) error { + t.Raw = string(text) + return nil +} + +// testStructWriteOnly converts only to text. +type testStructWriteOnly struct { + Raw string `name:"raw"` +} + +func (t testStructWriteOnly) MarshalText() ([]byte, error) { + return []byte(t.Raw), nil +} + +type testStructInner struct { + Flag *bool `name:"flag"` + Label string `name:"label"` +} + +// testStructWithText converts from text, so a dotted key cannot go into it. +type testStructWithText struct { + Raw string `name:"raw"` +} + +func (t *testStructWithText) UnmarshalText(text []byte) error { + t.Raw = string(text) + return nil +} + +func (t testStructWithText) MarshalText() ([]byte, error) { + return []byte(t.Raw), nil +} + +// TestParseNestedPaths checks that dotted keys set nested struct fields. +func TestParseNestedPaths(t *testing.T) { + t.Run("nested field", func(t *testing.T) { + got, err := Parse[testStructNested]([]string{"name=outer,inner.label=x"}) + require.NoError(t, err) + assert.Equal(t, "outer", got.Name) + assert.Equal(t, "x", got.Inner.Label) + }) + + t.Run("several keys reach the same nested struct", func(t *testing.T) { + got, err := Parse[testStructNested]([]string{"ptr.label=y,ptr.flag=false"}) + require.NoError(t, err) + require.NotNil(t, got.Ptr) + assert.Equal(t, "y", got.Ptr.Label) + require.NotNil(t, got.Ptr.Flag) + assert.False(t, *got.Ptr.Flag) + }) + + t.Run("unknown nested key reports the whole path", func(t *testing.T) { + _, err := Parse[testStructNested]([]string{"inner.bogus=1"}) + require.ErrorContains(t, err, "unknown fields: [inner.bogus]") + }) + + t.Run("name:\"-\" is not reachable", func(t *testing.T) { + _, err := Parse[testStructNested]([]string{"hidden.label=x"}) + require.ErrorContains(t, err, "unknown fields: [hidden.label]") + }) + + t.Run("a bare word is not a field name", func(t *testing.T) { + _, err := Parse[testStructNested]([]string{"inner=x"}) + require.ErrorContains(t, err, `invalid value "x": expected =`) + }) + + t.Run("opacity follows UnmarshalText alone", func(t *testing.T) { + _, err := Parse[testStructNested]([]string{"read-to.raw=x"}) + require.ErrorContains(t, err, "unknown fields: [read-to.raw]") + + got, err := Parse[testStructNested]([]string{"write-to.raw=x"}) + require.NoError(t, err) + assert.Equal(t, "x", got.WriteTo.Raw) + }) + + t.Run("embedded fields are not descended into", func(t *testing.T) { + _, err := Parse[testStructNested]([]string{"test-struct-embedded.label=x"}) + require.ErrorContains(t, err, "unknown fields: [test-struct-embedded.label]") + }) +} + func TestParseStandalone(t *testing.T) { t.Run("string", func(t *testing.T) { got, err := Parse[string]([]string{"hello"}) From 04e6921a5a46d1110acb4482abc98c5f055feeda Mon Sep 17 00:00:00 2001 From: Justin Chadwell Date: Mon, 31 Aug 2026 15:01:38 +0100 Subject: [PATCH 20/35] feat(instances): Show network interfaces The output does not show the name, TAP device, autoconfig or relay of a network interface. A relay refers to an interface by name, so you cannot find the interface to use as a relay. Mirror these fields from the API. Show the relay with its interface name, UUID, DNS setting and the instance that owns it. Signed-off-by: Justin Chadwell Signed-off-by: Cezar Craciunoiu --- cmd/unikraft/testdata/TestHelp/instances | 54 ++++- cmd/unikraft/testdata/TestHelp/run | 6 +- internal/cmd/instances.go | 22 +- internal/cmd/output_test.go | 27 ++- internal/cmd/testdata/TestOutput/instances | 223 +++++++++++++++++- .../testdata/TestOutput/instances-http-oci | 52 ++++ 6 files changed, 365 insertions(+), 19 deletions(-) diff --git a/cmd/unikraft/testdata/TestHelp/instances b/cmd/unikraft/testdata/TestHelp/instances index adc62ebb..ed37ae81 100644 --- a/cmd/unikraft/testdata/TestHelp/instances +++ b/cmd/unikraft/testdata/TestHelp/instances @@ -74,7 +74,11 @@ Fields: roms, roms.*, roms.*.name, roms.*.image, roms.*.dir, roms.*.at plugins, plugins.*, plugins.*.name, plugins.*.image, plugins.*.config private-fqdn - networks, networks.*, networks.*.uuid, networks.*.private-ip, networks.*.mac + networks, networks.*, networks.*.name, networks.*.uuid, networks.*.private-ip, + networks.*.mac, networks.*.tap-name, networks.*.relay, networks.*.relay.name, + networks.*.relay.uuid, networks.*.relay.dns, networks.*.relay.instance, + networks.*.relay.instance.name, networks.*.relay.instance.uuid, + networks.*.autoconfig gpus, gpus.*, gpus.*.uuid, gpus.*.model timestamps, timestamps.created, timestamps.started, timestamps.stopped scale-to-zero, scale-to-zero.enabled, scale-to-zero.policy, scale-to- @@ -150,7 +154,11 @@ Fields: roms, roms.*, roms.*.name, roms.*.image, roms.*.dir, roms.*.at plugins, plugins.*, plugins.*.name, plugins.*.image, plugins.*.config private-fqdn - networks, networks.*, networks.*.uuid, networks.*.private-ip, networks.*.mac + networks, networks.*, networks.*.name, networks.*.uuid, networks.*.private-ip, + networks.*.mac, networks.*.tap-name, networks.*.relay, networks.*.relay.name, + networks.*.relay.uuid, networks.*.relay.dns, networks.*.relay.instance, + networks.*.relay.instance.name, networks.*.relay.instance.uuid, + networks.*.autoconfig gpus, gpus.*, gpus.*.uuid, gpus.*.model timestamps, timestamps.created, timestamps.started, timestamps.stopped scale-to-zero, scale-to-zero.enabled, scale-to-zero.policy, scale-to- @@ -234,7 +242,11 @@ Fields: roms, roms.*, roms.*.name, roms.*.image, roms.*.dir, roms.*.at plugins, plugins.*, plugins.*.name, plugins.*.image, plugins.*.config private-fqdn - networks, networks.*, networks.*.uuid, networks.*.private-ip, networks.*.mac + networks, networks.*, networks.*.name, networks.*.uuid, networks.*.private-ip, + networks.*.mac, networks.*.tap-name, networks.*.relay, networks.*.relay.name, + networks.*.relay.uuid, networks.*.relay.dns, networks.*.relay.instance, + networks.*.relay.instance.name, networks.*.relay.instance.uuid, + networks.*.autoconfig gpus, gpus.*, gpus.*.uuid, gpus.*.model timestamps, timestamps.created, timestamps.started, timestamps.stopped scale-to-zero, scale-to-zero.enabled, scale-to-zero.policy, scale-to- @@ -320,7 +332,11 @@ Fields: roms, roms.*, roms.*.name, roms.*.image, roms.*.dir, roms.*.at plugins, plugins.*, plugins.*.name, plugins.*.image, plugins.*.config private-fqdn - networks, networks.*, networks.*.uuid, networks.*.private-ip, networks.*.mac + networks, networks.*, networks.*.name, networks.*.uuid, networks.*.private-ip, + networks.*.mac, networks.*.tap-name, networks.*.relay, networks.*.relay.name, + networks.*.relay.uuid, networks.*.relay.dns, networks.*.relay.instance, + networks.*.relay.instance.name, networks.*.relay.instance.uuid, + networks.*.autoconfig gpus, gpus.*, gpus.*.uuid, gpus.*.model timestamps, timestamps.created, timestamps.started, timestamps.stopped scale-to-zero, scale-to-zero.enabled, scale-to-zero.policy, scale-to- @@ -431,7 +447,11 @@ Fields: roms, roms.*, roms.*.name, roms.*.image, roms.*.dir, roms.*.at plugins, plugins.*, plugins.*.name, plugins.*.image, plugins.*.config private-fqdn - networks, networks.*, networks.*.uuid, networks.*.private-ip, networks.*.mac + networks, networks.*, networks.*.name, networks.*.uuid, networks.*.private-ip, + networks.*.mac, networks.*.tap-name, networks.*.relay, networks.*.relay.name, + networks.*.relay.uuid, networks.*.relay.dns, networks.*.relay.instance, + networks.*.relay.instance.name, networks.*.relay.instance.uuid, + networks.*.autoconfig gpus, gpus.*, gpus.*.uuid, gpus.*.model timestamps, timestamps.created, timestamps.started, timestamps.stopped scale-to-zero, scale-to-zero.enabled, scale-to-zero.policy, scale-to- @@ -646,7 +666,11 @@ Fields: roms, roms.*, roms.*.name, roms.*.image, roms.*.dir, roms.*.at plugins, plugins.*, plugins.*.name, plugins.*.image, plugins.*.config private-fqdn - networks, networks.*, networks.*.uuid, networks.*.private-ip, networks.*.mac + networks, networks.*, networks.*.name, networks.*.uuid, networks.*.private-ip, + networks.*.mac, networks.*.tap-name, networks.*.relay, networks.*.relay.name, + networks.*.relay.uuid, networks.*.relay.dns, networks.*.relay.instance, + networks.*.relay.instance.name, networks.*.relay.instance.uuid, + networks.*.autoconfig gpus, gpus.*, gpus.*.uuid, gpus.*.model timestamps, timestamps.created, timestamps.started, timestamps.stopped scale-to-zero, scale-to-zero.enabled, scale-to-zero.policy, scale-to- @@ -869,7 +893,11 @@ Fields: roms, roms.*, roms.*.name, roms.*.image, roms.*.dir, roms.*.at plugins, plugins.*, plugins.*.name, plugins.*.image, plugins.*.config private-fqdn - networks, networks.*, networks.*.uuid, networks.*.private-ip, networks.*.mac + networks, networks.*, networks.*.name, networks.*.uuid, networks.*.private-ip, + networks.*.mac, networks.*.tap-name, networks.*.relay, networks.*.relay.name, + networks.*.relay.uuid, networks.*.relay.dns, networks.*.relay.instance, + networks.*.relay.instance.name, networks.*.relay.instance.uuid, + networks.*.autoconfig gpus, gpus.*, gpus.*.uuid, gpus.*.model timestamps, timestamps.created, timestamps.started, timestamps.stopped scale-to-zero, scale-to-zero.enabled, scale-to-zero.policy, scale-to- @@ -1080,7 +1108,11 @@ Fields: roms, roms.*, roms.*.name, roms.*.image, roms.*.dir, roms.*.at plugins, plugins.*, plugins.*.name, plugins.*.image, plugins.*.config private-fqdn - networks, networks.*, networks.*.uuid, networks.*.private-ip, networks.*.mac + networks, networks.*, networks.*.name, networks.*.uuid, networks.*.private-ip, + networks.*.mac, networks.*.tap-name, networks.*.relay, networks.*.relay.name, + networks.*.relay.uuid, networks.*.relay.dns, networks.*.relay.instance, + networks.*.relay.instance.name, networks.*.relay.instance.uuid, + networks.*.autoconfig gpus, gpus.*, gpus.*.uuid, gpus.*.model timestamps, timestamps.created, timestamps.started, timestamps.stopped scale-to-zero, scale-to-zero.enabled, scale-to-zero.policy, scale-to- @@ -1222,7 +1254,11 @@ Fields: roms, roms.*, roms.*.name, roms.*.image, roms.*.dir, roms.*.at plugins, plugins.*, plugins.*.name, plugins.*.image, plugins.*.config private-fqdn - networks, networks.*, networks.*.uuid, networks.*.private-ip, networks.*.mac + networks, networks.*, networks.*.name, networks.*.uuid, networks.*.private-ip, + networks.*.mac, networks.*.tap-name, networks.*.relay, networks.*.relay.name, + networks.*.relay.uuid, networks.*.relay.dns, networks.*.relay.instance, + networks.*.relay.instance.name, networks.*.relay.instance.uuid, + networks.*.autoconfig gpus, gpus.*, gpus.*.uuid, gpus.*.model timestamps, timestamps.created, timestamps.started, timestamps.stopped scale-to-zero, scale-to-zero.enabled, scale-to-zero.policy, scale-to- diff --git a/cmd/unikraft/testdata/TestHelp/run b/cmd/unikraft/testdata/TestHelp/run index 8ffc65e9..5e69d217 100644 --- a/cmd/unikraft/testdata/TestHelp/run +++ b/cmd/unikraft/testdata/TestHelp/run @@ -64,7 +64,11 @@ Fields: roms, roms.*, roms.*.name, roms.*.image, roms.*.dir, roms.*.at plugins, plugins.*, plugins.*.name, plugins.*.image, plugins.*.config private-fqdn - networks, networks.*, networks.*.uuid, networks.*.private-ip, networks.*.mac + networks, networks.*, networks.*.name, networks.*.uuid, networks.*.private-ip, + networks.*.mac, networks.*.tap-name, networks.*.relay, networks.*.relay.name, + networks.*.relay.uuid, networks.*.relay.dns, networks.*.relay.instance, + networks.*.relay.instance.name, networks.*.relay.instance.uuid, + networks.*.autoconfig gpus, gpus.*, gpus.*.uuid, gpus.*.model timestamps, timestamps.created, timestamps.started, timestamps.stopped scale-to-zero, scale-to-zero.enabled, scale-to-zero.policy, scale-to- diff --git a/internal/cmd/instances.go b/internal/cmd/instances.go index d1f239fb..1ee0183f 100644 --- a/internal/cmd/instances.go +++ b/internal/cmd/instances.go @@ -190,9 +190,25 @@ type Instance struct { } type InstanceNetwork struct { - UUID string `mirror:"uuid" field:",long" help:"Unique identifier of the network interface."` - PrivateIP string `mirror:"private_ip" field:",long" help:"Private IP address, reachable only from your other instances in the metro."` - MAC string `mirror:"mac" field:",long" help:"MAC address of the network interface."` + Name string `mirror:"name" field:",long" help:"Interface name, unique in the account. Other instances use it as their relay."` + UUID string `mirror:"uuid" field:",long" help:"Unique identifier of the network interface."` + PrivateIP string `mirror:"private_ip" field:",long" help:"Private IP address, reachable only from your other instances in the metro."` + MAC string `mirror:"mac" field:",long" help:"MAC address of the network interface."` + TapName string `mirror:"tap_name" field:"tap-name,long" help:"Host TAP device of an interface that you bring yourself."` + Relay *InstanceNetworkRelay `mirror:"relay" field:",embed" help:"Interface that all traffic of this interface goes through."` + Autoconfig *bool `mirror:"autoconfig" field:",long" help:"Whether the platform configures the interface inside the guest."` +} + +// InstanceNetworkRelay is the interface of another instance that gets all the +// traffic of a network interface. +type InstanceNetworkRelay struct { + Name string `mirror:"name" field:",long" help:"Name of the relay interface."` + UUID string `mirror:"uuid" field:",long" help:"Unique identifier of the relay interface."` + // The API names this member relay_dns, inside the relay object itself. + DNS *bool `mirror:"relay_dns" field:",long" help:"Whether DNS requests go through the relay. Defaults to true."` + Instance *struct { + Link[Instance] + } `name:"-" mirror:"instance" json:"-" field:",long" help:"Instance that owns the relay interface."` } type InstanceGpu struct { diff --git a/internal/cmd/output_test.go b/internal/cmd/output_test.go index e36ed4a7..ab343d7e 100644 --- a/internal/cmd/output_test.go +++ b/internal/cmd/output_test.go @@ -139,10 +139,29 @@ func instancesOutputTests(t *testing.T) { sample.Resources.Memory = 256 sample.Resources.VCPUs = 2 sample.PrivateFQDN = "my-instance.internal" - sample.Networks = append(sample.Networks, cmd.InstanceNetwork{}) - sample.Networks[0].UUID = "net-uuid-1234" - sample.Networks[0].PrivateIP = "192.168.1.10" - sample.Networks[0].MAC = "aa:bb:cc:dd:ee:ff" + sample.Networks = []cmd.InstanceNetwork{ + { + Name: "my-instance-eth0", + UUID: "net-uuid-1234", + PrivateIP: "192.168.1.10", + MAC: "aa:bb:cc:dd:ee:ff", + Relay: &cmd.InstanceNetworkRelay{ + Name: "my-router-eth0", + UUID: "net-uuid-5678", + DNS: new(true), + Instance: &struct{ cmd.Link[cmd.Instance] }{ + cmd.Link[cmd.Instance]{Name: "my-router", UUID: "inst-uuid-5678"}, + }, + }, + }, + { + Name: "my-instance-eth1", + UUID: "net-uuid-9abc", + PrivateIP: "192.168.1.11", + MAC: "aa:bb:cc:dd:ee:00", + TapName: "tap0", + }, + } vmType := platform.InstanceTypeFull sample.Type_ = &vmType sample.Gpus = []cmd.InstanceGpu{ diff --git a/internal/cmd/testdata/TestOutput/instances b/internal/cmd/testdata/TestOutput/instances index 6b6c116a..7ce7756c 100644 --- a/internal/cmd/testdata/TestOutput/instances +++ b/internal/cmd/testdata/TestOutput/instances @@ -37,9 +37,22 @@ plugins: config: {"timeout":30} private-fqdn: my-instance.internal networks: -- uuid: net-uuid-1234 +- name: my-instance-eth0 + uuid: net-uuid-1234 private-ip: 192.168.1.10 mac: aa:bb:cc:dd:ee:ff + relay: + name: my-router-eth0 + uuid: net-uuid-5678 + dns: true + instance: + name: my-router + uuid: inst-uuid-5678 +- name: my-instance-eth1 + uuid: net-uuid-9abc + private-ip: 192.168.1.11 + mac: aa:bb:cc:dd:ee:00 + tap-name: tap0 gpus: - uuid: gpu-uuid-1234 model: 10de:1eb8 @@ -103,9 +116,32 @@ plugins: config: {"timeout":30} private-fqdn: my-instance.internal networks: -- uuid: net-uuid-1234 +- name: my-instance-eth0 + uuid: net-uuid-1234 private-ip: 192.168.1.10 mac: aa:bb:cc:dd:ee:ff + tap-name: + relay: + name: my-router-eth0 + uuid: net-uuid-5678 + dns: true + instance: + name: my-router + uuid: inst-uuid-5678 + autoconfig: +- name: my-instance-eth1 + uuid: net-uuid-9abc + private-ip: 192.168.1.11 + mac: aa:bb:cc:dd:ee:00 + tap-name: tap0 + relay: + name: + uuid: + dns: + instance: + name: + uuid: + autoconfig: gpus: - uuid: gpu-uuid-1234 model: 10de:1eb8 @@ -739,6 +775,11 @@ fra my-instance running nginx ["arg1", "arg2"] 256MiB 2 example.uni { "name": "0", "subfields": [ + { + "name": "name", + "value": "my-instance-eth0", + "verbosity": "long" + }, { "name": "uuid", "value": "net-uuid-1234", @@ -753,6 +794,132 @@ fra my-instance running nginx ["arg1", "arg2"] 256MiB 2 example.uni "name": "mac", "value": "aa:bb:cc:dd:ee:ff", "verbosity": "long" + }, + { + "name": "tap-name", + "value": "", + "verbosity": "long" + }, + { + "name": "relay", + "subfields": [ + { + "name": "name", + "value": "my-router-eth0", + "verbosity": "long" + }, + { + "name": "uuid", + "value": "net-uuid-5678", + "verbosity": "long" + }, + { + "name": "dns", + "value": true, + "verbosity": "long" + }, + { + "name": "instance", + "subfields": [ + { + "name": "name", + "value": "my-router", + "verbosity": "long" + }, + { + "name": "uuid", + "value": "inst-uuid-5678", + "verbosity": "long" + } + ], + "links": [ + { + "type": "instance", + "key": "my-router" + } + ], + "verbosity": "long" + } + ], + "verbosity": "long" + }, + { + "name": "autoconfig", + "value": null, + "verbosity": "long" + } + ], + "verbosity": "long" + }, + { + "name": "1", + "subfields": [ + { + "name": "name", + "value": "my-instance-eth1", + "verbosity": "long" + }, + { + "name": "uuid", + "value": "net-uuid-9abc", + "verbosity": "long" + }, + { + "name": "private-ip", + "value": "192.168.1.11", + "verbosity": "long" + }, + { + "name": "mac", + "value": "aa:bb:cc:dd:ee:00", + "verbosity": "long" + }, + { + "name": "tap-name", + "value": "tap0", + "verbosity": "long" + }, + { + "name": "relay", + "subfields": [ + { + "name": "name", + "value": "", + "verbosity": "long" + }, + { + "name": "uuid", + "value": "", + "verbosity": "long" + }, + { + "name": "dns", + "value": null, + "verbosity": "long" + }, + { + "name": "instance", + "subfields": [ + { + "name": "name", + "value": "", + "verbosity": "long" + }, + { + "name": "uuid", + "value": "", + "verbosity": "long" + } + ], + "verbosity": "long" + } + ], + "verbosity": "long" + }, + { + "name": "autoconfig", + "value": null, + "verbosity": "long" } ], "verbosity": "long" @@ -761,6 +928,11 @@ fra my-instance running nginx ["arg1", "arg2"] 256MiB 2 example.uni "elem": { "name": "", "subfields": [ + { + "name": "name", + "value": "", + "verbosity": "long" + }, { "name": "uuid", "value": "", @@ -775,6 +947,53 @@ fra my-instance running nginx ["arg1", "arg2"] 256MiB 2 example.uni "name": "mac", "value": "", "verbosity": "long" + }, + { + "name": "tap-name", + "value": "", + "verbosity": "long" + }, + { + "name": "relay", + "subfields": [ + { + "name": "name", + "value": "", + "verbosity": "long" + }, + { + "name": "uuid", + "value": "", + "verbosity": "long" + }, + { + "name": "dns", + "value": null, + "verbosity": "long" + }, + { + "name": "instance", + "subfields": [ + { + "name": "name", + "value": "", + "verbosity": "long" + }, + { + "name": "uuid", + "value": "", + "verbosity": "long" + } + ], + "verbosity": "long" + } + ], + "verbosity": "long" + }, + { + "name": "autoconfig", + "value": null, + "verbosity": "long" } ], "verbosity": "long" diff --git a/internal/cmd/testdata/TestOutput/instances-http-oci b/internal/cmd/testdata/TestOutput/instances-http-oci index 6f31fead..bfa8ea3a 100644 --- a/internal/cmd/testdata/TestOutput/instances-http-oci +++ b/internal/cmd/testdata/TestOutput/instances-http-oci @@ -433,6 +433,11 @@ fra my-instance running https+oci://cdn.example.com/me/app/@sha256:43d3d758 "elem": { "name": "", "subfields": [ + { + "name": "name", + "value": "", + "verbosity": "long" + }, { "name": "uuid", "value": "", @@ -447,6 +452,53 @@ fra my-instance running https+oci://cdn.example.com/me/app/@sha256:43d3d758 "name": "mac", "value": "", "verbosity": "long" + }, + { + "name": "tap-name", + "value": "", + "verbosity": "long" + }, + { + "name": "relay", + "subfields": [ + { + "name": "name", + "value": "", + "verbosity": "long" + }, + { + "name": "uuid", + "value": "", + "verbosity": "long" + }, + { + "name": "dns", + "value": null, + "verbosity": "long" + }, + { + "name": "instance", + "subfields": [ + { + "name": "name", + "value": "", + "verbosity": "long" + }, + { + "name": "uuid", + "value": "", + "verbosity": "long" + } + ], + "verbosity": "long" + } + ], + "verbosity": "long" + }, + { + "name": "autoconfig", + "value": null, + "verbosity": "long" } ], "verbosity": "long" From cab404a8cffdd836751e7644bb09737f2cdbe11f Mon Sep 17 00:00:00 2001 From: Justin Chadwell Date: Mon, 31 Aug 2026 15:02:27 +0100 Subject: [PATCH 21/35] feat(instances): Configure NICs on create You cannot set the network interfaces of an instance from the CLI, so you cannot create a relay or an instance that uses one. Add --network to instance create and run, one interface per flag. Set the relay with relay.name, relay.uuid and relay.dns. Refuse --replicas with a name, tap-name, ip or mac that each replica needs, and hint at net_manager when the API does not know the field. Signed-off-by: Justin Chadwell Signed-off-by: Cezar Craciunoiu --- cmd/unikraft/testdata/TestHelp/instances | 51 +++++++-- cmd/unikraft/testdata/TestHelp/run | 13 ++- internal/cmd/instances.go | 91 ++++++++++++--- internal/cmd/instances_test.go | 57 ++++++++++ internal/cmd/marshal_test.go | 104 ++++++++++++++++++ internal/cmd/output_test.go | 2 +- internal/cmd/testdata/TestOutput/instances | 35 +++++- .../testdata/TestOutput/instances-http-oci | 10 +- 8 files changed, 337 insertions(+), 26 deletions(-) diff --git a/cmd/unikraft/testdata/TestHelp/instances b/cmd/unikraft/testdata/TestHelp/instances index ed37ae81..c2f1f877 100644 --- a/cmd/unikraft/testdata/TestHelp/instances +++ b/cmd/unikraft/testdata/TestHelp/instances @@ -77,7 +77,7 @@ Fields: networks, networks.*, networks.*.name, networks.*.uuid, networks.*.private-ip, networks.*.mac, networks.*.tap-name, networks.*.relay, networks.*.relay.name, networks.*.relay.uuid, networks.*.relay.dns, networks.*.relay.instance, - networks.*.relay.instance.name, networks.*.relay.instance.uuid, + networks.*.relay.instance.name, networks.*.relay.instance.uuid, networks.*.ip, networks.*.autoconfig gpus, gpus.*, gpus.*.uuid, gpus.*.model timestamps, timestamps.created, timestamps.started, timestamps.stopped @@ -157,7 +157,7 @@ Fields: networks, networks.*, networks.*.name, networks.*.uuid, networks.*.private-ip, networks.*.mac, networks.*.tap-name, networks.*.relay, networks.*.relay.name, networks.*.relay.uuid, networks.*.relay.dns, networks.*.relay.instance, - networks.*.relay.instance.name, networks.*.relay.instance.uuid, + networks.*.relay.instance.name, networks.*.relay.instance.uuid, networks.*.ip, networks.*.autoconfig gpus, gpus.*, gpus.*.uuid, gpus.*.model timestamps, timestamps.created, timestamps.started, timestamps.stopped @@ -245,7 +245,7 @@ Fields: networks, networks.*, networks.*.name, networks.*.uuid, networks.*.private-ip, networks.*.mac, networks.*.tap-name, networks.*.relay, networks.*.relay.name, networks.*.relay.uuid, networks.*.relay.dns, networks.*.relay.instance, - networks.*.relay.instance.name, networks.*.relay.instance.uuid, + networks.*.relay.instance.name, networks.*.relay.instance.uuid, networks.*.ip, networks.*.autoconfig gpus, gpus.*, gpus.*.uuid, gpus.*.model timestamps, timestamps.created, timestamps.started, timestamps.stopped @@ -335,7 +335,7 @@ Fields: networks, networks.*, networks.*.name, networks.*.uuid, networks.*.private-ip, networks.*.mac, networks.*.tap-name, networks.*.relay, networks.*.relay.name, networks.*.relay.uuid, networks.*.relay.dns, networks.*.relay.instance, - networks.*.relay.instance.name, networks.*.relay.instance.uuid, + networks.*.relay.instance.name, networks.*.relay.instance.uuid, networks.*.ip, networks.*.autoconfig gpus, gpus.*, gpus.*.uuid, gpus.*.model timestamps, timestamps.created, timestamps.started, timestamps.stopped @@ -450,7 +450,7 @@ Fields: networks, networks.*, networks.*.name, networks.*.uuid, networks.*.private-ip, networks.*.mac, networks.*.tap-name, networks.*.relay, networks.*.relay.name, networks.*.relay.uuid, networks.*.relay.dns, networks.*.relay.instance, - networks.*.relay.instance.name, networks.*.relay.instance.uuid, + networks.*.relay.instance.name, networks.*.relay.instance.uuid, networks.*.ip, networks.*.autoconfig gpus, gpus.*, gpus.*.uuid, gpus.*.model timestamps, timestamps.created, timestamps.started, timestamps.stopped @@ -567,6 +567,17 @@ Create flags: --plugin=name=,image=[,config=] ... Plugin loaded from an image, with an optional JSON config passed to it on start. Up to 8 per instance. [examples: name=sandbox,image=plugins/sandbox:latest, name=sandbox,image=plugins/sandbox:latest,config={"persist_path":"/data"}] + --network== ... + Network interface to attach, one per flag and up to 4. The first one is the primary interface. Needs the net_manager permission. + name: interface name, unique in the account. Set it on a relay, so that other instances can refer to it. While its clients run, the relay instance stops only with --force and cannot be deleted + relay.name: name of the interface, not of the instance, that gets all the traffic of this one. It must be a platform interface of another instance, with no relay of its own + relay.uuid: the same, by UUID + relay.dns: false sends DNS requests to the platform's DNS server instead of the relay (default true) + tap-name: host TAP device, to bring your own interface + ip: address in CIDR notation, needs tap-name + mac: unicast MAC address, needs tap-name + autoconfig: whether the platform configures the interface inside the guest. + [examples: name=my-relay, relay.name=my-relay, relay.name=my-relay,relay.dns=false, name=my-vpc,tap-name=tap0,ip=10.0.0.5/24] --scale-to-zero== Scale-to-zero options. Traffic through a service or to the private IP wakes the instance. policy: on | idle (also while open TCP connections are idle, needs stateful=true) | off @@ -669,7 +680,7 @@ Fields: networks, networks.*, networks.*.name, networks.*.uuid, networks.*.private-ip, networks.*.mac, networks.*.tap-name, networks.*.relay, networks.*.relay.name, networks.*.relay.uuid, networks.*.relay.dns, networks.*.relay.instance, - networks.*.relay.instance.name, networks.*.relay.instance.uuid, + networks.*.relay.instance.name, networks.*.relay.instance.uuid, networks.*.ip, networks.*.autoconfig gpus, gpus.*, gpus.*.uuid, gpus.*.model timestamps, timestamps.created, timestamps.started, timestamps.stopped @@ -786,6 +797,17 @@ Create flags: --plugin=name=,image=[,config=] ... Plugin loaded from an image, with an optional JSON config passed to it on start. Up to 8 per instance. [examples: name=sandbox,image=plugins/sandbox:latest, name=sandbox,image=plugins/sandbox:latest,config={"persist_path":"/data"}] + --network== ... + Network interface to attach, one per flag and up to 4. The first one is the primary interface. Needs the net_manager permission. + name: interface name, unique in the account. Set it on a relay, so that other instances can refer to it. While its clients run, the relay instance stops only with --force and cannot be deleted + relay.name: name of the interface, not of the instance, that gets all the traffic of this one. It must be a platform interface of another instance, with no relay of its own + relay.uuid: the same, by UUID + relay.dns: false sends DNS requests to the platform's DNS server instead of the relay (default true) + tap-name: host TAP device, to bring your own interface + ip: address in CIDR notation, needs tap-name + mac: unicast MAC address, needs tap-name + autoconfig: whether the platform configures the interface inside the guest. + [examples: name=my-relay, relay.name=my-relay, relay.name=my-relay,relay.dns=false, name=my-vpc,tap-name=tap0,ip=10.0.0.5/24] --scale-to-zero== Scale-to-zero options. Traffic through a service or to the private IP wakes the instance. policy: on | idle (also while open TCP connections are idle, needs stateful=true) | off @@ -896,7 +918,7 @@ Fields: networks, networks.*, networks.*.name, networks.*.uuid, networks.*.private-ip, networks.*.mac, networks.*.tap-name, networks.*.relay, networks.*.relay.name, networks.*.relay.uuid, networks.*.relay.dns, networks.*.relay.instance, - networks.*.relay.instance.name, networks.*.relay.instance.uuid, + networks.*.relay.instance.name, networks.*.relay.instance.uuid, networks.*.ip, networks.*.autoconfig gpus, gpus.*, gpus.*.uuid, gpus.*.model timestamps, timestamps.created, timestamps.started, timestamps.stopped @@ -1015,6 +1037,17 @@ Create flags: --plugin=name=,image=[,config=] ... Plugin loaded from an image, with an optional JSON config passed to it on start. Up to 8 per instance. [examples: name=sandbox,image=plugins/sandbox:latest, name=sandbox,image=plugins/sandbox:latest,config={"persist_path":"/data"}] + --network== ... + Network interface to attach, one per flag and up to 4. The first one is the primary interface. Needs the net_manager permission. + name: interface name, unique in the account. Set it on a relay, so that other instances can refer to it. While its clients run, the relay instance stops only with --force and cannot be deleted + relay.name: name of the interface, not of the instance, that gets all the traffic of this one. It must be a platform interface of another instance, with no relay of its own + relay.uuid: the same, by UUID + relay.dns: false sends DNS requests to the platform's DNS server instead of the relay (default true) + tap-name: host TAP device, to bring your own interface + ip: address in CIDR notation, needs tap-name + mac: unicast MAC address, needs tap-name + autoconfig: whether the platform configures the interface inside the guest. + [examples: name=my-relay, relay.name=my-relay, relay.name=my-relay,relay.dns=false, name=my-vpc,tap-name=tap0,ip=10.0.0.5/24] --scale-to-zero== Scale-to-zero options. Traffic through a service or to the private IP wakes the instance. policy: on | idle (also while open TCP connections are idle, needs stateful=true) | off @@ -1111,7 +1144,7 @@ Fields: networks, networks.*, networks.*.name, networks.*.uuid, networks.*.private-ip, networks.*.mac, networks.*.tap-name, networks.*.relay, networks.*.relay.name, networks.*.relay.uuid, networks.*.relay.dns, networks.*.relay.instance, - networks.*.relay.instance.name, networks.*.relay.instance.uuid, + networks.*.relay.instance.name, networks.*.relay.instance.uuid, networks.*.ip, networks.*.autoconfig gpus, gpus.*, gpus.*.uuid, gpus.*.model timestamps, timestamps.created, timestamps.started, timestamps.stopped @@ -1257,7 +1290,7 @@ Fields: networks, networks.*, networks.*.name, networks.*.uuid, networks.*.private-ip, networks.*.mac, networks.*.tap-name, networks.*.relay, networks.*.relay.name, networks.*.relay.uuid, networks.*.relay.dns, networks.*.relay.instance, - networks.*.relay.instance.name, networks.*.relay.instance.uuid, + networks.*.relay.instance.name, networks.*.relay.instance.uuid, networks.*.ip, networks.*.autoconfig gpus, gpus.*, gpus.*.uuid, gpus.*.model timestamps, timestamps.created, timestamps.started, timestamps.stopped diff --git a/cmd/unikraft/testdata/TestHelp/run b/cmd/unikraft/testdata/TestHelp/run index 5e69d217..c90e555e 100644 --- a/cmd/unikraft/testdata/TestHelp/run +++ b/cmd/unikraft/testdata/TestHelp/run @@ -67,7 +67,7 @@ Fields: networks, networks.*, networks.*.name, networks.*.uuid, networks.*.private-ip, networks.*.mac, networks.*.tap-name, networks.*.relay, networks.*.relay.name, networks.*.relay.uuid, networks.*.relay.dns, networks.*.relay.instance, - networks.*.relay.instance.name, networks.*.relay.instance.uuid, + networks.*.relay.instance.name, networks.*.relay.instance.uuid, networks.*.ip, networks.*.autoconfig gpus, gpus.*, gpus.*.uuid, gpus.*.model timestamps, timestamps.created, timestamps.started, timestamps.stopped @@ -186,6 +186,17 @@ Create flags: --plugin=name=,image=[,config=] ... Plugin loaded from an image, with an optional JSON config passed to it on start. Up to 8 per instance. [examples: name=sandbox,image=plugins/sandbox:latest, name=sandbox,image=plugins/sandbox:latest,config={"persist_path":"/data"}] + --network== ... + Network interface to attach, one per flag and up to 4. The first one is the primary interface. Needs the net_manager permission. + name: interface name, unique in the account. Set it on a relay, so that other instances can refer to it. While its clients run, the relay instance stops only with --force and cannot be deleted + relay.name: name of the interface, not of the instance, that gets all the traffic of this one. It must be a platform interface of another instance, with no relay of its own + relay.uuid: the same, by UUID + relay.dns: false sends DNS requests to the platform's DNS server instead of the relay (default true) + tap-name: host TAP device, to bring your own interface + ip: address in CIDR notation, needs tap-name + mac: unicast MAC address, needs tap-name + autoconfig: whether the platform configures the interface inside the guest. + [examples: name=my-relay, relay.name=my-relay, relay.name=my-relay,relay.dns=false, name=my-vpc,tap-name=tap0,ip=10.0.0.5/24] --scale-to-zero== Scale-to-zero options. Traffic through a service or to the private IP wakes the instance. policy: on | idle (also while open TCP connections are idle, needs stateful=true) | off diff --git a/internal/cmd/instances.go b/internal/cmd/instances.go index 1ee0183f..b8fcd4e1 100644 --- a/internal/cmd/instances.go +++ b/internal/cmd/instances.go @@ -130,9 +130,9 @@ type Instance struct { Roms []*InstanceRom `mirror:"instance.roms" field:",embed" create:"set" edit:"set,add,del=strings" flag:"rom" sep:"none" help:"Read-only ROM device from an image or a local directory. The name uses letters, digits, - and _, and appears as /dev/ukp_rom_NAME in the guest." placeholder:"name=,image=,at=" example:"name=my-rom\\,image=myuser/my-rom:latest\\,at=/rom0,name=mydata\\,dir=./mydata\\,at=/rom"` Plugins []*InstancePlugin `mirror:"instance.plugins" field:",embed" create:"set" edit:"set,add,del=strings" flag:"plugin" sep:"none" help:"Plugin loaded from an image, with an optional JSON config passed to it on start. Up to 8 per instance." placeholder:"name=,image=[,config=]" example:"name=sandbox\\,image=plugins/sandbox:latest,name=sandbox\\,image=plugins/sandbox:latest\\,config={\"persist_path\":\"/data\"}"` - PrivateFQDN string `mirror:"instance.private_fqdn" field:"private-fqdn,long" help:"Private hostname, reachable only from your other instances in the metro."` - Networks []InstanceNetwork `mirror:"instance.network_interfaces" field:",embed" help:"Network interfaces of the instance."` - Gpus []InstanceGpu `mirror:"instance.gpus" field:"gpus,embed" help:"GPUs attached to the instance, only for type full."` + PrivateFQDN string `mirror:"instance.private_fqdn" field:"private-fqdn,long" help:"Private hostname, reachable only from your other instances in the metro."` + Networks []*InstanceNetwork `mirror:"instance.network_interfaces" field:",embed" create:"set" flag:"network" sep:"none" help:"Network interface to attach, one per flag and up to 4. The first one is the primary interface. Needs the net_manager permission.\n name: interface name, unique in the account. Set it on a relay, so that other instances can refer to it. While its clients run, the relay instance stops only with --force and cannot be deleted\n relay.name: name of the interface, not of the instance, that gets all the traffic of this one. It must be a platform interface of another instance, with no relay of its own\n relay.uuid: the same, by UUID\n relay.dns: false sends DNS requests to the platform's DNS server instead of the relay (default true)\n tap-name: host TAP device, to bring your own interface\n ip: address in CIDR notation, needs tap-name\n mac: unicast MAC address, needs tap-name\n autoconfig: whether the platform configures the interface inside the guest" placeholder:"=" example:"name=my-relay,relay.name=my-relay,relay.name=my-relay\\,relay.dns=false,name=my-vpc\\,tap-name=tap0\\,ip=10.0.0.5/24"` + Gpus []InstanceGpu `mirror:"instance.gpus" field:"gpus,embed" help:"GPUs attached to the instance, only for type full."` Timestamps struct { Created types.RelativeTime `mirror:"instance.created_at" field:",short" help:"Creation time."` @@ -190,27 +190,62 @@ type Instance struct { } type InstanceNetwork struct { - Name string `mirror:"name" field:",long" help:"Interface name, unique in the account. Other instances use it as their relay."` - UUID string `mirror:"uuid" field:",long" help:"Unique identifier of the network interface."` - PrivateIP string `mirror:"private_ip" field:",long" help:"Private IP address, reachable only from your other instances in the metro."` - MAC string `mirror:"mac" field:",long" help:"MAC address of the network interface."` - TapName string `mirror:"tap_name" field:"tap-name,long" help:"Host TAP device of an interface that you bring yourself."` - Relay *InstanceNetworkRelay `mirror:"relay" field:",embed" help:"Interface that all traffic of this interface goes through."` - Autoconfig *bool `mirror:"autoconfig" field:",long" help:"Whether the platform configures the interface inside the guest."` + Name string `name:"name" mirror:"name" json:"name,omitempty" field:",long" help:"Interface name, unique in the account. Other instances use it as their relay."` + UUID string `name:"-" mirror:"uuid" json:"-" field:",long" help:"Unique identifier of the network interface."` + PrivateIP string `name:"-" mirror:"private_ip" json:"-" field:",long" help:"Private IP address, reachable only from your other instances in the metro."` + MAC string `name:"mac" mirror:"mac" json:"mac,omitempty" field:",long" help:"MAC address of the network interface."` + TapName string `name:"tap-name" mirror:"tap_name" json:"tap-name,omitempty" field:"tap-name,long" help:"Host TAP device of an interface that you bring yourself."` + + Relay *InstanceNetworkRelay `name:"relay" mirror:"relay" json:"relay,omitempty" field:",embed" help:"Interface that all traffic of this interface goes through."` + + IP string `name:"ip" json:"ip,omitempty" field:"ip,invisible" help:"Interface address in CIDR notation. Needs tap-name."` + Autoconfig *bool `name:"autoconfig" mirror:"autoconfig" json:"autoconfig,omitempty" field:",long" help:"Whether the platform configures the interface inside the guest."` } // InstanceNetworkRelay is the interface of another instance that gets all the // traffic of a network interface. type InstanceNetworkRelay struct { - Name string `mirror:"name" field:",long" help:"Name of the relay interface."` - UUID string `mirror:"uuid" field:",long" help:"Unique identifier of the relay interface."` + Name string `name:"name" mirror:"name" json:"name,omitempty" field:",long" help:"Name of the relay interface."` + UUID string `name:"uuid" mirror:"uuid" json:"uuid,omitempty" field:",long" help:"Unique identifier of the relay interface."` // The API names this member relay_dns, inside the relay object itself. - DNS *bool `mirror:"relay_dns" field:",long" help:"Whether DNS requests go through the relay. Defaults to true."` + DNS *bool `name:"dns" mirror:"relay_dns" json:"dns,omitempty" field:",long" help:"Whether DNS requests go through the relay. Defaults to true."` Instance *struct { Link[Instance] } `name:"-" mirror:"instance" json:"-" field:",long" help:"Instance that owns the relay interface."` } +func (n *InstanceNetwork) Validate() error { + if n == nil { + return fmt.Errorf("network interface cannot be null") + } + if n.Relay != nil && n.Relay.Name == "" && n.Relay.UUID == "" { + return fmt.Errorf("relay requires relay.name or relay.uuid") + } + return nil +} + +func (n *InstanceNetwork) UnmarshalText(data []byte) error { + type alias InstanceNetwork + parsed, err := value.Parse[alias]([]string{string(data)}) + if err != nil { + return err + } + *n = InstanceNetwork(parsed) + return n.Validate() +} + +func (n *InstanceNetwork) UnmarshalJSON(data []byte) error { + if len(data) != 0 && data[0] == '"' { + var text string + if err := json.Unmarshal(data, &text); err != nil { + return err + } + return n.UnmarshalText([]byte(text)) + } + type networkJSON InstanceNetwork // alias to avoid recursion + return json.Unmarshal(data, (*networkJSON)(n)) +} + type InstanceGpu struct { UUID string `mirror:"uuid" field:",long" help:"Unique identifier of the GPU."` Model string `mirror:"model" field:",long" help:"GPU model, as its PCI vendor:device ID."` @@ -1356,6 +1391,27 @@ func (Instance) Create(ctx context.Context, fields []model.Field) ([]model.Model } req.Plugins = append(req.Plugins, reqPlugin) } + case "networks": + for _, net := range field.Create.Set.([]*InstanceNetwork) { + if err := net.Validate(); err != nil { + return nil, err + } + reqNet := platform.CreateInstanceRequestNetworkInterface{ + Name: ptr.NilIfZero(net.Name), + TapName: ptr.NilIfZero(net.TapName), + Ip: ptr.NilIfZero(net.IP), + Mac: ptr.NilIfZero(net.MAC), + Autoconfig: net.Autoconfig, + } + if net.Relay != nil { + reqNet.Relay = &platform.NetworkInterfaceRelay{ + Name: ptr.NilIfZero(net.Relay.Name), + Uuid: ptr.NilIfZero(net.Relay.UUID), + RelayDns: net.Relay.DNS, + } + } + req.NetworkInterfaces = append(req.NetworkInterfaces, reqNet) + } case "service": svc := field.Create.Set.(*InstanceService) if req.ServiceGroup == nil { @@ -1495,6 +1551,12 @@ func (Instance) Create(ctx context.Context, fields []model.Field) ([]model.Model return nil, fmt.Errorf("only one of --image, --template, --branch, or --checkpoint may be specified") } + if req.Replicas != nil && *req.Replicas > 0 && slices.ContainsFunc(req.NetworkInterfaces, func(n platform.CreateInstanceRequestNetworkInterface) bool { + return n.Name != nil || n.TapName != nil || n.Ip != nil || n.Mac != nil + }) { + return nil, fmt.Errorf("--replicas cannot be used with a --network that sets name, tap-name, ip or mac, because each replica needs its own") + } + if autostart { req.TimeoutS = new(int64(-1)) } @@ -1508,6 +1570,9 @@ func (Instance) Create(ctx context.Context, fields []model.Field) ([]model.Model log.G(ctx).Trace().Msg("creating instance") resp, err := timeouts.TryWithFallback(ctx, req, c.CreateInstance) if _, err = timeouts.Tolerate(ctx, resp, err, "instance create did not complete in time"); err != nil { + if len(req.NetworkInterfaces) > 0 && strings.Contains(err.Error(), "Unknown member 'network_interfaces'") { + return nil, fmt.Errorf("%w\nhint: --network needs the net_manager permission, contact support to enable it", err) + } return nil, err } if resp == nil || resp.Data == nil || len(resp.Data.Instances) == 0 { diff --git a/internal/cmd/instances_test.go b/internal/cmd/instances_test.go index 7c7462e3..50a29158 100644 --- a/internal/cmd/instances_test.go +++ b/internal/cmd/instances_test.go @@ -16,6 +16,7 @@ import ( "unikraft.com/cli/internal/cmd" "unikraft.com/cli/internal/mirror" + "unikraft.com/cli/internal/model/patch" ) func TestInstancePluginUnmarshalText(t *testing.T) { @@ -187,3 +188,59 @@ func TestInstanceRomName(t *testing.T) { }) } } + +// TestInstanceCreateRejectsNetworks checks that create stops a bad --network +// before it sends a request. +func TestInstanceCreateRejectsNetworks(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + spec map[string][]string + wantErr string + }{ + { + name: "relay without a target in the json form", + spec: map[string][]string{ + "image": {"nginx:latest"}, + "networks": {`[{"relay":{"dns":false}}]`}, + }, + wantErr: "relay requires relay.name or relay.uuid", + }, + { + name: "null interface in the json form", + spec: map[string][]string{ + "image": {"nginx:latest"}, + "networks": {`[null]`}, + }, + wantErr: "network interface cannot be null", + }, + { + name: "replicas with a named interface", + spec: map[string][]string{ + "image": {"nginx:latest"}, + "networks": {"name=my-relay"}, + "replicas": {"2"}, + }, + wantErr: "--replicas cannot be used with a --network", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + fields, err := cmd.Instance{}.Fields(t.Context()) + require.NoError(t, err) + + patched, err := patch.PatchedFields(t.Context(), fields, patch.PatchSpec{ + Create: true, + Set: tt.spec, + }) + require.NoError(t, err) + + _, err = cmd.Instance{}.Create(t.Context(), patched) + require.ErrorContains(t, err, tt.wantErr) + }) + } +} diff --git a/internal/cmd/marshal_test.go b/internal/cmd/marshal_test.go index f405427f..018762fe 100644 --- a/internal/cmd/marshal_test.go +++ b/internal/cmd/marshal_test.go @@ -99,6 +99,36 @@ func TestJSONRoundTrip(t *testing.T) { wantObject: &cmd.InstancePlugin{Name: "logger", Image: "plugins/logger:latest", Config: `{"level":"debug"}`}, wantText: &cmd.InstancePlugin{Name: "logger", Image: "plugins/logger:latest", Config: `{"level":"debug"}`}, }, + { + name: "InstanceNetwork", + object: `{"name":"eth1","mac":"aa:bb:cc:dd:ee:ff","tap-name":"tap0","ip":"10.0.0.5/24","autoconfig":false}`, + text: `"name=eth1,tap-name=tap0"`, + into: func() any { return &cmd.InstanceNetwork{} }, + wantObject: &cmd.InstanceNetwork{ + Name: "eth1", + MAC: "aa:bb:cc:dd:ee:ff", + TapName: "tap0", + IP: "10.0.0.5/24", + Autoconfig: new(false), + }, + wantText: &cmd.InstanceNetwork{Name: "eth1", TapName: "tap0"}, + marshalsToObject: true, + }, + { + name: "InstanceNetworkRelay", + object: `{"relay":{"name":"my-router-eth0","uuid":"9f8e-7d6c","dns":false}}`, + text: `"relay.name=my-router-eth0"`, + into: func() any { return &cmd.InstanceNetwork{} }, + wantObject: &cmd.InstanceNetwork{ + Relay: &cmd.InstanceNetworkRelay{ + Name: "my-router-eth0", + UUID: "9f8e-7d6c", + DNS: new(false), + }, + }, + wantText: &cmd.InstanceNetwork{Relay: &cmd.InstanceNetworkRelay{Name: "my-router-eth0"}}, + marshalsToObject: true, + }, { name: "InstanceScaleToZero", object: `{"policy":"on","stateful":true,"cooldown-time":500,"notify-time":100}`, @@ -397,6 +427,80 @@ func TestEditPatches(t *testing.T) { } } +// TestNetworkCreatePatches checks that each --network value sets one network +// interface, and that the read-only keys cannot be set. +func TestNetworkCreatePatches(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + spec map[string][]string + want []*cmd.InstanceNetwork + wantErr string + }{ + { + name: "dns without a relay target rejected", + spec: map[string][]string{"networks": {"name=eth1,relay.dns=false"}}, + wantErr: "relay requires relay.name or relay.uuid", + }, + { + name: "repeated builds one interface each", + spec: map[string][]string{"networks": { + "relay.name=my-router-eth0", + "name=eth1,tap-name=tap0,ip=10.0.0.5/24,mac=aa:bb:cc:dd:ee:ff,autoconfig=false", + }}, + want: []*cmd.InstanceNetwork{ + {Relay: &cmd.InstanceNetworkRelay{Name: "my-router-eth0"}}, + { + Name: "eth1", + TapName: "tap0", + IP: "10.0.0.5/24", + MAC: "aa:bb:cc:dd:ee:ff", + Autoconfig: new(false), + }, + }, + }, + { + name: "read-only keys rejected", + spec: map[string][]string{"networks": {"uuid=abc"}}, + wantErr: "unknown fields: [uuid]", + }, + { + name: "the json form cannot set read-only keys either", + spec: map[string][]string{"networks": {`[{"name":"eth1","uuid":"abc","private-ip":"10.0.0.5"}]`}}, + want: []*cmd.InstanceNetwork{{Name: "eth1"}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + fields, err := cmd.Instance{}.Fields(t.Context()) + require.NoError(t, err) + + patched, err := patch.PatchedFields(t.Context(), fields, patch.PatchSpec{ + Create: true, + Set: tt.spec, + }) + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + return + } + require.NoError(t, err) + + var got []*cmd.InstanceNetwork + for path, f := range model.IterFields(patched) { + if path.String() != "networks" || f.Create == nil { + continue + } + got = f.Create.Set.([]*cmd.InstanceNetwork) + } + assert.Equal(t, tt.want, got) + }) + } +} + // TestLinkCollection guards cross-resource links on list fields. // resource.FieldsFromStruct harvests the resource.Link interface only from // ANONYMOUS struct fields, so a list element must stay a struct embedding diff --git a/internal/cmd/output_test.go b/internal/cmd/output_test.go index ab343d7e..fd329ed7 100644 --- a/internal/cmd/output_test.go +++ b/internal/cmd/output_test.go @@ -139,7 +139,7 @@ func instancesOutputTests(t *testing.T) { sample.Resources.Memory = 256 sample.Resources.VCPUs = 2 sample.PrivateFQDN = "my-instance.internal" - sample.Networks = []cmd.InstanceNetwork{ + sample.Networks = []*cmd.InstanceNetwork{ { Name: "my-instance-eth0", UUID: "net-uuid-1234", diff --git a/internal/cmd/testdata/TestOutput/instances b/internal/cmd/testdata/TestOutput/instances index 7ce7756c..c6629fb7 100644 --- a/internal/cmd/testdata/TestOutput/instances +++ b/internal/cmd/testdata/TestOutput/instances @@ -843,6 +843,11 @@ fra my-instance running nginx ["arg1", "arg2"] 256MiB 2 example.uni ], "verbosity": "long" }, + { + "name": "ip", + "value": "", + "verbosity": "invisible" + }, { "name": "autoconfig", "value": null, @@ -916,6 +921,11 @@ fra my-instance running nginx ["arg1", "arg2"] 256MiB 2 example.uni ], "verbosity": "long" }, + { + "name": "ip", + "value": "", + "verbosity": "invisible" + }, { "name": "autoconfig", "value": null, @@ -990,6 +1000,11 @@ fra my-instance running nginx ["arg1", "arg2"] 256MiB 2 example.uni ], "verbosity": "long" }, + { + "name": "ip", + "value": "", + "verbosity": "invisible" + }, { "name": "autoconfig", "value": null, @@ -998,7 +1013,25 @@ fra my-instance running nginx ["arg1", "arg2"] 256MiB 2 example.uni ], "verbosity": "long" }, - "verbosity": "long" + "verbosity": "long", + "create": { + "set": [ + { + "name": "my-instance-eth0", + "mac": "aa:bb:cc:dd:ee:ff", + "relay": { + "name": "my-router-eth0", + "uuid": "net-uuid-5678", + "dns": true + } + }, + { + "name": "my-instance-eth1", + "mac": "aa:bb:cc:dd:ee:00", + "tap-name": "tap0" + } + ] + } }, { "name": "gpus", diff --git a/internal/cmd/testdata/TestOutput/instances-http-oci b/internal/cmd/testdata/TestOutput/instances-http-oci index bfa8ea3a..6fbfeb28 100644 --- a/internal/cmd/testdata/TestOutput/instances-http-oci +++ b/internal/cmd/testdata/TestOutput/instances-http-oci @@ -495,6 +495,11 @@ fra my-instance running https+oci://cdn.example.com/me/app/@sha256:43d3d758 ], "verbosity": "long" }, + { + "name": "ip", + "value": "", + "verbosity": "invisible" + }, { "name": "autoconfig", "value": null, @@ -503,7 +508,10 @@ fra my-instance running https+oci://cdn.example.com/me/app/@sha256:43d3d758 ], "verbosity": "long" }, - "verbosity": "long" + "verbosity": "long", + "create": { + "set": null + } }, { "name": "gpus", From 583092ec1dee619340e0234498b1b9ddf8167614 Mon Sep 17 00:00:00 2001 From: Justin Chadwell Date: Mon, 31 Aug 2026 15:27:15 +0100 Subject: [PATCH 22/35] test(integration): Cover instance relays No test creates instances that relay through another instance. Create a router with a named interface, and clients that relay through it by name and by UUID, with and without DNS. Check that a missing relay and a relay chain fail. Signed-off-by: Justin Chadwell Signed-off-by: Cezar Craciunoiu --- cmd/unikraft/integration/instance_test.go | 47 +++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/cmd/unikraft/integration/instance_test.go b/cmd/unikraft/integration/instance_test.go index 7a9c4b34..354c0b81 100644 --- a/cmd/unikraft/integration/instance_test.go +++ b/cmd/unikraft/integration/instance_test.go @@ -254,6 +254,53 @@ func TestInstances(t *testing.T) { r.Run(t, []string{"unikraft", "instance", "delete", "test-" + instName}) }) + t.Run("create-relay", func(t *testing.T) { + r := runner(t, true, []string{staging, stable}) + routerName, clientName, optOutName, byUUIDName := uniq(), uniq(), uniq(), uniq() + + iface := "test-" + routerName + "-eth0" + create := func(name string, opts ...string) []string { + return append([]string{ + "unikraft", "instance", "create", + "--name", "test-" + name, + "--metro", r.MetroName, + "--image", "nginx:latest", + "--memory", "128", + "--vcpus", "1", + "--set", "autostart=false", + }, opts...) + } + + r.Run(t, create(routerName, "--network", "name="+iface, "--output", "quiet")) + + out := r.Run(t, create(clientName, "--network", "relay.name="+iface)) + assert.Regexp(t, `relay:`, out) + assert.Regexp(t, `name:\s+`+regexp.QuoteMeta(iface), out) + assert.Regexp(t, `dns:\s+true`, out) + assert.Regexp(t, `(?m)name:\s+`+regexp.QuoteMeta("test-"+routerName)+`$`, out) + + out = r.Run(t, create(optOutName, "--network", "relay.name="+iface+",relay.dns=false")) + assert.Regexp(t, `dns:\s+false`, out) + + ifaceUUID := strings.TrimSpace(r.Run(t, []string{ + "unikraft", "instance", "get", "test-" + routerName, + "--output", "template={{ (index .networks 0).uuid }}", + })) + require.NotEmpty(t, ifaceUUID) + out = r.Run(t, create(byUUIDName, "--network", "relay.uuid="+ifaceUUID)) + assert.Regexp(t, `uuid:\s+`+regexp.QuoteMeta(ifaceUUID), out) + + out = r.Run(t, create(uniq(), "--network", "relay.name=test-"+routerName+"-nonexistent"), integ.ExpectFail()) + assert.Regexp(t, `Invalid relay`, out) + + r.Run(t, []string{"unikraft", "instance", "delete", "test-" + clientName, "test-" + optOutName, "test-" + byUUIDName}) + + require.Eventually(t, func() bool { + _, err := r.RunRaw(t, []string{"unikraft", "instance", "delete", "test-" + routerName}, integ.WithoutCancel()) + return err == nil + }, 2*time.Minute, 5*time.Second, "relay target never became deletable") + }) + t.Run("create-oom", func(t *testing.T) { // TODO: Add 'stable' back when it runs platform version 13. Older // versions send a duplicate "status" member that breaks every wait. From e6e7c6a0e133977cd912b4e6eeb50550605be6c2 Mon Sep 17 00:00:00 2001 From: aabedraba Date: Fri, 6 Nov 2026 08:06:11 +0100 Subject: [PATCH 23/35] feat(config): Use the Linux config path on macOS On macOS, the config file is in ~/Library/Application Support. That path has a space in it, so it is hard to type in a shell. So macOS now uses the same rule as Linux: $XDG_CONFIG_HOME/unikraft, or ~/.config/unikraft when XDG_CONFIG_HOME is not set or is relative. If only the old file exists, the CLI continues to use it. Windows does not change. Signed-off-by: aabedraba --- README.md | 7 ++-- internal/config/config.go | 37 +++++++++++++++-- internal/config/config_test.go | 76 ++++++++++++++++++++++++++++++++++ 3 files changed, 113 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index cba94ea7..cef4ebc5 100644 --- a/README.md +++ b/README.md @@ -247,8 +247,7 @@ Or, if you need to directly specify a metro endpoint, you can manually create a profile: ```yaml -# Linux: ~/.config/unikraft/config.yaml -# MacOS: ~/Library/Application\ Support/unikraft/config.yaml +# Linux and macOS: ~/.config/unikraft/config.yaml # Windows: %AppData%\unikraft\config.yaml profile: default profiles: @@ -362,7 +361,9 @@ unikraft build . --output my-org/my-app:latest ## Configuration -The CLI stores configuration in `~/.config/unikraft/config.yaml` (or the path specified by `UNIKRAFT_CONFIG`). +The CLI stores configuration in `~/.config/unikraft/config.yaml` on Linux and macOS, and in `%AppData%\unikraft\config.yaml` on Windows. +On Linux and macOS, if `XDG_CONFIG_HOME` is set, the CLI uses `$XDG_CONFIG_HOME/unikraft/config.yaml`. +If `UNIKRAFT_CONFIG` is set, the CLI uses that path. ### Environment Variables diff --git a/internal/config/config.go b/internal/config/config.go index 72170dd6..dd5788cd 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -12,6 +12,7 @@ import ( "maps" "os" "path/filepath" + "runtime" jujuerrors "github.com/juju/errors" "sigs.k8s.io/yaml" @@ -38,12 +39,40 @@ func ConfigFilePath() (string, error) { if path, ok := os.LookupEnv("UNIKRAFT_CONFIG"); ok { return path, nil } + return defaultConfigFilePath(runtime.GOOS) +} - userConfigDir, err := os.UserConfigDir() - if err != nil { - return "", jujuerrors.Annotate(err, "getting user config dir") +func defaultConfigFilePath(goos string) (string, error) { + if goos == "windows" { + dir, err := os.UserConfigDir() + if err != nil { + return "", jujuerrors.Annotate(err, "getting user config dir") + } + return filepath.Join(dir, "unikraft", defaultConfigFilename), nil + } + + home, homeErr := os.UserHomeDir() + dir := os.Getenv("XDG_CONFIG_HOME") + if !filepath.IsAbs(dir) { + if homeErr != nil { + return "", jujuerrors.Annotate(homeErr, "getting user home dir") + } + dir = filepath.Join(home, ".config") + } + path := filepath.Join(dir, "unikraft", defaultConfigFilename) + + // Existing macOS installs keep their file in Application Support. Use that + // file until one exists at the new path, so that no user loses a config. + legacy := filepath.Join(home, "Library", "Application Support", "unikraft", defaultConfigFilename) + if goos == "darwin" && homeErr == nil && !fileExists(path) && fileExists(legacy) { + return legacy, nil } - return filepath.Join(userConfigDir, "unikraft", defaultConfigFilename), nil + return path, nil +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil } // Save the current configuration to the configuration file. diff --git a/internal/config/config_test.go b/internal/config/config_test.go index cd796e4d..69eecae7 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -471,3 +471,79 @@ profiles: assert.Equal(t, "https://api.was.unikraft.cloud", direct.URL()) assert.Equal(t, "index.was.unikraft.cloud", direct.Index().Host) } + +func TestDefaultConfigFilePath(t *testing.T) { + tests := []struct { + name string + goos string + xdg bool + noHome bool + existing []string + expected string + }{ + { + name: "macOS uses the Linux path", + goos: "darwin", + expected: ".config/unikraft/config.yaml", + }, + { + name: "macOS uses XDG_CONFIG_HOME", + goos: "darwin", + xdg: true, + expected: "xdg/unikraft/config.yaml", + }, + { + name: "XDG_CONFIG_HOME does not need HOME", + goos: "darwin", + xdg: true, + noHome: true, + expected: "xdg/unikraft/config.yaml", + }, + { + name: "macOS keeps a legacy file", + goos: "darwin", + existing: []string{"Library/Application Support/unikraft/config.yaml"}, + expected: "Library/Application Support/unikraft/config.yaml", + }, + { + name: "macOS prefers the new file to a legacy file", + goos: "darwin", + existing: []string{ + "Library/Application Support/unikraft/config.yaml", + ".config/unikraft/config.yaml", + }, + expected: ".config/unikraft/config.yaml", + }, + { + name: "Linux ignores a legacy file", + goos: "linux", + existing: []string{"Library/Application Support/unikraft/config.yaml"}, + expected: ".config/unikraft/config.yaml", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Setenv("XDG_CONFIG_HOME", "") + if tt.xdg { + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg")) + } + if tt.noHome { + t.Setenv("HOME", "") + t.Setenv("USERPROFILE", "") + } + for _, f := range tt.existing { + path := filepath.Join(home, filepath.FromSlash(f)) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, nil, 0o600)) + } + + path, err := defaultConfigFilePath(tt.goos) + require.NoError(t, err) + assert.Equal(t, filepath.Join(home, filepath.FromSlash(tt.expected)), path) + }) + } +} From c7e84172853a35046eacf12b4d2e6d7829644a47 Mon Sep 17 00:00:00 2001 From: Dragos Gheorghioiu Date: Thu, 5 Nov 2026 14:33:34 +0200 Subject: [PATCH 24/35] feat(mcp): Add an MCP server command AI assistants could only drive Unikraft Cloud by shelling out to the CLI and parsing its text output. Add `unikraft mcp`. It serves typed list, create, edit and delete tools for each resource, instance control and logs, sandbox exec, read and write, and the docs as resources, over stdio or HTTP(S) with the current profile's credentials. Signed-off-by: Dragos Gheorghioiu --- README.md | 44 +++ cmd/unikraft/help_test.go | 8 + cmd/unikraft/integration/helpers_test.go | 76 +---- cmd/unikraft/integration/mcp_test.go | 52 +++ cmd/unikraft/testdata/TestHelp/general | 8 + cmd/unikraft/testdata/TestHelp/mcp | 106 ++++++ go.mod | 8 +- go.sum | 20 +- internal/branding/branding.go | 14 + internal/cmd/mcp.go | 301 +++++++++++++++++ internal/cmd/mcp_test.go | 139 ++++++++ internal/cmd/mcp_tools.go | 148 ++++++++ internal/cmd/root.go | 47 ++- internal/integration/env.go | 127 +++++-- internal/mcp/instances.go | 110 ++++++ internal/mcp/instances_test.go | 109 ++++++ internal/mcp/instructions.go | 40 +++ internal/mcp/sandbox.go | 162 +++++++++ internal/mcp/sandbox_test.go | 122 +++++++ internal/mcpserver/docs.go | 171 ++++++++++ internal/mcpserver/docs_test.go | 154 +++++++++ internal/mcpserver/http.go | 95 ++++++ internal/mcpserver/http_test.go | 93 ++++++ internal/mcpserver/mcptest/session.go | 129 +++++++ internal/mcpserver/server.go | 77 +++++ internal/model/mcp/options.go | 47 +++ internal/model/mcp/schema.go | 409 +++++++++++++++++++++++ internal/model/mcp/schema_test.go | 142 ++++++++ internal/model/mcp/tools.go | 381 +++++++++++++++++++++ internal/model/mcp/tools_test.go | 222 ++++++++++++ internal/w/README.md | 17 +- internal/w/jsonschema/jsonschema.go | 41 +++ internal/w/jsonschema/jsonschema_test.go | 32 ++ 33 files changed, 3511 insertions(+), 140 deletions(-) create mode 100644 cmd/unikraft/integration/mcp_test.go create mode 100644 cmd/unikraft/testdata/TestHelp/mcp create mode 100644 internal/branding/branding.go create mode 100644 internal/cmd/mcp.go create mode 100644 internal/cmd/mcp_test.go create mode 100644 internal/cmd/mcp_tools.go create mode 100644 internal/mcp/instances.go create mode 100644 internal/mcp/instances_test.go create mode 100644 internal/mcp/instructions.go create mode 100644 internal/mcp/sandbox.go create mode 100644 internal/mcp/sandbox_test.go create mode 100644 internal/mcpserver/docs.go create mode 100644 internal/mcpserver/docs_test.go create mode 100644 internal/mcpserver/http.go create mode 100644 internal/mcpserver/http_test.go create mode 100644 internal/mcpserver/mcptest/session.go create mode 100644 internal/mcpserver/server.go create mode 100644 internal/model/mcp/options.go create mode 100644 internal/model/mcp/schema.go create mode 100644 internal/model/mcp/schema_test.go create mode 100644 internal/model/mcp/tools.go create mode 100644 internal/model/mcp/tools_test.go create mode 100644 internal/w/jsonschema/jsonschema.go create mode 100644 internal/w/jsonschema/jsonschema_test.go diff --git a/README.md b/README.md index cba94ea7..6f981a93 100644 --- a/README.md +++ b/README.md @@ -296,6 +296,7 @@ unikraft instances logs my-instance | `run` | Run an image as an instance | | `build` | Build a Unikraft project into a VM image | | `tui` | Browse resources in a TUI | +| `mcp` | Serve Unikraft Cloud to AI assistants over the Model Context Protocol | | `metros` | List available metro locations | | `instances` | Manage instances (list, get, create, edit, delete, logs, start, stop, restart) | | `volumes` | Manage persistent volumes (list, get, create, edit, delete, clone) | @@ -374,6 +375,7 @@ The CLI stores configuration in `~/.config/unikraft/config.yaml` (or the path sp | `UNIKRAFT_LOG_LEVEL` | Set log level (`trace`, `debug`, `info`, `warn`, `error`, `fatal`) | | `UNIKRAFT_LOG_TYPE` | Set output format (`text`, `json`) | | `UNIKRAFT_TELEMETRY` | Enable/disable usage analytics (`true`, `false`) | +| `UNIKRAFT_DOCS_URL` | Documentation site that `unikraft mcp` serves as resources; empty serves none | | `UKC_TOKEN` | API token for legacy profiles / `UNIKRAFT_PROFILE_ENV` | | `UKC_METRO` | Metro name (e.g. `fra`) or full endpoint URL for legacy profiles / `UNIKRAFT_PROFILE_ENV` | | `UKC_ALLOW_INSECURE` | Skip TLS verification for the metro from `UKC_METRO` (`true`, `false`) | @@ -411,6 +413,48 @@ unikraft instances get my-instance --fields name,state,image unikraft instances get my-instance -v ``` +## MCP Server + +`unikraft mcp` lets AI assistants such as Claude Code, Claude Desktop or Cursor manage Unikraft Cloud through the [Model Context Protocol](https://modelcontextprotocol.io). +Every resource type gets typed tools such as `instance_list` or `volume_create`, instances also get `instance_control` and `instance_logs`, and sandboxes get `sandbox_exec`, `sandbox_read` and `sandbox_write`. +The tools use the current profile, and the documentation is served as `unikraft://docs` resources. + +Register the server with a client as a stdio server: + +```json +{"mcpServers": {"unikraft": {"command": "unikraft", "args": ["mcp"]}}} +``` + +With Claude Code: + +```sh +claude mcp add unikraft -- unikraft mcp +``` + +Serve fewer tools to keep the assistant's context small, or only the ones that cannot change anything: + +```sh +# Only the instance, volume and metro tools +unikraft mcp --resources instance,volume,metro + +# Only list, image_get, instance_logs and sandbox_read +unikraft mcp --read-only +``` + +Pass `--listen` to serve over HTTP at `/mcp` instead, and `--tls-cert` with `--tls-key` for HTTPS. +Clients then register the server by URL: + +```sh +unikraft mcp --listen 127.0.0.1:8080 +``` + +```json +{"mcpServers": {"unikraft": {"type": "http", "url": "http://127.0.0.1:8080/mcp"}}} +``` + +Only loopback addresses are accepted unless `--allow-remote` is given. +Every client acts with the profile's credentials, so put an authenticating proxy in front of a server that others can reach. + ## Shell Completion Generate completion scripts for your shell: diff --git a/cmd/unikraft/help_test.go b/cmd/unikraft/help_test.go index 49fe713c..d18dfdee 100644 --- a/cmd/unikraft/help_test.go +++ b/cmd/unikraft/help_test.go @@ -45,6 +45,7 @@ func TestHelp(t *testing.T) { run("run", runHelpTests) run("config", configHelpTests) run("api", apiHelpTests) + run("mcp", mcpHelpTests) } // TestVersion checks that `unikraft version` output contains expected fields. @@ -235,6 +236,13 @@ func apiHelpTests(t *testing.T, unikraftPath string) { ) } +func mcpHelpTests(t *testing.T, unikraftPath string) { + r := integ.NewTestEnv(t, unikraftPath) + integ.Gild(t, cli(r), + []string{"unikraft", "mcp", "--help"}, + ) +} + // cli returns a callback that runs a CLI command and formats its output for // golden comparison. It tolerates command failures and records the exit code. func cli(env *integ.TestEnv) func(*testing.T, []string) string { diff --git a/cmd/unikraft/integration/helpers_test.go b/cmd/unikraft/integration/helpers_test.go index 19214fa7..1fb8e5ce 100644 --- a/cmd/unikraft/integration/helpers_test.go +++ b/cmd/unikraft/integration/helpers_test.go @@ -6,24 +6,11 @@ package integration import ( - "context" - "crypto/rand" - "encoding/hex" - "fmt" "os" - "path/filepath" - "sync/atomic" "testing" - "github.com/mitchellh/copystructure" - "github.com/stretchr/testify/require" - - "unikraft.com/x/log" - "unikraft.com/cli/internal/cmd" - "unikraft.com/cli/internal/config" integ "unikraft.com/cli/internal/integration" - "unikraft.com/cli/internal/model" ) func TestMain(m *testing.M) { @@ -32,11 +19,6 @@ func TestMain(m *testing.M) { os.Exit(code) } -var ( - uniqSeed string - uniqCounter atomic.Uint64 -) - // Servers const ( staging = "staging" @@ -44,17 +26,8 @@ const ( prod = "prod" ) -func init() { - var b [3]byte - if _, err := rand.Read(b[:]); err != nil { - panic(err) - } - uniqSeed = hex.EncodeToString(b[:]) -} - func uniq() string { - n := uniqCounter.Add(1) - return fmt.Sprintf("%s%06x", uniqSeed, n) + return integ.Uniq() } func runner(t *testing.T, online bool, servers []string) *integ.TestEnv { @@ -62,50 +35,5 @@ func runner(t *testing.T, online bool, servers []string) *integ.TestEnv { integ.SkipUnlessIntegration(t) integ.RequireSupportedServer(t, servers) t.Parallel() - - unikraftPath := integ.BuildUnikraft(t) - - baseCfg, err := integ.LoadRemote(t) - require.NoError(t, err) - - if online && baseCfg == nil { - t.Skip("online test requires config, but no config found") - } - - configPath := filepath.Join(t.TempDir(), "config.yaml") - var testCfg *integ.Remote - if baseCfg != nil { - cloned, err := copystructure.Copy(baseCfg) - require.NoError(t, err) - testCfg = cloned.(*integ.Remote) - testCfg.Config.Path = configPath - require.NoError(t, testCfg.Config.Save()) - } - - ctx := t.Context() - logger, err := log.New(ctx, log.Config{Sink: t.Output(), Type: log.TextType, Level: log.TraceLevel}) - require.NoError(t, err) - ctx = log.WithLogger(ctx, logger) - - partitionPath := filepath.Join(t.TempDir(), "partition.json") - t.Cleanup(func() { - ctx := ctx - if testCfg != nil { - ctx = config.WithConfig(ctx, testCfg.Config) - } - - if _, statErr := os.Stat(partitionPath); os.IsNotExist(statErr) { - return - } - - partition, err := model.LoadPartition(partitionPath, cmd.PartitionedResources...) - require.NoError(t, err) - require.NotNil(t, partition) - - require.NoError(t, partition.Teardown(context.WithoutCancel(ctx))) - }) - - return integ.NewTestEnv(t, unikraftPath). - WithRemote(testCfg, configPath). - WithPartitionPath(partitionPath) + return integ.NewEnv(t, online, cmd.PartitionedResources...) } diff --git a/cmd/unikraft/integration/mcp_test.go b/cmd/unikraft/integration/mcp_test.go new file mode 100644 index 00000000..8be2136c --- /dev/null +++ b/cmd/unikraft/integration/mcp_test.go @@ -0,0 +1,52 @@ +// 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 ( + "context" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + integ "unikraft.com/cli/internal/integration" + "unikraft.com/cli/internal/mcpserver/mcptest" +) + +func TestMCP(t *testing.T) { + t.Run("sandbox", func(t *testing.T) { + r := runner(t, true, []string{staging}) + image := integ.Busybox.Build(t, r) + session := mcptest.Connect(t, &mcp.CommandTransport{ + Command: r.Command(context.WithoutCancel(t.Context()), []string{"unikraft", "mcp", "--docs-url="}), + }) + call := func(tool string, args map[string]any) string { + t.Helper() + res := mcptest.CallTool(t, t.Context(), session, tool, args) + require.False(t, res.IsError, mcptest.Text(res)) + return mcptest.Text(res) + } + + name := "test-" + uniq() + path := "/tmp/mcp.txt" + call("instance_create", map[string]any{"set": map[string]any{ + "name": name, + "metro": r.MetroName, + "image": image, + "runtime.args": "tail -f /dev/null", + "plugins": []string{"name=" + sandboxPlugin + ",rom=" + sandboxPluginRom}, + "resources.memory": "512MiB", + "resources.vcpus": 1, + }}) + r.Run(t, []string{"unikraft", "--timeout", "60s", "instance", "wait", "--until", "state==running", name}, integ.WithNoPartition()) + + call("sandbox_write", map[string]any{"key": name, "path": path, "content": "hello from mcp"}) + assert.Contains(t, call("sandbox_exec", map[string]any{"key": name, "command": []string{"cat", path}}), "hello from mcp") + assert.Equal(t, "hello from mcp", call("sandbox_read", map[string]any{"key": name, "path": path})) + call("instance_delete", map[string]any{"keys": []string{name}}) + }) +} diff --git a/cmd/unikraft/testdata/TestHelp/general b/cmd/unikraft/testdata/TestHelp/general index e1f0c49a..b37c9133 100644 --- a/cmd/unikraft/testdata/TestHelp/general +++ b/cmd/unikraft/testdata/TestHelp/general @@ -40,6 +40,8 @@ Resources: Utilities: api Make an authenticated HTTP request to the Unikraft Cloud API. + mcp + Serve Unikraft Cloud resources to AI assistants as an MCP server over stdio or HTTP. completion Outputs shell code for initialising tab completions. version, ver, v @@ -144,6 +146,8 @@ Resources: Utilities: api Make an authenticated HTTP request to the Unikraft Cloud API. + mcp + Serve Unikraft Cloud resources to AI assistants as an MCP server over stdio or HTTP. completion Outputs shell code for initialising tab completions. version, ver, v @@ -250,6 +254,8 @@ Resources: Utilities: api Make an authenticated HTTP request to the Unikraft Cloud API. + mcp + Serve Unikraft Cloud resources to AI assistants as an MCP server over stdio or HTTP. completion Outputs shell code for initialising tab completions. version, ver, v @@ -353,6 +359,8 @@ Resources: Utilities: api Make an authenticated HTTP request to the Unikraft Cloud API. + mcp + Serve Unikraft Cloud resources to AI assistants as an MCP server over stdio or HTTP. completion Outputs shell code for initialising tab completions. version, ver, v diff --git a/cmd/unikraft/testdata/TestHelp/mcp b/cmd/unikraft/testdata/TestHelp/mcp new file mode 100644 index 00000000..4839e293 --- /dev/null +++ b/cmd/unikraft/testdata/TestHelp/mcp @@ -0,0 +1,106 @@ +$ unikraft mcp --help + +The server speaks the Model Context Protocol over stdin and stdout, or over +HTTP with `--listen`, so an AI assistant such as Claude Code, Claude Desktop +or Cursor can manage Unikraft Cloud through typed tools instead of shelling +out to the CLI. + +Every resource type gets list, create, edit and delete tools, named like +`instance_list` or `volume_create`. A list tool also fetches specific resources +when given keys. Instances additionally get `instance_control` for start, +stop, restart and suspend, and `instance_logs`. Sandboxes get `sandbox_exec`, +`sandbox_read` and `sandbox_write` for the commands and files inside them. +The tools use the current profile and fan out across its metros exactly like +the matching CLI commands do. Every tool call reads the configuration file +again, so `unikraft login` and `unikraft profile use` take effect without +restarting the server. Unless `--profile` pins one, `profile_use` lists the +profiles and switches between them. + +Pass `--resources` with a comma-separated list of resource types to serve +only their tools, such as `--resources instance,volume,metro`. The names +are the prefixes of the tool names. A shorter tool list costs an assistant +less context on every turn. + +The documentation is served as resources: `unikraft://docs` is the table of +contents and `unikraft://docs/` a page, such as +`unikraft://docs/cli/fields`. +Pages are fetched from the documentation site when read, so they cost +nothing until an assistant asks for one. + +Diagnostics always go to stderr, because stdout carries the protocol in stdio +mode. Raise them with `--log-level debug`. A `--timeout` ends the whole +session when it expires. + +Register the server with a client as a stdio server, for example: + + {"mcpServers": {"unikraft": {"command": "unikraft", "args": ["mcp"]}}} + +With `--listen host:port` the same tools are served over HTTP at `/mcp`, with +a liveness check at `/healthz`, still using this profile's credentials. Pass +`--tls-cert` and `--tls-key` to serve HTTPS instead. Only loopback addresses +are accepted unless `--allow-remote` is given. TLS encrypts the traffic but +does not authenticate clients, so then put an authenticating proxy in front, +since every client acts as you. A `--timeout` then stops accepting requests +and drains the calls in flight. Clients register the server by URL: + + {"mcpServers": {"unikraft": {"type": "http", "url": +"http://127.0.0.1:8080/mcp"}}} + +Usage: + unikraft mcp [flags] + +Examples: + # Serve every tool over stdio + unikraft mcp + + # Serve only the tools that cannot change anything + unikraft mcp --read-only + + # Serve only the instance, volume and metro tools + unikraft mcp --resources instance,volume,metro + + # Serve another profile with debug logs on stderr + unikraft mcp --profile staging --log-level debug + + # Serve over HTTP on the loopback interface + unikraft mcp --listen 127.0.0.1:8080 + + # Serve HTTPS on every interface with a certificate + unikraft mcp --listen :8443 --allow-remote --tls-cert cert.pem --tls-key + key.pem + + # Serve every interface on an instance whose service terminates TLS + unikraft mcp --listen :8080 --allow-remote + +Flags: + --read-only + Expose only the tools that cannot change anything: list, image_get, instance_logs and sandbox_read. + --resources= ... + Serve only the tools of these resource types. The names are the prefixes of the tool names. Defaults to every type. + --listen= + Serve the protocol over HTTP at this address, at /mcp, instead of stdio. Loopback addresses only unless --allow-remote is set. + --allow-remote + Let --listen bind a non-loopback address. Every client then acts with this profile's credentials, so only do this behind an authenticating proxy or on a private network. + --tls-cert= + Serve --listen over HTTPS with this PEM certificate chain. + --tls-key= + PEM private key of --tls-cert. + +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/go.mod b/go.mod index e413d112..744eb027 100644 --- a/go.mod +++ b/go.mod @@ -24,11 +24,13 @@ require ( github.com/docker/go-units v0.5.0 github.com/ettle/strcase v0.2.0 github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 + github.com/google/jsonschema-go v0.4.3 github.com/google/uuid v1.6.0 github.com/jotaen/kong-completion v0.0.14 github.com/juju/errors v1.0.0 github.com/mitchellh/copystructure v1.2.0 github.com/moby/buildkit v0.31.1 + github.com/modelcontextprotocol/go-sdk v1.8.0 github.com/opencontainers/go-digest v1.0.0 github.com/opencontainers/image-spec v1.1.1 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c @@ -62,7 +64,7 @@ require ( unikraft.com/x/image-spec v0.0.0-20261020121654-15b23b7bb4a5 unikraft.com/x/io v0.0.0-20261013095502-1607ff4bc624 unikraft.com/x/joinerrgroup v0.0.0-20260304162956-523940cab1de - unikraft.com/x/kingkong v0.0.0-20261007115714-ef931437a206 + unikraft.com/x/kingkong v0.0.0-20261105154614-c73c61ee8f7b 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 @@ -157,6 +159,8 @@ require ( github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 // indirect github.com/secure-systems-lab/go-securesystemslib v0.11.0 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect github.com/shibumi/go-pathspec v1.3.0 // indirect github.com/shirou/gopsutil/v3 v3.24.5 // indirect github.com/shoenig/go-m1cpu v0.2.2 // indirect @@ -177,6 +181,7 @@ require ( github.com/tonistiigi/vt100 v0.0.0-20240514184818-90bafcd6abab // indirect github.com/unikraft/go-cpio v0.0.0-20260415131742-4f1984ca41f3 // indirect github.com/xo/terminfo v1.0.0 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.71.0 // indirect @@ -196,6 +201,7 @@ require ( golang.org/x/crypto v0.57.0 // indirect golang.org/x/exp v0.0.0-20260603202125-055de637280b // indirect golang.org/x/net v0.59.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/term v0.47.0 // indirect golang.org/x/time v0.16.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260825221802-da73d73af1c5 // indirect diff --git a/go.sum b/go.sum index ee0ea450..623e15e9 100644 --- a/go.sum +++ b/go.sum @@ -162,6 +162,8 @@ github.com/gofrs/flock v0.13.1 h1:jjREztyBeSKBZYAC+mgc1laB+xsgy4kYMf3FbKF2UBo= github.com/gofrs/flock v0.13.1/go.mod h1:sf4BFiHwnvgxa25DlQoDqXQnwRMEOwqxRq37P6MzzmE= github.com/gofrs/uuid/v5 v5.5.1 h1:z1Ce19/JwNidXpy3tOQc3241lnJLKdKyq/xlNvlD4Ng= github.com/gofrs/uuid/v5 v5.5.1/go.mod h1:bbAA98EoIlxyRHIVg6ektCSsZ5n8mSbwgEhvhMYlZgg= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= @@ -169,6 +171,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-containerregistry v0.21.6 h1:T+yqQIlJXKrM98Om4DlW3GoWQAmhZuLMwoDOvVrtiUM= github.com/google/go-containerregistry v0.21.6/go.mod h1:U7MMSBIJynke2MVQrQk19NP9k/uQsGz/h0amIFSHMbo= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -251,6 +255,8 @@ github.com/moby/sys/user v0.4.1 h1:RgjRlaDKi/Xmyrz4t8lyzXT6v2ooFeO/7xtchmhVWE0= github.com/moby/sys/user v0.4.1/go.mod h1:E9QsW5WRe1kUAf7kW8hXKwu1uhsZEAdPLYHYSDudF4Y= github.com/moby/sys/userns v0.2.1 h1:4OvdM7BcPkASbuouHsbW3aeMJSFlYDldBRnXVZhaRk8= github.com/moby/sys/userns v0.2.1/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/modelcontextprotocol/go-sdk v1.8.0 h1:KIvahhYqwtbeniWVPs3TcXEA7b8jEtwfBpOTAI+Urx4= +github.com/modelcontextprotocol/go-sdk v1.8.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts= github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ= github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= @@ -312,6 +318,10 @@ github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPO github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg= github.com/secure-systems-lab/go-securesystemslib v0.11.0 h1:iuCR9kcMFD4QurdKrGvPLoKZLv9YvwPYVr0473BdtFs= github.com/secure-systems-lab/go-securesystemslib v0.11.0/go.mod h1:+PMOTjUGwHj2vcZ+TFKlb1tXRbrdWE1LYDT5i9JC80Q= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/shibumi/go-pathspec v1.3.0 h1:QUyMZhFo0Md5B8zV8x2tesohbb5kfbpTi9rBnKh5dkI= @@ -385,6 +395,8 @@ github.com/xo/terminfo v1.0.0 h1:2ZpYzqWzyyytjk3TP6aJVDhkMAkc99/1xKQdA3TDTBY= github.com/xo/terminfo v1.0.0/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= @@ -432,6 +444,8 @@ golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -445,6 +459,8 @@ golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= +golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= @@ -501,8 +517,8 @@ unikraft.com/x/io v0.0.0-20261013095502-1607ff4bc624 h1:aNS+lX92H5pBhMo3srxyX1iQ unikraft.com/x/io v0.0.0-20261013095502-1607ff4bc624/go.mod h1:kIUpmhaVEiZdxrTYAGIcythxPbPdVzAvuH4KEn8T1rE= unikraft.com/x/joinerrgroup v0.0.0-20260304162956-523940cab1de h1:cm4FnPvnahRIK0derbI+T4ds1LsD5CFeyyAvIqcOCek= unikraft.com/x/joinerrgroup v0.0.0-20260304162956-523940cab1de/go.mod h1:XND1VvLxwqKFGrmdwUTWps4WEpMm7HTHPQg9HWQtrxg= -unikraft.com/x/kingkong v0.0.0-20261007115714-ef931437a206 h1:Og2CF77IGSDzZtHfNR7CQCvg7h3pSwPzU88sYoFPvNM= -unikraft.com/x/kingkong v0.0.0-20261007115714-ef931437a206/go.mod h1:K6lAaiU4KpjVWXdfvRGb1Ti24n1U4VijEIgitkgkuvM= +unikraft.com/x/kingkong v0.0.0-20261105154614-c73c61ee8f7b h1:p4zDPtyeXvWmNoan/p1wWGshyqz3LF6PQCA1BUw5hds= +unikraft.com/x/kingkong v0.0.0-20261105154614-c73c61ee8f7b/go.mod h1:8AqzGcZd3QGhw8RMsN20MUi8ZORGLzwVuqbtvPcs4uk= unikraft.com/x/kraftfile v0.0.0-20261020121654-15b23b7bb4a5 h1:n18wLb1hfHp7BKAmi6c4oKN3rhvqG2PwIaI6KGsDS9Y= unikraft.com/x/kraftfile v0.0.0-20261020121654-15b23b7bb4a5/go.mod h1:eFKmrOcQcvRRpXO6S6JvVUuw7VgJQXZFxRrj/uJdtRM= unikraft.com/x/log v0.0.0-20261007115714-ef931437a206 h1:A8mOWakAYfEPWcdXmoQabDEJn0c7Q/xIz6gEcgzrWfY= diff --git a/internal/branding/branding.go b/internal/branding/branding.go new file mode 100644 index 00000000..a89011e9 --- /dev/null +++ b/internal/branding/branding.go @@ -0,0 +1,14 @@ +// 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 branding + +const ( + Name = "unikraft" + Product = "Unikraft Cloud" + WebsiteURL = "https://unikraft.com" + DocsURL = "https://unikraft.com/docs/" + IssuesURL = "https://github.com/unikraft-cloud/cli/issues" +) diff --git a/internal/cmd/mcp.go b/internal/cmd/mcp.go new file mode 100644 index 00000000..68ba9d69 --- /dev/null +++ b/internal/cmd/mcp.go @@ -0,0 +1,301 @@ +// 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" + "io" + "log/slog" + "net" + "net/netip" + "slices" + "strconv" + "strings" + + "github.com/MakeNowJust/heredoc" + sdk "github.com/modelcontextprotocol/go-sdk/mcp" + + "unikraft.com/x/kingkong" + "unikraft.com/x/log" + "unikraft.com/x/version" + + "unikraft.com/cli/internal/branding" + "unikraft.com/cli/internal/config" + "unikraft.com/cli/internal/mcp" + "unikraft.com/cli/internal/mcpserver" + "unikraft.com/cli/internal/model" + modelmcp "unikraft.com/cli/internal/model/mcp" +) + +const instanceEditNote = "Edits to tags, delete-lock, autokill, scale-to-zero and sched-priority apply at once, even while the instance runs. Other edits to a running instance are queued until it next stops. Adding plugins, roms or volumes takes effect after instance_control suspend followed by start. Changing image, runtime.args, runtime.env, resources.memory, resources.vcpus or annotations, replacing or removing plugins or roms, and removing volumes take effect only after instance_control restart, or stop followed by start. An instance in standby keeps a snapshot of its memory, so those changes stay queued until a stop, which discards the snapshot." + +var mcpResourceTypes = []string{ + "certificate", + "image", + "instance", + "instance_checkpoint", + "instance_template", + "metro", + "profile", + "sandbox", + "service", + "volume", + "volume_template", +} + +const imageGetDescription = "Inspect images by reference, resolving each one from its registry, so public images such as nginx:latest work as well as your own. image_list only covers the images already in your account. Returns one JSON object per image." + +type MCPCmd struct { + ReadOnly bool `help:"Expose only the tools that cannot change anything: list, image_get, instance_logs and sandbox_read."` + Resources []string `name:"resources" placeholder:"type,..." help:"Serve only the tools of these resource types. The names are the prefixes of the tool names. Defaults to every type."` + DocsURL string `name:"docs-url" env:"UNIKRAFT_DOCS_URL" default:"${docs_url}" placeholder:"url" hidden:"" help:"Documentation site served as resources. Pass an empty value to serve none."` + Listen string `name:"listen" placeholder:"host:port" help:"Serve the protocol over HTTP at this address, at /mcp, instead of stdio. Loopback addresses only unless --allow-remote is set."` + AllowRemote bool `name:"allow-remote" help:"Let --listen bind a non-loopback address. Every client then acts with this profile's credentials, so only do this behind an authenticating proxy or on a private network."` + TLSCert string `name:"tls-cert" and:"tls" type:"path" placeholder:"file" help:"Serve --listen over HTTPS with this PEM certificate chain."` + TLSKey string `name:"tls-key" and:"tls" type:"path" placeholder:"file" help:"PEM private key of --tls-cert."` +} + +func (MCPCmd) Help() string { + return heredoc.Docf(` + The server speaks the Model Context Protocol over stdin and stdout, or over + HTTP with %[1]s--listen%[1]s, so an AI assistant such as Claude Code, Claude Desktop + or Cursor can manage Unikraft Cloud through typed tools instead of shelling + out to the CLI. + + Every resource type gets list, create, edit and delete tools, named like + %[1]sinstance_list%[1]s or %[1]svolume_create%[1]s. A list tool also fetches specific resources + when given keys. Instances additionally get %[1]sinstance_control%[1]s for start, + stop, restart and suspend, and %[1]sinstance_logs%[1]s. Sandboxes get %[1]ssandbox_exec%[1]s, + %[1]ssandbox_read%[1]s and %[1]ssandbox_write%[1]s for the commands and files inside them. + The tools use the current profile and fan out across its metros exactly like + the matching CLI commands do. Every tool call reads the configuration file + again, so %[1]sunikraft login%[1]s and %[1]sunikraft profile use%[1]s take effect without + restarting the server. Unless %[1]s--profile%[1]s pins one, %[1]sprofile_use%[1]s lists the + profiles and switches between them. + + Pass %[1]s--resources%[1]s with a comma-separated list of resource types to serve + only their tools, such as %[1]s--resources instance,volume,metro%[1]s. The names + are the prefixes of the tool names. A shorter tool list costs an assistant + less context on every turn. + + The documentation is served as resources: %[1]sunikraft://docs%[1]s is the table of + contents and %[1]sunikraft://docs/%[1]s a page, such as %[1]sunikraft://docs/cli/fields%[1]s. + Pages are fetched from the documentation site when read, so they cost + nothing until an assistant asks for one. + + Diagnostics always go to stderr, because stdout carries the protocol in stdio + mode. Raise them with %[1]s--log-level debug%[1]s. A %[1]s--timeout%[1]s ends the whole + session when it expires. + + Register the server with a client as a stdio server, for example: + + {"mcpServers": {"unikraft": {"command": "unikraft", "args": ["mcp"]}}} + + With %[1]s--listen host:port%[1]s the same tools are served over HTTP at %[1]s/mcp%[1]s, with + a liveness check at %[1]s/healthz%[1]s, still using this profile's credentials. Pass + %[1]s--tls-cert%[1]s and %[1]s--tls-key%[1]s to serve HTTPS instead. Only loopback addresses + are accepted unless %[1]s--allow-remote%[1]s is given. TLS encrypts the traffic but + does not authenticate clients, so then put an authenticating proxy in front, + since every client acts as you. A %[1]s--timeout%[1]s then stops accepting requests + and drains the calls in flight. Clients register the server by URL: + + {"mcpServers": {"unikraft": {"type": "http", "url": "http://127.0.0.1:8080/mcp"}}} + `, "`") +} + +func (MCPCmd) Examples() []kingkong.Example { + return []kingkong.Example{ + { + Description: "Serve every tool over stdio", + Commands: []string{ + "unikraft mcp", + }, + }, + { + Description: "Serve only the tools that cannot change anything", + Commands: []string{ + "unikraft mcp --read-only", + }, + }, + { + Description: "Serve only the instance, volume and metro tools", + Commands: []string{ + "unikraft mcp --resources instance,volume,metro", + }, + }, + { + Description: "Serve another profile with debug logs on stderr", + Commands: []string{ + "unikraft mcp --profile staging --log-level debug", + }, + }, + { + Description: "Serve over HTTP on the loopback interface", + Commands: []string{ + "unikraft mcp --listen 127.0.0.1:8080", + }, + }, + { + Description: "Serve HTTPS on every interface with a certificate", + Commands: []string{ + "unikraft mcp --listen :8443 --allow-remote --tls-cert cert.pem --tls-key key.pem", + }, + }, + { + Description: "Serve every interface on an instance whose service terminates TLS", + Commands: []string{ + "unikraft mcp --listen :8080 --allow-remote", + }, + }, + } +} + +func (c *MCPCmd) Run(ctx context.Context, stdio config.Stdio, partition *model.Partition) error { + loopback := true + if c.Listen != "" { + host, port, err := net.SplitHostPort(c.Listen) + if err != nil { + return fmt.Errorf("invalid listen address %q: %w", c.Listen, err) + } + if _, err := strconv.ParseUint(port, 10, 16); err != nil { + return fmt.Errorf("invalid listen address %q: port must be a number from 0 to 65535", c.Listen) + } + if ip, err := netip.ParseAddr(host); err == nil { + loopback = ip.IsLoopback() + } else { + loopback = host == "localhost" + } + if !loopback && !c.AllowRemote { + return fmt.Errorf("--listen %s binds a non-loopback address and the server has no authentication, so every client would act with this profile's credentials; pass --allow-remote to accept that", c.Listen) + } + } + + srv, err := newMCPServer(ctx, stdio.Stderr, partition, *c) + if err != nil { + return err + } + + if c.Listen == "" { + log.G(ctx).Info().Bool("read-only", c.ReadOnly).Msg("serving MCP over stdio") + err = srv.Run(ctx) + } else { + if !loopback { + log.G(ctx).Warn().Str("listen", c.Listen).Msg("serving MCP without authentication on a non-loopback address: every client acts with this profile's credentials") + } + log.G(ctx).Info().Bool("read-only", c.ReadOnly).Str("listen", c.Listen).Bool("tls", c.TLSCert != "").Msg("serving MCP over HTTP") + err = srv.ListenAndServe(ctx, c.Listen) + } + if err != nil && !errors.Is(err, context.Canceled) { + return err + } + return nil +} + +func newMCPServer(ctx context.Context, stderr io.Writer, partition *model.Partition, c MCPCmd) (*mcpserver.Server, error) { + if err := checkResources(c.Resources); err != nil { + return nil, err + } + var logger *slog.Logger + if log.G(ctx).GetLevel() <= log.DebugLevel { + logger = slog.New(slog.NewTextHandler(stderr, &slog.HandlerOptions{Level: slog.LevelDebug})) + } + srv, err := mcpserver.New(mcpserver.Options{ + Name: branding.Name, + Title: branding.Product, + WebsiteURL: branding.WebsiteURL, + Version: version.Version, + Instructions: mcp.Instructions(c.ReadOnly, c.Resources, c.DocsURL, mcpserver.DocsURI(branding.Name)), + Logger: logger, + DocsURL: c.DocsURL, + TLSCertFile: c.TLSCert, + TLSKeyFile: c.TLSKey, + }) + if err != nil { + return nil, err + } + srv.MCP().AddReceivingMiddleware(reloadConfig) + tools := modelmcp.New(srv.MCP(), modelmcp.Options{ + Partition: partition, + ReadOnly: c.ReadOnly, + Resources: c.Resources, + Stderr: stderr, + }) + if err := errors.Join( + tools.AddListTool[Instance](ctx), + tools.AddCreateTool[Instance](ctx, + modelmcp.WithCreateDefault("autostart", true), + modelmcp.WithCreateExample("image", "metro", "service.services", "resources.memory"), + ), + tools.AddEditTool[Instance](ctx, modelmcp.WithEditNote(instanceEditNote)), + tools.AddDeleteTool[Instance](ctx), + tools.AddListTool[InstanceTemplate](ctx), + tools.AddCreateTool[InstanceTemplate](ctx), + tools.AddEditTool[InstanceTemplate](ctx), + tools.AddDeleteTool[InstanceTemplate](ctx), + tools.AddListTool[InstanceCheckpoint](ctx), + tools.AddCreateTool[InstanceCheckpoint](ctx), + tools.AddEditTool[InstanceCheckpoint](ctx), + tools.AddDeleteTool[InstanceCheckpoint](ctx), + tools.AddListTool[Volume](ctx), + tools.AddCreateTool[Volume](ctx, modelmcp.WithCreateExample("metro", "size", "access-mode", "filesystem")), + tools.AddEditTool[Volume](ctx), + tools.AddDeleteTool[Volume](ctx), + tools.AddListTool[VolumeTemplate](ctx), + tools.AddCreateTool[VolumeTemplate](ctx), + tools.AddEditTool[VolumeTemplate](ctx), + tools.AddDeleteTool[VolumeTemplate](ctx), + tools.AddListTool[ServiceGroup](ctx), + tools.AddCreateTool[ServiceGroup](ctx, modelmcp.WithCreateExample("metro", "domains", "autokill")), + tools.AddEditTool[ServiceGroup](ctx), + tools.AddDeleteTool[ServiceGroup](ctx), + tools.AddListTool[Certificate](ctx), + tools.AddCreateTool[Certificate](ctx), + tools.AddDeleteTool[Certificate](ctx), + tools.AddListTool[ImageEntry](ctx), + tools.AddGetTool[Image](ctx, imageGetDescription), + tools.AddDeleteTool[Image](ctx), + tools.AddListTool[Metro](ctx), + addProfileUseTool(ctx, tools, partition), + mcp.AddInstanceTools(tools, mcpInstanceCommands{}), + mcp.AddSandboxTools(tools, mcpSandboxCommands{partition: partition}), + ); err != nil { + return nil, err + } + return srv, nil +} + +func reloadConfig(next sdk.MethodHandler) sdk.MethodHandler { + return func(ctx context.Context, method string, req sdk.Request) (sdk.Result, error) { + if _, ok := req.(*sdk.CallToolRequest); ok { + started := config.G(ctx) + profile, _ := started.OverriddenCurrentProfile() + cfg, err := loadConfig(started.Path, profile) + if err != nil { + var res sdk.CallToolResult + res.SetError(err) + return &res, nil + } + ctx = config.WithConfig(ctx, cfg) + } + return next(ctx, method, req) + } +} + +func checkResources(names []string) error { + var unknown []string + for _, name := range names { + if !slices.Contains(mcpResourceTypes, modelmcp.NormalizeName(name)) { + unknown = append(unknown, name) + } + } + if len(unknown) == 0 { + return nil + } + return fmt.Errorf("--resources: unknown resource types: %s; known: %s", + strings.Join(unknown, ", "), strings.Join(mcpResourceTypes, ", ")) +} diff --git a/internal/cmd/mcp_test.go b/internal/cmd/mcp_test.go new file mode 100644 index 00000000..e00368e9 --- /dev/null +++ b/internal/cmd/mcp_test.go @@ -0,0 +1,139 @@ +// 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 ( + "io" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "unikraft.com/cli/internal/config" + "unikraft.com/cli/internal/mcpserver/mcptest" +) + +func TestMCPServerTools(t *testing.T) { + ctx := config.WithConfig(t.Context(), &config.Config{}) + + readOnlyTools := []string{ + "instance_list", "instance_template_list", "instance_checkpoint_list", + "volume_list", "volume_template_list", "service_list", "certificate_list", + "image_list", "image_get", "metro_list", "instance_logs", "sandbox_read", + } + mutatingTools := []string{ + "instance_create", "instance_edit", "instance_delete", "instance_control", + "instance_template_create", "instance_template_edit", "instance_template_delete", + "instance_checkpoint_create", "instance_checkpoint_edit", "instance_checkpoint_delete", + "volume_create", "volume_edit", "volume_delete", + "volume_template_create", "volume_template_edit", "volume_template_delete", + "service_create", "service_edit", "service_delete", + "certificate_create", "certificate_delete", "image_delete", + "sandbox_exec", "sandbox_write", "profile_use", + } + + srv, err := newMCPServer(ctx, io.Discard, nil, MCPCmd{}) + require.NoError(t, err) + assert.ElementsMatch(t, append(readOnlyTools, mutatingTools...), mcptest.ConnectServer(t, ctx, srv.MCP()).ToolNames()) + + srv, err = newMCPServer(ctx, io.Discard, nil, MCPCmd{ReadOnly: true}) + require.NoError(t, err) + assert.ElementsMatch(t, readOnlyTools, mcptest.ConnectServer(t, ctx, srv.MCP()).ToolNames()) +} + +func TestMCPServerResources(t *testing.T) { + ctx := config.WithConfig(t.Context(), &config.Config{}) + + srv, err := newMCPServer(ctx, io.Discard, nil, MCPCmd{Resources: []string{"instance", "metro"}}) + require.NoError(t, err) + session := mcptest.ConnectServer(t, ctx, srv.MCP()) + assert.ElementsMatch(t, []string{ + "instance_list", "instance_create", "instance_edit", "instance_delete", "instance_control", "instance_logs", "metro_list", + }, session.ToolNames()) + assert.Contains(t, session.Instructions, "Only the tools of these resource types are served: instance, metro.") + + _, err = newMCPServer(ctx, io.Discard, nil, MCPCmd{Resources: []string{"instance", "nope"}}) + assert.EqualError(t, err, "--resources: unknown resource types: nope; known: certificate, image, instance, instance_checkpoint, instance_template, metro, profile, sandbox, service, volume, volume_template") +} + +func TestMCPResourceTypes(t *testing.T) { + ctx := config.WithConfig(t.Context(), &config.Config{}) + + srv, err := newMCPServer(ctx, io.Discard, nil, MCPCmd{}) + require.NoError(t, err) + for _, tool := range mcptest.ConnectServer(t, ctx, srv.MCP()).ToolNames() { + assert.Truef(t, slices.ContainsFunc(mcpResourceTypes, func(typ string) bool { + return strings.HasPrefix(tool, typ+"_") + }), "tool %s belongs to no type in mcpResourceTypes", tool) + } + + for _, typ := range mcpResourceTypes { + srv, err := newMCPServer(ctx, io.Discard, nil, MCPCmd{Resources: []string{typ}}) + require.NoError(t, err) + assert.NotEmptyf(t, mcptest.ConnectServer(t, ctx, srv.MCP()).ToolNames(), "type %s serves no tools", typ) + } +} + +func TestMCPProfileUse(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + writeConfig := func(profile string) { + require.NoError(t, os.WriteFile(path, []byte("profile: "+profile+` +profiles: + a: {type: cloud, token: a, metros: [{name: fra, endpoint: https://api.fra.unikraft.cloud}]} + b: {type: cloud, token: b, metros: [{name: was, endpoint: https://api.was.unikraft.cloud}]} +`), 0o600)) + } + writeConfig("a") + cfg, err := loadConfig(path, "") + require.NoError(t, err) + ctx := config.WithConfig(t.Context(), cfg) + srv, err := newMCPServer(ctx, io.Discard, nil, MCPCmd{}) + require.NoError(t, err) + session := mcptest.ConnectServer(t, ctx, srv.MCP()) + + active := func(args map[string]any) string { + res := mcptest.CallTool(t, ctx, session, "profile_use", args) + require.False(t, res.IsError, mcptest.Text(res)) + var names []string + for _, profile := range mcptest.Resources(t, res) { + if profile["active"] == true { + names = append(names, profile["name"].(string)) + } + } + require.Len(t, names, 1) + return names[0] + } + + writeConfig("b") + assert.Equal(t, "b", active(map[string]any{})) + assert.Equal(t, "a", active(map[string]any{"name": "a"})) + assert.Equal(t, "a", active(map[string]any{})) + + require.NoError(t, os.WriteFile(path, []byte("profiles: ["), 0o600)) + res := mcptest.CallTool(t, ctx, session, "profile_use", map[string]any{}) + assert.True(t, res.IsError) + assert.Contains(t, mcptest.Text(res), "loading config") + + writeConfig("a") + cfg, err = loadConfig(path, "b") + require.NoError(t, err) + pinned := config.WithConfig(t.Context(), cfg) + srv, err = newMCPServer(pinned, io.Discard, nil, MCPCmd{}) + require.NoError(t, err) + session = mcptest.ConnectServer(t, pinned, srv.MCP()) + assert.NotContains(t, session.ToolNames(), "profile_use") + res = mcptest.CallTool(t, pinned, session, "metro_list", map[string]any{"fields": []string{"name"}}) + assert.Equal(t, []map[string]any{{"name": "was"}}, mcptest.Resources(t, res)) + + t.Setenv("UNIKRAFT_PROFILE_ENV", "1") + srv, err = newMCPServer(ctx, io.Discard, nil, MCPCmd{}) + require.NoError(t, err) + assert.NotContains(t, mcptest.ConnectServer(t, ctx, srv.MCP()).ToolNames(), "profile_use") +} diff --git a/internal/cmd/mcp_tools.go b/internal/cmd/mcp_tools.go new file mode 100644 index 00000000..fd497a9f --- /dev/null +++ b/internal/cmd/mcp_tools.go @@ -0,0 +1,148 @@ +// 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" + "fmt" + "path" + "strings" + + sdk "github.com/modelcontextprotocol/go-sdk/mcp" + + "unikraft.com/cloud/sdk/plugins/sandbox" + "unikraft.com/x/shell" + + "unikraft.com/cli/internal/config" + "unikraft.com/cli/internal/mcp" + "unikraft.com/cli/internal/model" + "unikraft.com/cli/internal/model/cmd" + modelmcp "unikraft.com/cli/internal/model/mcp" + "unikraft.com/cli/internal/types" +) + +// mcpInstanceCommands runs the MCP instance tools with the CLI's own commands. +type mcpInstanceCommands struct{} + +func (mcpInstanceCommands) Start(ctx context.Context, stdio config.Stdio, instances []string) error { + return (&InstancesStartCmd{Targets: instances, FormatOpts: modelmcp.JSONFormat()}).Run(ctx, stdio) +} + +func (mcpInstanceCommands) Stop(ctx context.Context, stdio config.Stdio, instances []string, opts mcp.InstanceStopOptions) error { + return (&InstancesStopCmd{ + Targets: instances, + Force: opts.Force, + DrainTimeout: opts.DrainTimeout, + FormatOpts: modelmcp.JSONFormat(), + }).Run(ctx, stdio) +} + +func (mcpInstanceCommands) Restart(ctx context.Context, stdio config.Stdio, instances []string, opts mcp.InstanceStopOptions) error { + return (&InstancesRestartCmd{ + Targets: instances, + Force: opts.Force, + DrainTimeout: opts.DrainTimeout, + FormatOpts: modelmcp.JSONFormat(), + }).Run(ctx, stdio) +} + +func (mcpInstanceCommands) Suspend(ctx context.Context, stdio config.Stdio, instances []string, drainTimeout types.DurationMS) error { + return (&InstancesSuspendCmd{ + Targets: instances, + DrainTimeout: drainTimeout, + FormatOpts: modelmcp.JSONFormat(), + }).Run(ctx, stdio) +} + +func (mcpInstanceCommands) Logs(ctx context.Context, stdio config.Stdio, instance string, tail int) error { + return (&InstancesLogsCmd{Targets: []string{instance}, Tail: new(tail)}).Run(ctx, stdio) +} + +type mcpProfileUseArgs struct { + Name string `json:"name,omitzero" jsonschema:"Profile to switch to. Leave it out to only list the profiles."` +} + +func addProfileUseTool(ctx context.Context, tools *modelmcp.Tools, partition *model.Partition) error { + cfg := config.G(ctx) + if _, pinned := cfg.OverriddenCurrentProfile(); pinned || cfg.CurrentProfileName() == config.EnvProfile { + return nil + } + return tools.AddTool("profile", &sdk.Tool{ + Name: "profile_use", + Title: "Use a profile", + Description: "Without name, list the profiles. With name, switch every tool to that profile. Switching sets the default profile in the configuration file like unikraft profile use, so it also applies to the CLI and to other servers started without --profile. Returns the profiles; active marks the one in use.", + Annotations: modelmcp.MutatingAnnotations(), + }, true, func(ctx context.Context, in mcpProfileUseArgs, stdio config.Stdio) error { + if in.Name != "" { + if err := (&UseCmd{Name: in.Name}).Run(ctx, config.G(ctx)); err != nil { + return err + } + } + return (&cmd.ModelListCmd[Profile]{FormatOpts: modelmcp.JSONFormat()}).Run(ctx, stdio, partition) + }) +} + +type mcpSandboxCommands struct { + partition *model.Partition +} + +func (c mcpSandboxCommands) Exec(ctx context.Context, stdio config.Stdio, instance string, opts mcp.SandboxExecOptions) (int, error) { + target, err := c.target(ctx, instance) + if err != nil { + return 0, err + } + var run *sandbox.Cmd + if opts.Cmdline != "" { + run = target.CommandLine(ctx, opts.Cmdline) + } else { + run = target.CommandArgs(ctx, opts.Args) + } + run.Dir = opts.Dir + run.Env = opts.Env + run.Stdin = strings.NewReader(opts.Stdin) + run.Stdout = stdio.Stdout + run.Stderr = stdio.Stdout + return shell.ExitStatus(run.Run()) +} + +func (c mcpSandboxCommands) ReadFile(ctx context.Context, instance, remote string) ([]byte, error) { + target, err := c.target(ctx, instance) + if err != nil { + return nil, err + } + return target.ReadFile(ctx, remote) +} + +func (c mcpSandboxCommands) WriteFile(ctx context.Context, instance, remote string, data []byte, opts mcp.SandboxWriteOptions) error { + target, err := c.target(ctx, instance) + if err != nil { + return err + } + if opts.Parents { + if err := target.Mkdir(ctx, path.Dir(remote), true); err != nil { + return err + } + } + return target.WriteFile(ctx, remote, data, opts.Append) +} + +func (c mcpSandboxCommands) target(ctx context.Context, instance string) (sandbox.Target, error) { + inst, err := lookupInstance(ctx, c.partition, instance) + if err != nil { + return sandbox.Target{}, err + } + plugin := sandbox.PluginName + if cause := missingPlugin(inst, plugin); cause != nil { + if _, ok := pendingPlugin(inst, plugin); ok { + return sandbox.Target{}, fmt.Errorf("instance %q has plugin %q attached but not loaded yet; restart it with instance_control", inst.Name, plugin) + } + return sandbox.Target{}, fmt.Errorf("%w; attach it with instance_edit by adding plugins name=%s,image=%s, then restart the instance with instance_control", cause, plugin, defaultPluginImage) + } + if !inst.State.IsRunning() { + return sandbox.Target{}, fmt.Errorf("instance %q is not running (state: %s); start it with instance_control", inst.Name, string(inst.State)) + } + return sandboxTargetFor(ctx, inst, plugin) +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index abc2b47a..f10a8304 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -28,6 +28,7 @@ import ( xsignal "unikraft.com/x/signal" "unikraft.com/x/version" + "unikraft.com/cli/internal/branding" "unikraft.com/cli/internal/cmd/login" "unikraft.com/cli/internal/config" "unikraft.com/cli/internal/logfmt" @@ -55,6 +56,7 @@ type UnikraftCLI struct { Resources AnyResourceCmd `cmd:"" group:"cmd-resources" hidden:"" help:"Manage any Unikraft Cloud resource." aliases:"resource,resources" set:"name=resource" set:"names=resources"` API APICmd `cmd:"" group:"cmd-utilities" help:"Make an authenticated HTTP request to the Unikraft Cloud API."` + MCP MCPCmd `cmd:"" group:"cmd-utilities" help:"Serve Unikraft Cloud resources to AI assistants as an MCP server over stdio or HTTP."` Login login.LoginCmd `cmd:"" group:"cmd-config" help:"Login to Unikraft Cloud."` Logout login.LogoutCmd `cmd:"" group:"cmd-config" help:"Logout from Unikraft Cloud."` @@ -195,22 +197,9 @@ func NewRootCmd(ctx context.Context, args []string, stdio config.Stdio, signals return ctx, nil, nil, nil, jujuerrors.Annotate(err, "getting config file path") } } - cfg, err := config.Load(configPath) + cfg, err := loadConfig(configPath, cli.globalFlags.Profile) if err != nil { - return ctx, nil, nil, nil, jujuerrors.Annotate(err, "loading config") - } - if cfg == nil { - // This is the first time the configuration is being loaded (potentially a - // fresh install), so we should set the path to the default location. This - // ensures that when the user eventually saves the configuration, it will be - // saved to the expected location, even if they didn't explicitly specify - // it. - cfg = &config.Config{ - Path: configPath, - } - } - if cli.globalFlags.Profile != "" { - cfg.OverrideCurrentProfile(cli.globalFlags.Profile) + return ctx, nil, nil, nil, err } ctx = config.WithConfig(ctx, cfg) kctx.Bind(cfg) @@ -259,6 +248,27 @@ func NewRootCmd(ctx context.Context, args []string, stdio config.Stdio, signals return ctx, kctx, &cli, cleanup, nil } +func loadConfig(path, profile string) (*config.Config, error) { + cfg, err := config.Load(path) + if err != nil { + return nil, jujuerrors.Annotate(err, "loading config") + } + if cfg == nil { + // This is the first time the configuration is being loaded (potentially a + // fresh install), so we should set the path to the default location. This + // ensures that when the user eventually saves the configuration, it will be + // saved to the expected location, even if they didn't explicitly specify + // it. + cfg = &config.Config{ + Path: path, + } + } + if profile != "" { + cfg.OverrideCurrentProfile(profile) + } + return cfg, nil +} + func ctxWithLogger(ctx context.Context, out io.Writer, type_ log.Type, level log.Level) context.Context { switch level.String() { case "trace": @@ -300,13 +310,14 @@ func NewParser(cli *UnikraftCLI) (*kong.Kong, error) { } opts := []kong.Option{ - kong.Name("unikraft"), + kong.Name(branding.Name), kong.UsageOnError(), + kong.Vars{"docs_url": branding.DocsURL}, kong.Description("The Unikraft Command-Line Interface."), kingkong.DescriptionDetail("The Unikraft Command-Line Interface.\n" + " _ \n" + - " c'3'o .-. Docs: https://unikraft.com/docs/cli\n" + - " (| |)_/ Issues: https://github.com/unikraft-cloud/cli/issues\n" + + " c'3'o .-. Docs: " + branding.DocsURL + "cli\n" + + " (| |)_/ Issues: " + branding.IssuesURL + "\n" + " "), kong.ConfigureHelp(helpOptions), kong.Help(kingkong.HelpPrinter(version.Version)), diff --git a/internal/integration/env.go b/internal/integration/env.go index ff02ed40..7a22fbcb 100644 --- a/internal/integration/env.go +++ b/internal/integration/env.go @@ -8,6 +8,8 @@ package integration import ( "bytes" "context" + "crypto/rand" + "fmt" "net" "os" "os/exec" @@ -15,12 +17,17 @@ import ( "slices" "strings" "sync" + "sync/atomic" "testing" "time" "github.com/charmbracelet/x/ansi" + "github.com/mitchellh/copystructure" "github.com/stretchr/testify/require" + "unikraft.com/x/log" + + "unikraft.com/cli/internal/config" "unikraft.com/cli/internal/model" ) @@ -44,6 +51,54 @@ func NewTestEnv(t *testing.T, unikraftPath string) *TestEnv { } } +// NewEnv builds the CLI and gives an environment with its own copy of the +// test config, when one is available, and a partition whose resources are +// torn down when the test ends. With online set, the test is skipped when +// there is no config. +func NewEnv(t *testing.T, online bool, teardown ...model.Model) *TestEnv { + t.Helper() + env := NewTestEnv(t, BuildUnikraft(t)) + remote, err := LoadRemote(t) + require.NoError(t, err) + if online && remote == nil { + t.Skip("online test requires config, but no config found") + } + if remote != nil { + cloned, err := copystructure.Copy(remote) + require.NoError(t, err) + env.Remote = cloned.(*Remote) + env.Config.Path = env.configPath + require.NoError(t, env.Config.Save()) + } + + logger, err := log.New(t.Context(), log.Config{Sink: t.Output(), Type: log.TextType, Level: log.TraceLevel}) + require.NoError(t, err) + ctx := log.WithLogger(t.Context(), logger) + t.Cleanup(func() { + if env.Remote != nil { + ctx = config.WithConfig(ctx, env.Config) + } + if _, err := os.Stat(env.partitionPath); os.IsNotExist(err) { + return + } + partition, err := model.LoadPartition(env.partitionPath, teardown...) + require.NoError(t, err) + require.NotNil(t, partition) + require.NoError(t, partition.Teardown(context.WithoutCancel(ctx))) + }) + return env +} + +var ( + uniqSeed = strings.ToLower(rand.Text()[:6]) + uniqCounter atomic.Uint64 +) + +// Uniq gives a name suffix that is unique across the test binary. +func Uniq() string { + return fmt.Sprintf("%s%06x", uniqSeed, uniqCounter.Add(1)) +} + func (env *TestEnv) WithRemote(remote *Remote, configPath string) *TestEnv { env.Remote = remote env.configPath = configPath @@ -113,6 +168,39 @@ func WithNoPartition() CmdOption { return func(c *cmdConfig) { c.noPartition = true } } +// Command gives an unstarted command for args with the environment that Run +// and StartBackground give their children, and no stdout or stderr. The +// caller owns the process. +func (env *TestEnv) Command(ctx context.Context, args []string, opts ...CmdOption) *exec.Cmd { + var cfg cmdConfig + for _, opt := range opts { + opt(&cfg) + } + return env.command(ctx, args, cfg) +} + +func (env *TestEnv) command(ctx context.Context, args []string, cfg cmdConfig) *exec.Cmd { + var c *exec.Cmd + if args[0] == "unikraft" { + c = exec.CommandContext(ctx, env.unikraftPath, args[1:]...) + } else { + c = exec.CommandContext(ctx, args[0], args[1:]...) + } + c.Dir = cfg.workDir + if cfg.stdin != "" { + c.Stdin = strings.NewReader(cfg.stdin) + } + c.Env = slices.DeleteFunc(os.Environ(), func(s string) bool { + return strings.HasPrefix(s, "UNIKRAFT_") + }) + c.Env = append(c.Env, "NO_COLOR=1", "UNIKRAFT_CONFIG="+env.configPath, "BUILDKIT_PROGRESS=quiet") + c.Env = append(c.Env, cfg.env...) + if !cfg.noPartition { + c.Env = append(c.Env, model.UnikraftPartitionEnv+"="+env.partitionPath) + } + return c +} + func (env *TestEnv) RunRaw(t *testing.T, args []string, opts ...CmdOption) (string, error) { t.Helper() var cfg cmdConfig @@ -132,31 +220,10 @@ func (env *TestEnv) RunRaw(t *testing.T, args []string, opts ...CmdOption) (stri defer cancel() } - var c *exec.Cmd - if args[0] == "unikraft" { - c = exec.CommandContext(cmdCtx, env.unikraftPath, args[1:]...) - } else { - c = exec.CommandContext(cmdCtx, args[0], args[1:]...) - } - + c := env.command(cmdCtx, args, cfg) var output bytes.Buffer c.Stdout = &output c.Stderr = &output - c.Dir = cfg.workDir - if cfg.stdin != "" { - c.Stdin = strings.NewReader(cfg.stdin) - } - c.Env = os.Environ() - c.Env = slices.DeleteFunc(c.Env, func(s string) bool { - return strings.HasPrefix(s, "UNIKRAFT_") - }) - 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, model.UnikraftPartitionEnv+"="+env.partitionPath) - } err := c.Run() return ansi.Strip(output.String()), err @@ -231,21 +298,7 @@ func (env *TestEnv) StartBackground(t *testing.T, args []string, waitAddr string t.Helper() t.Logf("starting background: %s", strings.Join(args, " ")) - var c *exec.Cmd - if args[0] == "unikraft" { - c = exec.CommandContext(t.Context(), env.unikraftPath, args[1:]...) - } else { - c = exec.CommandContext(t.Context(), args[0], args[1:]...) - } - - c.Env = os.Environ() - c.Env = slices.DeleteFunc(c.Env, func(s string) bool { - return strings.HasPrefix(s, "UNIKRAFT_") - }) - 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, model.UnikraftPartitionEnv+"="+env.partitionPath) + c := env.command(t.Context(), args, cmdConfig{}) c.Cancel = func() error { return c.Process.Signal(os.Interrupt) } diff --git a/internal/mcp/instances.go b/internal/mcp/instances.go new file mode 100644 index 00000000..436aa05c --- /dev/null +++ b/internal/mcp/instances.go @@ -0,0 +1,110 @@ +// 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 mcp + +import ( + "cmp" + "context" + "errors" + "fmt" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + + "unikraft.com/cli/internal/config" + modelmcp "unikraft.com/cli/internal/model/mcp" + "unikraft.com/cli/internal/types" +) + +const ( + defaultLogTail = 200 + defaultDrainTimeout types.DurationMS = -1 +) + +// InstanceCommands runs the CLI's instance commands on behalf of the +// instance tools, writing their output to the given stdio. +type InstanceCommands interface { + Start(ctx context.Context, stdio config.Stdio, instances []string) error + Stop(ctx context.Context, stdio config.Stdio, instances []string, opts InstanceStopOptions) error + Restart(ctx context.Context, stdio config.Stdio, instances []string, opts InstanceStopOptions) error + Suspend(ctx context.Context, stdio config.Stdio, instances []string, drainTimeout types.DurationMS) error + Logs(ctx context.Context, stdio config.Stdio, instance string, tail int) error +} + +// InstanceStopOptions carries the flags shared by stop and restart; a +// negative DrainTimeout keeps the platform's default. +type InstanceStopOptions struct { + Force bool + DrainTimeout types.DurationMS +} + +type instanceControlArgs struct { + Action string `json:"action" jsonschema:"What to do with the instances."` + Keys []string `json:"keys"` + Force bool `json:"force,omitzero" jsonschema:"For stop and restart: stop immediately instead of draining connections first."` + DrainTimeoutMS *int64 `json:"drain_timeout_ms,omitzero" jsonschema:"For stop, restart and suspend: milliseconds to drain connections first. Defaults to the platform's timeout."` +} + +type instanceLogsArgs struct { + Key string `json:"key"` + Tail int `json:"tail,omitzero" jsonschema:"How many lines from the end of the log to return. Defaults to 200."` +} + +func schemaWithKey[T any](name, description string) (*jsonschema.Schema, error) { + schema, err := jsonschema.For[T](nil) + if err != nil { + return nil, err + } + schema.Properties[name].Description = description + return schema, nil +} + +func AddInstanceTools(tools *modelmcp.Tools, commands InstanceCommands) error { + control, err := schemaWithKey[instanceControlArgs]("keys", "Instances to act on, each "+modelmcp.KeyFormat+".") + if err != nil { + return err + } + logs, err := schemaWithKey[instanceLogsArgs]("key", "The instance to read logs from: "+modelmcp.KeyFormat+".") + if err != nil { + return err + } + control.Properties["action"].Enum = []any{"start", "stop", "restart", "suspend"} + return errors.Join(tools.AddTool("instance", &mcp.Tool{ + Name: "instance_logs", + Title: "Instance logs", + Description: "Read the last lines of an instance's console output; the first place to look when an instance fails to start or misbehaves. Returns plain text.", + InputSchema: logs, + Annotations: modelmcp.ReadOnlyAnnotations(), + }, false, func(ctx context.Context, in instanceLogsArgs, stdio config.Stdio) error { + return commands.Logs(ctx, stdio, in.Key, cmp.Or(in.Tail, defaultLogTail)) + }), tools.AddTool("instance", &mcp.Tool{ + Name: "instance_control", + Title: "Control instances", + Description: "Start, stop, restart or suspend instances. Stop and restart drain connections first unless force is set; a suspended instance resumes from memory on its next request. Returns the updated instances.", + InputSchema: control, + Annotations: modelmcp.MutatingAnnotations(), + }, true, func(ctx context.Context, in instanceControlArgs, stdio config.Stdio) error { + if len(in.Keys) == 0 { + return errors.New("no instances specified") + } + drain := defaultDrainTimeout + if in.DrainTimeoutMS != nil { + drain = types.DurationMS(*in.DrainTimeoutMS) + } + switch in.Action { + case "start": + return commands.Start(ctx, stdio, in.Keys) + case "stop": + return commands.Stop(ctx, stdio, in.Keys, InstanceStopOptions{Force: in.Force, DrainTimeout: drain}) + case "restart": + return commands.Restart(ctx, stdio, in.Keys, InstanceStopOptions{Force: in.Force, DrainTimeout: drain}) + case "suspend": + return commands.Suspend(ctx, stdio, in.Keys, drain) + default: + return fmt.Errorf("unknown action %q: use start, stop, restart or suspend", in.Action) + } + })) +} diff --git a/internal/mcp/instances_test.go b/internal/mcp/instances_test.go new file mode 100644 index 00000000..3d00c483 --- /dev/null +++ b/internal/mcp/instances_test.go @@ -0,0 +1,109 @@ +// 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 mcp_test + +import ( + "context" + "fmt" + "strings" + "testing" + + sdk "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "unikraft.com/cli/internal/config" + "unikraft.com/cli/internal/mcp" + "unikraft.com/cli/internal/mcpserver/mcptest" + modelmcp "unikraft.com/cli/internal/model/mcp" + "unikraft.com/cli/internal/types" +) + +type fakeInstances struct { + calls []string +} + +func (f *fakeInstances) Start(_ context.Context, stdio config.Stdio, instances []string) error { + f.calls = append(f.calls, "start "+strings.Join(instances, ",")) + _, err := fmt.Fprintf(stdio.Stdout, `[{"name": %q, "state": "running"}]`, instances[0]) + return err +} + +func (f *fakeInstances) Stop(_ context.Context, _ config.Stdio, instances []string, opts mcp.InstanceStopOptions) error { + f.calls = append(f.calls, fmt.Sprintf("stop %s force=%t drain=%d", strings.Join(instances, ","), opts.Force, opts.DrainTimeout)) + return nil +} + +func (f *fakeInstances) Restart(_ context.Context, _ config.Stdio, instances []string, opts mcp.InstanceStopOptions) error { + f.calls = append(f.calls, fmt.Sprintf("restart %s force=%t drain=%d", strings.Join(instances, ","), opts.Force, opts.DrainTimeout)) + return nil +} + +func (f *fakeInstances) Suspend(_ context.Context, _ config.Stdio, instances []string, drainTimeout types.DurationMS) error { + f.calls = append(f.calls, fmt.Sprintf("suspend %s drain=%d", strings.Join(instances, ","), drainTimeout)) + return nil +} + +func (f *fakeInstances) Logs(_ context.Context, stdio config.Stdio, instance string, tail int) error { + f.calls = append(f.calls, fmt.Sprintf("logs %s tail=%d", instance, tail)) + fmt.Fprintln(stdio.Stdout, "line 1\nline 2") + return nil +} + +func connect(t *testing.T, add func(*modelmcp.Tools) error) *mcptest.Session { + t.Helper() + srv := sdk.NewServer(&sdk.Implementation{Name: "test"}, nil) + require.NoError(t, add(modelmcp.New(srv, modelmcp.Options{}))) + return mcptest.ConnectServer(t, t.Context(), srv) +} + +func TestInstanceTools(t *testing.T) { + ctx := t.Context() + fake := &fakeInstances{} + session := connect(t, func(tools *modelmcp.Tools) error { return mcp.AddInstanceTools(tools, fake) }) + + assert.ElementsMatch(t, []string{"instance_control", "instance_logs"}, session.ToolNames()) + logs := session.Tool("instance_logs") + assert.True(t, logs.Annotations.ReadOnlyHint) + assert.True(t, strings.HasPrefix(logs.Description, "Read the last lines"), logs.Description) + control := session.Tool("instance_control") + assert.Equal(t, "Control instances", control.Title) + assert.False(t, control.Annotations.ReadOnlyHint) + assert.ElementsMatch(t, []any{"action", "keys"}, control.InputSchema.(map[string]any)["required"]) + assert.Equal(t, []any{"start", "stop", "restart", "suspend"}, mcptest.Property(t, control.InputSchema, "action")["enum"]) + assert.Equal(t, "array", mcptest.Property(t, control.InputSchema, "keys")["type"]) + assert.Equal(t, "integer", mcptest.Property(t, control.InputSchema, "drain_timeout_ms")["type"]) + + res := mcptest.CallTool(t, ctx, session, "instance_control", map[string]any{"action": "start", "keys": []string{"web"}}) + require.False(t, res.IsError, mcptest.Text(res)) + assert.Equal(t, []map[string]any{{"name": "web", "state": "running"}}, mcptest.Resources(t, res)) + + for _, args := range []map[string]any{ + {"action": "stop", "keys": []string{"web", "db"}, "force": true}, + {"action": "restart", "keys": []string{"web"}, "drain_timeout_ms": 5000}, + {"action": "suspend", "keys": []string{"web"}}, + } { + res = mcptest.CallTool(t, ctx, session, "instance_control", args) + require.False(t, res.IsError, mcptest.Text(res)) + } + + res = mcptest.CallTool(t, ctx, session, "instance_control", map[string]any{"action": "explode", "keys": []string{"web"}}) + assert.True(t, res.IsError) + assert.Contains(t, mcptest.Text(res), "explode does not equal any of") + + res = mcptest.CallTool(t, ctx, session, "instance_logs", map[string]any{"key": "web"}) + require.False(t, res.IsError, mcptest.Text(res)) + assert.Nil(t, res.StructuredContent) + assert.Contains(t, mcptest.Text(res), "line 1") + + assert.Equal(t, []string{ + "start web", + "stop web,db force=true drain=-1", + "restart web force=false drain=5000", + "suspend web drain=-1", + "logs web tail=200", + }, fake.calls) +} diff --git a/internal/mcp/instructions.go b/internal/mcp/instructions.go new file mode 100644 index 00000000..f24bea01 --- /dev/null +++ b/internal/mcp/instructions.go @@ -0,0 +1,40 @@ +// 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 mcp + +import ( + "slices" + "strings" + + modelmcp "unikraft.com/cli/internal/model/mcp" +) + +func Instructions(readOnly bool, resources []string, docsURL, docsURI string) string { + text := `Tools are named _: list, create, edit and delete for each Unikraft Cloud resource type. A list tool returns everything unless keys names specific resources, so it doubles as get. image_get inspects any image reference, including public ones. instance_control starts, stops, restarts or suspends instances and instance_logs reads their console output. instance_create starts the new instance unless autostart is set to false; do that only when the user explicitly asks for a stopped instance. + +Resource keys accept a name or a UUID, optionally prefixed with metro/ to target one metro, as in fra/my-app. Reads fan out to every metro of the current profile; narrow a list with filter entries such as metro==fra or state==running. + +Create takes a set object and edit takes set, add and del objects, all keyed by dotted field paths, exactly like the CLI's --set, --add and --del flags. Values may be strings, parsed as on the command line, or JSON values of the field's type. Pass dry_run to preview the resulting patch without applying it. + +A sandbox is an instance with the sandbox plugin loaded, so to create one call instance_create with the plugin in the same set as the image, for example {"image": "official/alpine:latest", "plugins": ["name=sandbox,image=plugins/sandbox:latest"]}. The image must run on the base-compat runtime; official/alpine:latest and official/debian-slim:latest ship the standard Linux utilities so most commands work. To keep files across restarts, put a volume such as ":/data:size=1GiB" in volumes, which creates it and attaches it at /data in the same call, and add config={"persist_path":"/data"} to the plugin entry. Plugins load at boot, so a plugin added to a running instance with instance_edit only takes effect after a restart with instance_control. Inside a running sandbox, sandbox_exec runs a command, sandbox_read returns a text file and sandbox_write creates or appends to one; interactive shells and bulk file copies stay with the unikraft instance shell and copy CLI commands. + +Results come back as JSON text and as structured content under "resources". Pass fields to limit which field paths are returned.` + if docsURL != "" { + text += "\n\nThe documentation is available as resources: read " + docsURI + " for the table of contents and " + docsURI + "/ for a page, the same content as " + strings.TrimSuffix(docsURL, "/") + "/. The CLI reference lives under " + docsURI + "/cli/unikraft." + } + if len(resources) > 0 { + names := make([]string, 0, len(resources)) + for _, name := range resources { + names = append(names, modelmcp.NormalizeName(name)) + } + slices.Sort(names) + text += "\n\nOnly the tools of these resource types are served: " + strings.Join(slices.Compact(names), ", ") + "." + } + if readOnly { + text += "\n\nThis server runs in read-only mode: only the list, image_get, instance_logs and sandbox_read tools are available." + } + return text +} diff --git a/internal/mcp/sandbox.go b/internal/mcp/sandbox.go new file mode 100644 index 00000000..fd850464 --- /dev/null +++ b/internal/mcp/sandbox.go @@ -0,0 +1,162 @@ +// 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 mcp + +import ( + "context" + "encoding/json/jsontext" + "encoding/json/v2" + "errors" + "fmt" + "time" + "unicode/utf8" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + + "unikraft.com/cli/internal/config" + modelmcp "unikraft.com/cli/internal/model/mcp" +) + +type SandboxCommands interface { + Exec(ctx context.Context, stdio config.Stdio, instance string, opts SandboxExecOptions) (int, error) + ReadFile(ctx context.Context, instance, path string) ([]byte, error) + WriteFile(ctx context.Context, instance, path string, data []byte, opts SandboxWriteOptions) error +} + +type SandboxExecOptions struct { + Args []string + Cmdline string + Dir string + Env map[string]string + Stdin string +} + +type SandboxWriteOptions struct { + Append bool + Parents bool +} + +type sandboxExecArgs struct { + Key string `json:"key"` + Command jsontext.Value `json:"command"` + Dir string `json:"dir,omitzero" jsonschema:"Directory to run the command in."` + Env map[string]string `json:"env,omitzero" jsonschema:"The whole environment the command sees, not an addition to it."` + Stdin string `json:"stdin,omitzero" jsonschema:"Text fed to the command's standard input."` + TimeoutMS int64 `json:"timeout_ms,omitzero" jsonschema:"Milliseconds to wait before the command is stopped. Defaults to the server's timeout."` +} + +type sandboxReadArgs struct { + Key string `json:"key"` + Path string `json:"path" jsonschema:"Absolute path of the file inside the sandbox."` +} + +type sandboxWriteArgs struct { + Key string `json:"key"` + Path string `json:"path" jsonschema:"Absolute path of the file inside the sandbox."` + Content string `json:"content" jsonschema:"The text to write."` + Append bool `json:"append,omitzero" jsonschema:"Append to the file instead of replacing it."` + Parents bool `json:"parents,omitzero" jsonschema:"Create the missing parent directories."` +} + +var errExecTimeout = errors.New("sandbox command timed out") + +func AddSandboxTools(tools *modelmcp.Tools, commands SandboxCommands) error { + const key = "The sandbox instance: " + modelmcp.KeyFormat + "." + exec, err := schemaWithKey[sandboxExecArgs]("key", key) + if err != nil { + return err + } + read, err := schemaWithKey[sandboxReadArgs]("key", key) + if err != nil { + return err + } + write, err := schemaWithKey[sandboxWriteArgs]("key", key) + if err != nil { + return err + } + exec.Properties["command"] = &jsonschema.Schema{ + AnyOf: []*jsonschema.Schema{{Type: "array", Items: &jsonschema.Schema{Type: "string"}}, {Type: "string"}}, + Description: "The program and its arguments as an array of strings, or one string for the shell to interpret, so that pipes and redirections work.", + } + return errors.Join( + tools.AddTool("sandbox", &mcp.Tool{ + Name: "sandbox_read", + Title: "Read sandbox file", + Description: "Read a text file from a running sandbox instance. Returns its content.", + InputSchema: read, + Annotations: modelmcp.ReadOnlyAnnotations(), + }, false, func(ctx context.Context, in sandboxReadArgs, stdio config.Stdio) error { + data, err := commands.ReadFile(ctx, in.Key, in.Path) + if err != nil { + return err + } + if !utf8.Valid(data) { + return fmt.Errorf("%s is not UTF-8 text; read it with the unikraft instance read CLI command", in.Path) + } + _, err = stdio.Stdout.Write(data) + return err + }), + tools.AddTool("sandbox", &mcp.Tool{ + Name: "sandbox_exec", + Title: "Run in sandbox", + Description: "Run a command inside a running sandbox: an instance with the sandbox plugin loaded, which instance_create makes when plugins holds the plugin. Returns the command's combined output; a non-zero exit status is reported as an error after the output.", + InputSchema: exec, + Annotations: modelmcp.MutatingAnnotations(), + }, false, func(ctx context.Context, in sandboxExecArgs, stdio config.Stdio) error { + opts := SandboxExecOptions{Dir: in.Dir, Env: in.Env, Stdin: in.Stdin} + var err error + switch in.Command.Kind() { + case '[': + err = json.Unmarshal(in.Command, &opts.Args) + case '"': + err = json.Unmarshal(in.Command, &opts.Cmdline) + default: + return errors.New("invalid command: give an array of strings or one command line") + } + if err != nil { + return fmt.Errorf("invalid command: %w", err) + } + if len(opts.Args) == 0 && opts.Cmdline == "" { + return errors.New("invalid command: it is empty") + } + timeout := time.Duration(in.TimeoutMS) * time.Millisecond + if timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeoutCause(ctx, timeout, errExecTimeout) + defer cancel() + } + code, err := commands.Exec(ctx, stdio, in.Key, opts) + if errors.Is(context.Cause(ctx), errExecTimeout) { + return fmt.Errorf("command timed out after %s", timeout) + } + if err != nil { + return err + } + if code != 0 { + return fmt.Errorf("exit status %d", code) + } + return nil + }), + tools.AddTool("sandbox", &mcp.Tool{ + Name: "sandbox_write", + Title: "Write sandbox file", + Description: "Write a text file inside a running sandbox instance, replacing it unless append is set. Returns a confirmation.", + InputSchema: write, + Annotations: modelmcp.MutatingAnnotations(), + }, false, func(ctx context.Context, in sandboxWriteArgs, stdio config.Stdio) error { + if err := commands.WriteFile(ctx, in.Key, in.Path, []byte(in.Content), SandboxWriteOptions{Append: in.Append, Parents: in.Parents}); err != nil { + return err + } + verb := "written" + if in.Append { + verb = "appended" + } + _, err := fmt.Fprintf(stdio.Stdout, "%d bytes %s to %s\n", len(in.Content), verb, in.Path) + return err + }), + ) +} diff --git a/internal/mcp/sandbox_test.go b/internal/mcp/sandbox_test.go new file mode 100644 index 00000000..cc8a3ba7 --- /dev/null +++ b/internal/mcp/sandbox_test.go @@ -0,0 +1,122 @@ +// 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 mcp_test + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "unikraft.com/cli/internal/config" + "unikraft.com/cli/internal/mcp" + "unikraft.com/cli/internal/mcpserver/mcptest" + modelmcp "unikraft.com/cli/internal/model/mcp" +) + +type fakeSandbox struct { + calls []string + data []byte +} + +func (f *fakeSandbox) Exec(ctx context.Context, stdio config.Stdio, instance string, opts mcp.SandboxExecOptions) (int, error) { + f.calls = append(f.calls, fmt.Sprintf("exec %s args=%v cmdline=%q dir=%q env=%v stdin=%q", instance, opts.Args, opts.Cmdline, opts.Dir, opts.Env, opts.Stdin)) + switch opts.Cmdline { + case "sleep": + <-ctx.Done() + return 0, ctx.Err() + case "boom": + return 0, errors.New("transport broke") + } + fmt.Fprint(stdio.Stdout, "out\n") + if opts.Cmdline == "false" { + return 1, nil + } + return 0, nil +} + +func (f *fakeSandbox) ReadFile(_ context.Context, instance, path string) ([]byte, error) { + f.calls = append(f.calls, "read "+instance+" "+path) + return f.data, nil +} + +func (f *fakeSandbox) WriteFile(_ context.Context, instance, path string, data []byte, opts mcp.SandboxWriteOptions) error { + f.calls = append(f.calls, fmt.Sprintf("write %s %s %q append=%t parents=%t", instance, path, data, opts.Append, opts.Parents)) + return nil +} + +func TestSandboxTools(t *testing.T) { + ctx := t.Context() + fake := &fakeSandbox{data: []byte("hello\n")} + session := connect(t, func(tools *modelmcp.Tools) error { return mcp.AddSandboxTools(tools, fake) }) + + assert.ElementsMatch(t, []string{"sandbox_exec", "sandbox_read", "sandbox_write"}, session.ToolNames()) + assert.True(t, session.Tool("sandbox_read").Annotations.ReadOnlyHint) + assert.False(t, session.Tool("sandbox_write").Annotations.ReadOnlyHint) + exec := session.Tool("sandbox_exec") + assert.False(t, exec.Annotations.ReadOnlyHint) + assert.Equal(t, "Run in sandbox", exec.Title) + assert.ElementsMatch(t, []any{"key", "command"}, exec.InputSchema.(map[string]any)["required"]) + assert.Len(t, mcptest.Property(t, exec.InputSchema, "command")["anyOf"], 2) + assert.Equal(t, "integer", mcptest.Property(t, exec.InputSchema, "timeout_ms")["type"]) + assert.ElementsMatch(t, []any{"key", "path", "content"}, session.Tool("sandbox_write").InputSchema.(map[string]any)["required"]) + + res := mcptest.CallTool(t, ctx, session, "sandbox_exec", map[string]any{"key": "scratch", "command": []string{"ls", "/data"}, "dir": "/", "env": map[string]string{"A": "1"}, "stdin": "in"}) + require.False(t, res.IsError, mcptest.Text(res)) + assert.Equal(t, "out\n", mcptest.Text(res)) + assert.Nil(t, res.StructuredContent) + + res = mcptest.CallTool(t, ctx, session, "sandbox_exec", map[string]any{"key": "scratch", "command": "false"}) + assert.True(t, res.IsError) + assert.Equal(t, "out\n\nexit status 1", mcptest.Text(res)) + + res = mcptest.CallTool(t, ctx, session, "sandbox_exec", map[string]any{"key": "scratch", "command": "boom"}) + assert.True(t, res.IsError) + assert.Contains(t, mcptest.Text(res), "transport broke") + + res = mcptest.CallTool(t, ctx, session, "sandbox_exec", map[string]any{"key": "scratch", "command": "sleep", "timeout_ms": 20}) + assert.True(t, res.IsError) + assert.Contains(t, mcptest.Text(res), "command timed out after 20ms") + + res = mcptest.CallTool(t, ctx, session, "sandbox_exec", map[string]any{"key": "scratch", "command": 7}) + assert.True(t, res.IsError) + assert.Contains(t, mcptest.Text(res), "validating /properties/command") + + res = mcptest.CallTool(t, ctx, session, "sandbox_exec", map[string]any{"key": "scratch", "command": []string{}}) + assert.True(t, res.IsError) + assert.Contains(t, mcptest.Text(res), "invalid command: it is empty") + + res = mcptest.CallTool(t, ctx, session, "sandbox_read", map[string]any{"key": "scratch", "path": "/data/x"}) + require.False(t, res.IsError, mcptest.Text(res)) + assert.Equal(t, "hello\n", mcptest.Text(res)) + + fake.data = []byte{0xff, 0xfe, 0x00} + res = mcptest.CallTool(t, ctx, session, "sandbox_read", map[string]any{"key": "scratch", "path": "/data/bin"}) + assert.True(t, res.IsError) + assert.Contains(t, mcptest.Text(res), "/data/bin is not UTF-8 text") + + res = mcptest.CallTool(t, ctx, session, "sandbox_write", map[string]any{"key": "scratch", "path": "/data/y", "content": "hi\n", "append": true, "parents": true}) + require.False(t, res.IsError, mcptest.Text(res)) + assert.Equal(t, "3 bytes appended to /data/y\n", mcptest.Text(res)) + + res = mcptest.CallTool(t, ctx, session, "sandbox_write", map[string]any{"key": "scratch", "path": "/data/z", "content": ""}) + require.False(t, res.IsError, mcptest.Text(res)) + assert.Equal(t, "0 bytes written to /data/z\n", mcptest.Text(res)) + + assert.Equal(t, []string{ + `exec scratch args=[ls /data] cmdline="" dir="/" env=map[A:1] stdin="in"`, + `exec scratch args=[] cmdline="false" dir="" env=map[] stdin=""`, + `exec scratch args=[] cmdline="boom" dir="" env=map[] stdin=""`, + `exec scratch args=[] cmdline="sleep" dir="" env=map[] stdin=""`, + "read scratch /data/x", + "read scratch /data/bin", + `write scratch /data/y "hi\n" append=true parents=true`, + `write scratch /data/z "" append=false parents=false`, + }, fake.calls) +} diff --git a/internal/mcpserver/docs.go b/internal/mcpserver/docs.go new file mode 100644 index 00000000..045db493 --- /dev/null +++ b/internal/mcpserver/docs.go @@ -0,0 +1,171 @@ +// 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 mcpserver + +import ( + "context" + "errors" + "fmt" + "io" + "io/fs" + "mime" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "unikraft.com/x/log" +) + +const ( + docsMIMEType = "text/markdown" + docsIndex = "llms.txt" + docsCacheTTL = 10 * time.Minute + docsMaxBytes = 8 << 20 +) + +var errDocsNotFound = errors.New("documentation page not found") + +type docs struct { + base *url.URL + uri string + title string + client *http.Client + + mu sync.Mutex + cache map[string]cachedPage +} + +type cachedPage struct { + body string + fetched time.Time +} + +func DocsURI(name string) string { + return name + "://docs" +} + +func newDocs(baseURL, uri, title string, client *http.Client) (*docs, error) { + base, err := url.Parse(baseURL) + if err != nil { + return nil, fmt.Errorf("invalid documentation URL %q: %w", baseURL, err) + } + if base.Scheme == "" || base.Host == "" { + return nil, fmt.Errorf("invalid documentation URL %q: scheme and host are required", baseURL) + } + if !strings.HasSuffix(base.Path, "/") { + base.Path += "/" + } + return &docs{ + base: base, + uri: uri, + title: title, + client: client, + cache: make(map[string]cachedPage), + }, nil +} + +func (s *Server) addDocs(d *docs) { + site := d.base.String() + s.srv.AddResource(&mcp.Resource{ + URI: d.uri, + Name: "docs", + Title: d.title + " documentation", + Description: "Table of contents of " + site + ". Every entry is a readable " + d.uri + "/ resource.", + MIMEType: docsMIMEType, + }, d.read) + s.srv.AddResourceTemplate(&mcp.ResourceTemplate{ + URITemplate: d.uri + "/{+path}", + Name: "docs-page", + Title: d.title + " documentation page", + Description: "A documentation page by path, such as " + d.uri + "/cli/fields, with the same content as " + site + ".", + MIMEType: docsMIMEType, + }, d.read) +} + +func (d *docs) read(ctx context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + uri := req.Params.URI + start := time.Now() + body, cached, err := d.lookup(ctx, uri) + log.G(ctx).Debug(). + Str("uri", uri). + Bool("cached", cached). + Dur("took", time.Since(start)). + Err(err). + Msg("resource read") + if errors.Is(err, errDocsNotFound) { + return nil, mcp.ResourceNotFoundError(uri) + } + if err != nil { + return nil, err + } + return &mcp.ReadResourceResult{ + Contents: []*mcp.ResourceContents{{URI: uri, MIMEType: docsMIMEType, Text: body}}, + }, nil +} + +func (d *docs) lookup(ctx context.Context, uri string) (string, bool, error) { + if uri == d.uri { + index, cached, err := d.fetch(ctx, docsIndex) + if err != nil { + return "", cached, err + } + return strings.ReplaceAll(index, "]("+d.base.Path, "]("+d.uri+"/"), cached, nil + } + path, ok := strings.CutPrefix(uri, d.uri+"/") + path = strings.TrimSuffix(path, ".md") + if !ok || path == "." || !fs.ValidPath(path) { + return "", false, errDocsNotFound + } + return d.fetch(ctx, path+".md") +} + +func (d *docs) fetch(ctx context.Context, rel string) (string, bool, error) { + d.mu.Lock() + cached, ok := d.cache[rel] + d.mu.Unlock() + if ok && time.Since(cached.fetched) < docsCacheTTL { + return cached.body, true, nil + } + + target := d.base.JoinPath(rel).String() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) + if err != nil { + return "", false, err + } + req.Header.Set("Accept", docsMIMEType) + resp, err := d.client.Do(req) + if err != nil { + return "", false, fmt.Errorf("fetching %s: %w", target, err) + } + defer resp.Body.Close() + + switch { + case resp.StatusCode == http.StatusNotFound: + return "", false, fmt.Errorf("%w: %s", errDocsNotFound, target) + case resp.StatusCode != http.StatusOK: + return "", false, fmt.Errorf("documentation site returned %s for %s", resp.Status, target) + } + if mediaType, _, _ := mime.ParseMediaType(resp.Header.Get("Content-Type")); mediaType != docsMIMEType && mediaType != "text/plain" { + return "", false, fmt.Errorf("documentation site returned %q instead of markdown for %s", mediaType, target) + } + data, err := io.ReadAll(io.LimitReader(resp.Body, docsMaxBytes+1)) + if err != nil { + return "", false, fmt.Errorf("reading %s: %w", target, err) + } + if len(data) > docsMaxBytes { + return "", false, fmt.Errorf("%s is larger than %d MiB", target, docsMaxBytes>>20) + } + + body := string(data) + d.mu.Lock() + d.cache[rel] = cachedPage{body: body, fetched: time.Now()} + d.mu.Unlock() + return body, false, nil +} diff --git a/internal/mcpserver/docs_test.go b/internal/mcpserver/docs_test.go new file mode 100644 index 00000000..b92a654d --- /dev/null +++ b/internal/mcpserver/docs_test.go @@ -0,0 +1,154 @@ +// 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 mcpserver_test + +import ( + "bytes" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "unikraft.com/x/log" + + "unikraft.com/cli/internal/mcpserver" + "unikraft.com/cli/internal/mcpserver/mcptest" +) + +const docsIndex = `# Documentation + +> Documentation files for Large Language Models + +## Documentation + +- [Introduction](/docs/introduction.md) +- [Fields](/docs/cli/fields.md): Display, sort, filter, and edit resource fields +- [Instances](/docs/platform/instances.md): Run unikernels +- [Autoscale](/docs/features/autoscale.md) +- [NGINX](/docs/guides/nginx.md): Deploy NGINX +- [unikraft instances](/docs/cli/unikraft/instances.md): Manage instances. +- [Escaped](/docs/../etc/passwd.md) +- not a link +` + +func newDocsSite(t *testing.T, indexStatus int) (*httptest.Server, *atomic.Int32) { + t.Helper() + var pageRequests atomic.Int32 + site := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Accept") != "text/markdown" { + w.WriteHeader(http.StatusNotAcceptable) + return + } + w.Header().Set("Content-Type", "text/markdown; charset=utf-8") + switch r.URL.Path { + case "/docs/llms.txt": + w.WriteHeader(indexStatus) + fmt.Fprint(w, docsIndex) + case "/docs/cli/fields.md": + pageRequests.Add(1) + fmt.Fprint(w, "# Fields\n\nBody.\n") + case "/docs/introduction.md": + fmt.Fprint(w, "# Introduction\n") + case "/docs/guides/nginx.md": + fmt.Fprint(w, "# NGINX\n") + case "/docs/login.md": + w.Header().Set("Content-Type", "text/html") + fmt.Fprint(w, "Sign in") + case "/docs/huge.md": + fmt.Fprint(w, strings.Repeat("a", 8<<20+1)) + default: + w.WriteHeader(http.StatusNotFound) + fmt.Fprint(w, "# Not found\n") + } + })) + t.Cleanup(site.Close) + return site, &pageRequests +} + +func TestDocsResources(t *testing.T) { + site, pageRequests := newDocsSite(t, http.StatusOK) + var logs bytes.Buffer + logger := zerolog.New(&logs).Level(zerolog.DebugLevel) + ctx := log.WithLogger(t.Context(), &logger) + session := mcptest.ConnectServer(t, ctx, newServer(t, mcpserver.Options{DocsURL: site.URL + "/docs", HTTPClient: site.Client()}).MCP()) + + templates, err := session.ListResourceTemplates(ctx, nil) + require.NoError(t, err) + require.Len(t, templates.ResourceTemplates, 1) + assert.Equal(t, "unikraft://docs/{+path}", templates.ResourceTemplates[0].URITemplate) + + listed, err := session.ListResources(ctx, nil) + require.NoError(t, err) + require.Len(t, listed.Resources, 1) + assert.Equal(t, "unikraft://docs", listed.Resources[0].URI) + + page, err := session.ReadResource(ctx, &mcp.ReadResourceParams{URI: "unikraft://docs/cli/fields"}) + require.NoError(t, err) + require.Len(t, page.Contents, 1) + assert.Equal(t, "unikraft://docs/cli/fields", page.Contents[0].URI) + assert.Equal(t, "text/markdown", page.Contents[0].MIMEType) + assert.Equal(t, "# Fields\n\nBody.\n", page.Contents[0].Text) + + _, err = session.ReadResource(ctx, &mcp.ReadResourceParams{URI: "unikraft://docs/cli/fields"}) + require.NoError(t, err) + assert.Equal(t, int32(1), pageRequests.Load()) + assert.Contains(t, logs.String(), `"uri":"unikraft://docs/cli/fields","cached":false,"took":`) + assert.Contains(t, logs.String(), `"uri":"unikraft://docs/cli/fields","cached":true,"took":`) + + toc, err := session.ReadResource(ctx, &mcp.ReadResourceParams{URI: "unikraft://docs"}) + require.NoError(t, err) + require.Len(t, toc.Contents, 1) + assert.Contains(t, toc.Contents[0].Text, "[Fields](unikraft://docs/cli/fields.md): Display") + assert.Contains(t, toc.Contents[0].Text, "[Introduction](unikraft://docs/introduction.md)") + assert.Contains(t, toc.Contents[0].Text, "[NGINX](unikraft://docs/guides/nginx.md): Deploy NGINX") + + guide, err := session.ReadResource(ctx, &mcp.ReadResourceParams{URI: "unikraft://docs/guides/nginx.md"}) + require.NoError(t, err) + assert.Equal(t, "# NGINX\n", guide.Contents[0].Text) + + _, err = session.ReadResource(ctx, &mcp.ReadResourceParams{URI: "unikraft://docs/login"}) + require.ErrorContains(t, err, `"text/html" instead of markdown`) + + _, err = session.ReadResource(ctx, &mcp.ReadResourceParams{URI: "unikraft://docs/huge"}) + require.ErrorContains(t, err, "larger than 8 MiB") + + _, err = session.ReadResource(ctx, &mcp.ReadResourceParams{URI: "unikraft://docs/missing/page"}) + require.ErrorContains(t, err, "not found") + + _, err = session.ReadResource(ctx, &mcp.ReadResourceParams{URI: "unikraft://docs/../etc/passwd"}) + require.ErrorContains(t, err, "not found") +} + +func TestDocsIndexUnavailable(t *testing.T) { + site, _ := newDocsSite(t, http.StatusInternalServerError) + ctx := t.Context() + session := mcptest.ConnectServer(t, ctx, newServer(t, mcpserver.Options{DocsURL: site.URL + "/docs/", HTTPClient: site.Client()}).MCP()) + + page, err := session.ReadResource(ctx, &mcp.ReadResourceParams{URI: "unikraft://docs/introduction"}) + require.NoError(t, err) + assert.Equal(t, "# Introduction\n", page.Contents[0].Text) + + _, err = session.ReadResource(ctx, &mcp.ReadResourceParams{URI: "unikraft://docs"}) + require.ErrorContains(t, err, "500") +} + +func TestDocsDisabledAndInvalid(t *testing.T) { + ctx := t.Context() + session := mcptest.ConnectServer(t, ctx, newServer(t, mcpserver.Options{}).MCP()) + res, err := session.ListResources(ctx, nil) + require.NoError(t, err) + assert.Empty(t, res.Resources) + + _, err = mcpserver.New(mcpserver.Options{DocsURL: "unikraft.com/docs"}) + require.ErrorContains(t, err, "invalid documentation URL") +} diff --git a/internal/mcpserver/http.go b/internal/mcpserver/http.go new file mode 100644 index 00000000..fa80c728 --- /dev/null +++ b/internal/mcpserver/http.go @@ -0,0 +1,95 @@ +// 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 mcpserver + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "unikraft.com/x/log" +) + +// HTTPPath is where the protocol is served in network mode. +const HTTPPath = "/mcp" + +const ( + healthPath = "/healthz" + readHeaderTimeout = 10 * time.Second + shutdownTimeout = 10 * time.Second +) + +// Handler serves the protocol at HTTPPath over the streamable HTTP transport +// and a liveness check at /healthz. Every request runs on a temporary +// session, so any process can answer any request. Tool calls take their +// config and logger from the request context, which must therefore derive +// from the CLI context, as Serve arranges. +func (s *Server) Handler() http.Handler { + streamable := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return s.srv }, &mcp.StreamableHTTPOptions{ + Stateless: true, + Logger: s.opts.Logger, + }) + + mux := http.NewServeMux() + mux.Handle(HTTPPath, streamable) + mux.HandleFunc("GET "+healthPath, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + fmt.Fprintln(w, "ok") + }) + return http.NewCrossOriginProtection().Handler(mux) +} + +// Serve serves Handler on l until ctx is cancelled, then stops accepting and +// drains in-flight calls for up to shutdownTimeout. It returns nil after a +// clean shutdown. +func (s *Server) Serve(ctx context.Context, l net.Listener) error { + srv := &http.Server{ + Handler: s.Handler(), + ReadHeaderTimeout: readHeaderTimeout, + BaseContext: func(net.Listener) context.Context { return context.WithoutCancel(ctx) }, + } + + done := make(chan struct{}) + stop := context.AfterFunc(ctx, func() { + defer close(done) + sctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), shutdownTimeout) + defer cancel() + if err := srv.Shutdown(sctx); err != nil { + log.G(ctx).Warn().Err(err).Msg("forcing http server close") + _ = srv.Close() + } + }) + + log.G(ctx).Info().Str("addr", l.Addr().String()).Msg("listening") + var err error + if s.opts.TLSCertFile != "" { + err = srv.ServeTLS(l, s.opts.TLSCertFile, s.opts.TLSKeyFile) + } else { + err = srv.Serve(l) + } + if stop() { + return err + } + <-done + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return err +} + +func (s *Server) ListenAndServe(ctx context.Context, addr string) error { + var lc net.ListenConfig + l, err := lc.Listen(ctx, "tcp", addr) + if err != nil { + return err + } + return s.Serve(ctx, l) +} diff --git a/internal/mcpserver/http_test.go b/internal/mcpserver/http_test.go new file mode 100644 index 00000000..e0b7e3b2 --- /dev/null +++ b/internal/mcpserver/http_test.go @@ -0,0 +1,93 @@ +// 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 mcpserver_test + +import ( + "context" + "crypto/tls" + "io" + "net" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + integ "unikraft.com/cli/internal/integration" + "unikraft.com/cli/internal/mcpserver" + "unikraft.com/cli/internal/mcpserver/mcptest" + modelmcp "unikraft.com/cli/internal/model/mcp" + modelt "unikraft.com/cli/internal/model/testing" +) + +func newServer(t *testing.T, opts mcpserver.Options) *mcpserver.Server { + t.Helper() + opts.Name, opts.Title = "unikraft", "Unikraft Cloud" + srv, err := mcpserver.New(opts) + require.NoError(t, err) + return srv +} + +func TestServe(t *testing.T) { + t.Run("http", func(t *testing.T) { + testServe(t, mcpserver.Options{}, "http", http.DefaultClient) + }) + t.Run("https", func(t *testing.T) { + cert := integ.GenerateCert(t, "localhost") + dir := t.TempDir() + opts := mcpserver.Options{TLSCertFile: filepath.Join(dir, "cert.pem"), TLSKeyFile: filepath.Join(dir, "key.pem")} + require.NoError(t, os.WriteFile(opts.TLSCertFile, []byte(cert.Chain), 0o600)) + require.NoError(t, os.WriteFile(opts.TLSKeyFile, []byte(cert.Key), 0o600)) + client := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}} + testServe(t, opts, "https", client) + }) +} + +func testServe(t *testing.T, opts mcpserver.Options, scheme string, client *http.Client) { + env := modelt.NewTestEnv() + env.Add(modelt.TestModel{ID: "1", Name: "a"}) + ctx, cancel := context.WithCancel(modelt.WithTestEnv(t.Context(), env)) + defer cancel() + srv := newServer(t, opts) + require.NoError(t, modelmcp.New(srv.MCP(), modelmcp.Options{}).AddListTool[modelt.TestModel](ctx)) + + l, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + errc := make(chan error, 1) + go func() { errc <- srv.Serve(ctx, l) }() + url := scheme + "://" + l.Addr().String() + mcpserver.HTTPPath + + session := mcptest.Connect(t, &mcp.StreamableClientTransport{Endpoint: url, HTTPClient: client}) + res := mcptest.CallTool(t, ctx, session, "test_list", map[string]any{"fields": []string{"name"}}) + require.False(t, res.IsError, mcptest.Text(res)) + assert.Equal(t, []map[string]any{{"name": "a"}}, mcptest.Resources(t, res)) + require.NoError(t, session.Close()) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader("{}")) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + req.Header.Set("Sec-Fetch-Site", "cross-site") + resp, err := client.Do(req) + require.NoError(t, err) + _, err = io.Copy(io.Discard, resp.Body) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + assert.Equal(t, http.StatusForbidden, resp.StatusCode) + + cancel() + select { + case err := <-errc: + require.NoError(t, err) + case <-time.After(15 * time.Second): + t.Fatal("server did not stop after the context was cancelled") + } +} diff --git a/internal/mcpserver/mcptest/session.go b/internal/mcpserver/mcptest/session.go new file mode 100644 index 00000000..b0bf3473 --- /dev/null +++ b/internal/mcpserver/mcptest/session.go @@ -0,0 +1,129 @@ +// 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 mcptest + +import ( + "context" + "encoding/json" + "slices" + "strings" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/require" +) + +// Session is a client session with an MCP server, with the instructions and +// tools the server announced. +type Session struct { + *mcp.ClientSession + + Instructions string + Tools []*mcp.Tool +} + +// Connect opens a session over transport and closes it when the test ends. +func Connect(t testing.TB, transport mcp.Transport) *Session { + t.Helper() + ctx := t.Context() + + client := mcp.NewClient(&mcp.Implementation{Name: "unikraft-mcptest", Version: "0"}, nil) + session, err := client.Connect(ctx, transport, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = session.Close() }) + + res, err := session.ListTools(ctx, nil) + require.NoError(t, err) + + return &Session{ + ClientSession: session, + Instructions: session.InitializeResult().Instructions, + Tools: res.Tools, + } +} + +func ConnectServer(t testing.TB, ctx context.Context, srv *mcp.Server) *Session { + t.Helper() + clientTransport, serverTransport := mcp.NewInMemoryTransports() + _, err := srv.Connect(ctx, serverTransport, nil) + require.NoError(t, err) + return Connect(t, clientTransport) +} + +func (s *Session) Tool(name string) *mcp.Tool { + i := slices.IndexFunc(s.Tools, func(tool *mcp.Tool) bool { return tool.Name == name }) + if i < 0 { + return nil + } + return s.Tools[i] +} + +func (s *Session) ToolNames() []string { + names := make([]string, 0, len(s.Tools)) + for _, tool := range s.Tools { + names = append(names, tool.Name) + } + return names +} + +// Call runs the tool and gives its text output and whether the tool reported +// an error. A protocol failure is returned as the error. +func (s *Session) Call(ctx context.Context, name string, args json.RawMessage) (string, bool, error) { + params := &mcp.CallToolParams{Name: name} + if len(args) > 0 { + params.Arguments = args + } + res, err := s.CallTool(ctx, params) + if err != nil { + return "", false, err + } + return Text(res), res.IsError, nil +} + +func Text(res *mcp.CallToolResult) string { + var parts []string + for _, content := range res.Content { + if tc, ok := content.(*mcp.TextContent); ok { + parts = append(parts, tc.Text) + } + } + return strings.Join(parts, "\n") +} + +func CallTool(t testing.TB, ctx context.Context, session *Session, name string, args map[string]any) *mcp.CallToolResult { + t.Helper() + res, err := session.CallTool(ctx, &mcp.CallToolParams{Name: name, Arguments: args}) + require.NoError(t, err) + return res +} + +func Resources(t testing.TB, res *mcp.CallToolResult) []map[string]any { + t.Helper() + structured, ok := res.StructuredContent.(map[string]any) + require.True(t, ok, "structured content %T is not an object", res.StructuredContent) + items, ok := structured["resources"].([]any) + require.True(t, ok, "resources %T is not a list", structured["resources"]) + out := make([]map[string]any, 0, len(items)) + for _, item := range items { + out = append(out, item.(map[string]any)) + } + return out +} + +func Properties(t testing.TB, schema any) map[string]any { + t.Helper() + object, ok := schema.(map[string]any) + require.True(t, ok, "schema %T is not an object", schema) + props, _ := object["properties"].(map[string]any) + return props +} + +func Property(t testing.TB, schema any, name string) map[string]any { + t.Helper() + prop, ok := Properties(t, schema)[name].(map[string]any) + require.True(t, ok, "property %s is not an object", name) + return prop +} diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go new file mode 100644 index 00000000..ef4c6961 --- /dev/null +++ b/internal/mcpserver/server.go @@ -0,0 +1,77 @@ +// 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 mcpserver + +import ( + "context" + "log/slog" + "net/http" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "unikraft.com/cli/internal/httpclient" +) + +// Options configures a Server. +type Options struct { + Name string + Title string + WebsiteURL string + Version string + Instructions string + Logger *slog.Logger + + DocsURL string + HTTPClient *http.Client + + TLSCertFile string + TLSKeyFile string +} + +// Server serves an MCP server over stdio or HTTP, with a documentation site +// as resources. +type Server struct { + srv *mcp.Server + opts Options +} + +func New(opts Options) (*Server, error) { + if opts.HTTPClient == nil { + opts.HTTPClient = httpclient.GetClient(false) + } + var d *docs + if opts.DocsURL != "" { + var err error + if d, err = newDocs(opts.DocsURL, DocsURI(opts.Name), opts.Title, opts.HTTPClient); err != nil { + return nil, err + } + } + + s := &Server{ + srv: mcp.NewServer(&mcp.Implementation{ + Name: opts.Name, + Title: opts.Title, + Version: opts.Version, + WebsiteURL: opts.WebsiteURL, + }, &mcp.ServerOptions{ + Instructions: opts.Instructions, + Logger: opts.Logger, + }), + opts: opts, + } + if d != nil { + s.addDocs(d) + } + return s, nil +} + +func (s *Server) MCP() *mcp.Server { + return s.srv +} + +func (s *Server) Run(ctx context.Context) error { + return s.srv.Run(ctx, &mcp.StdioTransport{}) +} diff --git a/internal/model/mcp/options.go b/internal/model/mcp/options.go new file mode 100644 index 00000000..7dfe9906 --- /dev/null +++ b/internal/model/mcp/options.go @@ -0,0 +1,47 @@ +// 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 mcp + +// ToolOption adjusts how a resource type's tools are registered. +type ToolOption func(*toolOptions) + +type toolOptions struct { + createDefaults map[string]any + createExample []string + editNote string +} + +func newToolOptions(opts []ToolOption) toolOptions { + var o toolOptions + for _, opt := range opts { + opt(&o) + } + return o +} + +// WithCreateDefault makes the create tool set the field at path to value +// unless the caller sets it. +func WithCreateDefault(path string, value any) ToolOption { + return func(o *toolOptions) { + if o.createDefaults == nil { + o.createDefaults = map[string]any{} + } + o.createDefaults[path] = value + } +} + +func WithCreateExample(paths ...string) ToolOption { + return func(o *toolOptions) { + o.createExample = paths + } +} + +// WithEditNote appends note to the edit tool's description. +func WithEditNote(note string) ToolOption { + return func(o *toolOptions) { + o.editNote = note + } +} diff --git a/internal/model/mcp/schema.go b/internal/model/mcp/schema.go new file mode 100644 index 00000000..aa96291a --- /dev/null +++ b/internal/model/mcp/schema.go @@ -0,0 +1,409 @@ +// 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 mcp + +import ( + "cmp" + "encoding" + "encoding/json/jsontext" + "fmt" + "maps" + "reflect" + "slices" + "strconv" + "strings" + "time" + + "github.com/ettle/strcase" + "github.com/google/jsonschema-go/jsonschema" + + "unikraft.com/x/kingkong" + + "unikraft.com/cli/internal/model" + "unikraft.com/cli/internal/model/cmd" + "unikraft.com/cli/internal/model/value" +) + +const ( + maxExampleValues = 3 + maxExampleFields = 4 + + KeyFormat = "a name or UUID, optionally prefixed with metro/" +) + +func toolName(typeName, op string) string { + return NormalizeName(typeName) + "_" + op +} + +func toolTitle(typ model.Type, op string) string { + switch op { + case "list": + return "List " + typ.Names + case "get": + return "Inspect " + typ.Names + case "create": + return "Create " + typ.Name + case "edit": + return "Edit " + typ.Name + case "delete": + return "Delete " + typ.Names + } + return "" +} + +func NormalizeName(name string) string { + return strcase.ToSnake(name) +} + +func createDescription(typ model.Type, fields []model.Field, set *jsonschema.Schema, defaults map[string]jsontext.Value, example []string) string { + article := "a" + if strings.ContainsRune("aeiou", rune(typ.Name[0])) { + article = "an" + } + required := "none" + if len(set.Required) > 0 { + required = strings.Join(set.Required, ", ") + } + text := fmt.Sprintf( + "Create %s %s. Put the values in set, keyed by field path; list fields take arrays and anything else may be given as a string. Required: %s.", + article, typ.Name, required, + ) + if example := exampleSet(fields, set, example); example != "" { + text += " Example set: " + example + "." + } + if len(defaults) > 0 { + parts := make([]string, 0, len(defaults)) + for _, path := range slices.Sorted(maps.Keys(defaults)) { + parts = append(parts, path+" defaults to "+string(defaults[path])) + } + text += " Unless set, " + strings.Join(parts, " and ") + "." + } + return text + fmt.Sprintf(" Returns the created %s.", typ.Name) +} + +func editDescription(typ model.Type, note string) string { + text := fmt.Sprintf( + "Change an existing %s: set replaces field values, add appends to list fields and del removes entries from them, all keyed by field path. Use dry_run to preview the change.", + typ.Name, + ) + if note != "" { + text += " " + note + } + return text + fmt.Sprintf(" Returns the updated %s.", typ.Name) +} + +func stringArray(desc string) *jsonschema.Schema { + return &jsonschema.Schema{Type: "array", Items: &jsonschema.Schema{Type: "string"}, Description: desc} +} + +func keysSchema(typ model.Type) *jsonschema.Schema { + return stringArray(fmt.Sprintf("Keys of the %s, each %s.", typ.Names, KeyFormat)) +} + +func dryRunSchema() *jsonschema.Schema { + return &jsonschema.Schema{Type: "boolean", Description: "Return the resulting patch instead of applying it."} +} + +func listSchema(typ model.Type, fields []model.Field) *jsonschema.Schema { + return &jsonschema.Schema{ + Type: "object", + AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}}, + Properties: map[string]*jsonschema.Schema{ + "keys": stringArray(fmt.Sprintf("Return only these %s, each %s. Omit to list all.", typ.Names, KeyFormat)), + "filter": stringArray("Conditions every result must satisfy, such as name==my-app, metro==fra or state!=running. Field paths: " + strings.Join(fieldPaths(fields), ", ") + "." + fieldGlossary(fields)), + "sort": stringArray("Field paths to sort by, most significant first; prefix a path with - for descending order. Same paths as filter."), + "fields": stringArray("Field paths to include in the result. Omit for every field. Same paths as filter."), + }, + } +} + +func getSchema(typ model.Type, fields []model.Field) *jsonschema.Schema { + return &jsonschema.Schema{ + Type: "object", + AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}}, + Properties: map[string]*jsonschema.Schema{ + "keys": keysSchema(typ), + "fields": stringArray("Field paths to include in the result. Omit for every field. Available: " + strings.Join(fieldPaths(fields), ", ") + "." + fieldGlossary(fields)), + }, + Required: []string{"keys"}, + } +} + +func createSchema(fields []model.Field) *jsonschema.Schema { + set, _, _ := patchSchema(fields, true) + schema := &jsonschema.Schema{ + Type: "object", + AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}}, + Properties: map[string]*jsonschema.Schema{ + "set": set, + "dry_run": dryRunSchema(), + }, + } + if len(set.Required) > 0 { + schema.Required = []string{"set"} + } + return schema +} + +func editSchema(typ model.Type, fields []model.Field) *jsonschema.Schema { + set, add, del := patchSchema(fields, false) + return &jsonschema.Schema{ + Type: "object", + AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}}, + Properties: map[string]*jsonschema.Schema{ + "key": {Type: "string", Description: fmt.Sprintf("The %s to change, as %s.", typ.Name, KeyFormat)}, + "set": set, + "add": add, + "del": del, + "dry_run": dryRunSchema(), + }, + Required: []string{"key"}, + } +} + +func deleteSchema(typ model.Type) *jsonschema.Schema { + return &jsonschema.Schema{ + Type: "object", + AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}}, + Properties: map[string]*jsonschema.Schema{"keys": keysSchema(typ)}, + Required: []string{"keys"}, + } +} + +func patchSchema(fields []model.Field, create bool) (set, add, del *jsonschema.Schema) { + set = &jsonschema.Schema{Type: "object", Description: "Field values to set, keyed by field path."} + add = &jsonschema.Schema{Type: "object", Description: "Entries to append to list or map fields, keyed by field path; the paths are described under set."} + del = &jsonschema.Schema{Type: "object", Description: "Entries to remove from list or map fields, keyed by field path; the paths are described under set."} + + for path, field := range model.IterFields(fields) { + patch := field.Edit + if create { + patch = field.Create + } + if patch == nil { + continue + } + key := path.String() + if patch.Set != nil { + prop := addProperty(set, key, patch.Set) + prop.Description = describe(key, field, prop, patch) + if patch.Required && value.IsZero(patch.Set) { + set.Required = append(set.Required, key) + } + } + if create { + continue + } + if patch.Add != nil { + prop := addProperty(add, key, patch.Add) + prop.Description = describe(key, field, prop, nil) + if setProp := set.Properties[key]; setProp != nil && setProp.Description == prop.Description { + prop.Description = "Entries to append to " + key + "; described under set." + } + } + if patch.Del != nil { + prop := addProperty(del, key, patch.Del) + prop.Description = cmp.Or(delDescription(key, field, patch), describe(key, field, prop, nil)) + } + } + return set, add, del +} + +func addProperty(schema *jsonschema.Schema, key string, template any) *jsonschema.Schema { + prop := typeSchema(reflect.TypeOf(template)) + if schema.Properties == nil { + schema.Properties = make(map[string]*jsonschema.Schema) + } + schema.Properties[key] = prop + return prop +} + +// delDescription describes a del entry whose shape differs from the field's +// own values: a name for a list of structured entries, a key for a map. +func delDescription(path string, field *model.Field, patch *model.Patch) string { + delType, fieldType := reflect.TypeOf(patch.Del), reflect.TypeOf(field.Value) + if delType == nil || delType.Kind() != reflect.Slice || delType.Elem().Kind() != reflect.String || delType == fieldType { + return "" + } + switch { + case field.ElemMap || (fieldType != nil && fieldType.Kind() == reflect.Map): + return "Entries to remove from " + path + ", by key." + case field.Elem != nil: + return "Entries to remove from " + path + ", by name." + default: + return "" + } +} + +func describe(path string, field *model.Field, prop *jsonschema.Schema, patch *model.Patch) string { + help := field.Help + if help == "" { + help = path + " (" + schemaType(prop) + ")" + } else if !strings.HasSuffix(help, ".") { + help += "." + } + parts := []string{help} + if values := kingkong.ParseExamples(field.Example); len(values) > 0 { + parts = append(parts, "For example "+quotedList(values[:min(len(values), maxExampleValues)])+".") + } + if patch != nil && patch.Required && !value.IsZero(patch.Set) { + if rendered, err := value.Render(patch.Set, value.RenderOpts{Quiet: true}); err == nil && rendered != "" { + parts = append(parts, "Defaults to "+rendered+".") + } + } + return strings.Join(parts, " ") +} + +func quotedList(values []string) string { + quoted := make([]string, len(values)) + for i, v := range values { + quoted[i] = strconv.Quote(v) + } + if len(quoted) == 1 { + return quoted[0] + } + return strings.Join(quoted[:len(quoted)-1], ", ") + " or " + quoted[len(quoted)-1] +} + +// exampleSet renders a set object a model could send to create, from the +// required fields and the ones that carry an example. +func exampleSet(fields []model.Field, set *jsonschema.Schema, preferred []string) string { + examples := make(map[string]string) + var ordered []string + for path, field := range model.IterFields(fields) { + if field.Create == nil || field.Create.Set == nil { + continue + } + if values := kingkong.ParseExamples(field.Example); len(values) > 0 { + examples[path.String()] = values[0] + ordered = append(ordered, path.String()) + } + } + + var paths []string + add := func(path string) { + if len(paths) < maxExampleFields && !slices.Contains(paths, path) && set.Properties[path] != nil { + paths = append(paths, path) + } + } + for _, path := range set.Required { + add(path) + } + for _, path := range preferred { + if _, ok := examples[path]; ok { + add(path) + } + } + for _, path := range ordered { + add(path) + } + if len(paths) == 0 { + return "" + } + + var b strings.Builder + b.WriteString("{") + for i, path := range paths { + if i > 0 { + b.WriteString(", ") + } + fmt.Fprintf(&b, "%q: %s", path, exampleJSON(set.Properties[path], path, examples[path])) + } + b.WriteString("}") + return b.String() +} + +func exampleJSON(prop *jsonschema.Schema, path, example string) string { + if example == "" { + return strconv.Quote("<" + path + ">") + } + switch schemaType(prop) { + case "array": + return "[" + strconv.Quote(example) + "]" + case "integer", "number": + if _, err := strconv.ParseFloat(example, 64); err == nil { + return example + } + } + return strconv.Quote(example) +} + +func schemaType(prop *jsonschema.Schema) string { + if len(prop.AnyOf) > 0 { + prop = prop.AnyOf[0] + } + return cmp.Or(prop.Type, "string") +} + +var ( + textUnmarshalerType = reflect.TypeFor[encoding.TextUnmarshaler]() + durationType = reflect.TypeFor[time.Duration]() +) + +func typeSchema(t reflect.Type) *jsonschema.Schema { + if t == nil { + return &jsonschema.Schema{Type: "string"} + } + for t.Kind() == reflect.Pointer { + t = t.Elem() + } + if t == durationType || t.Implements(textUnmarshalerType) || reflect.PointerTo(t).Implements(textUnmarshalerType) { + return &jsonschema.Schema{Type: "string"} + } + switch t.Kind() { + case reflect.String: + return &jsonschema.Schema{Type: "string"} + case reflect.Bool: + return orString(&jsonschema.Schema{Type: "boolean"}) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return orString(&jsonschema.Schema{Type: "integer"}) + case reflect.Float32, reflect.Float64: + return orString(&jsonschema.Schema{Type: "number"}) + case reflect.Slice, reflect.Array: + return orString(&jsonschema.Schema{Type: "array", Items: typeSchema(t.Elem())}) + case reflect.Map: + if t.Key().Kind() == reflect.String { + return orString(&jsonschema.Schema{Type: "object", AdditionalProperties: typeSchema(t.Elem())}) + } + case reflect.Struct: + return orString(&jsonschema.Schema{Type: "object"}) + } + return &jsonschema.Schema{Type: "string"} +} + +func orString(typed *jsonschema.Schema) *jsonschema.Schema { + return &jsonschema.Schema{AnyOf: []*jsonschema.Schema{typed, {Type: "string"}}} +} + +// fieldGlossary explains the fields that carry help but are not create +// input, so a model knows what values a filter such as state==standby can +// match. +func fieldGlossary(fields []model.Field) string { + var entries []string + for path, field := range model.IterFields(fields) { + if field.Help == "" || field.Create != nil { + continue + } + entries = append(entries, path.String()+": "+strings.TrimSuffix(strings.Join(strings.Fields(field.Help), " "), ".")) + } + if len(entries) == 0 { + return "" + } + return " Field meanings: " + strings.Join(entries, "; ") + "." +} + +func fieldPaths(fields []model.Field) []string { + selected, err := cmd.SelectFields(fields, true, model.FieldVerbosityInvisible, nil) + if err != nil { + selected = fields + } + var paths []string + for path := range model.IterFields(selected) { + paths = append(paths, path.String()) + } + return paths +} diff --git a/internal/model/mcp/schema_test.go b/internal/model/mcp/schema_test.go new file mode 100644 index 00000000..dedfcae2 --- /dev/null +++ b/internal/model/mcp/schema_test.go @@ -0,0 +1,142 @@ +// 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 mcp + +import ( + "encoding/json/jsontext" + "maps" + "reflect" + "slices" + "testing" + "time" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/stretchr/testify/assert" + + "unikraft.com/cli/internal/model" +) + +func TestToolName(t *testing.T) { + assert.Equal(t, "instance_list", toolName("instance", "list")) + assert.Equal(t, "instance_template_create", toolName("instance-template", "create")) + assert.Equal(t, "volume_template_edit", toolName("Volume Template", "edit")) +} + +func TestTypeSchema(t *testing.T) { + str := &jsonschema.Schema{Type: "string"} + orString := func(typed *jsonschema.Schema) *jsonschema.Schema { + return &jsonschema.Schema{AnyOf: []*jsonschema.Schema{typed, str}} + } + tests := []struct { + name string + template any + want *jsonschema.Schema + }{ + {"nil", nil, str}, + {"string", "", str}, + {"int", 0, orString(&jsonschema.Schema{Type: "integer"})}, + {"uint pointer", (*uint64)(nil), orString(&jsonschema.Schema{Type: "integer"})}, + {"float", 0.0, orString(&jsonschema.Schema{Type: "number"})}, + {"bool", false, orString(&jsonschema.Schema{Type: "boolean"})}, + {"duration", time.Duration(0), str}, + {"text unmarshaler", time.Time{}, str}, + {"strings", []string(nil), orString(&jsonschema.Schema{Type: "array", Items: str})}, + {"int pointers", []*int(nil), orString(&jsonschema.Schema{Type: "array", Items: orString(&jsonschema.Schema{Type: "integer"})})}, + {"map", map[string]string(nil), orString(&jsonschema.Schema{Type: "object", AdditionalProperties: str})}, + {"struct", struct{ A int }{}, orString(&jsonschema.Schema{Type: "object"})}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, typeSchema(reflect.TypeOf(tt.template))) + }) + } +} + +func TestPatchValues(t *testing.T) { + tests := []struct { + in string + want []string + }{ + {`"a"`, []string{"a"}}, + {`7`, []string{"7"}}, + {`true`, []string{"true"}}, + {`null`, nil}, + {`["a", 2, {"k":"v"}]`, []string{"a", "2", `{"k":"v"}`}}, + {`{"k": "v"}`, []string{`{"k": "v"}`}}, + } + for _, tt := range tests { + t.Run(tt.in, func(t *testing.T) { + assert.Equal(t, tt.want, patchValues(jsontext.Value(tt.in))) + }) + } +} + +func TestPatchArgs(t *testing.T) { + args := patchArgs(map[string]jsontext.Value{ + "b": jsontext.Value(`["x", "y"]`), + "a": jsontext.Value(`1`), + }) + assert.Equal(t, []map[string]string{{"a": "1"}, {"b": "x"}, {"b": "y"}}, args) +} + +func TestPatchSchemaRequired(t *testing.T) { + fields := []model.Field{ + {Name: "metro", Create: &model.Patch{Set: "fra", Required: true}}, + {Name: "image", Create: &model.Patch{Set: "", Required: true}}, + {Name: "tags", Edit: &model.Patch{Set: []string(nil), Add: []string(nil), Del: []string(nil)}}, + {Name: "notes", Help: "Notes.", Edit: &model.Patch{Add: []string(nil)}}, + } + + set, _, _ := patchSchema(fields, true) + assert.Equal(t, []string{"image"}, set.Required) + assert.Contains(t, set.Properties["metro"].Description, "Defaults to fra.") + assert.NotContains(t, set.Properties, "tags") + + set, add, del := patchSchema(fields, false) + assert.Equal(t, []string{"tags"}, slices.Collect(maps.Keys(set.Properties))) + assert.ElementsMatch(t, []string{"tags", "notes"}, slices.Collect(maps.Keys(add.Properties))) + assert.Equal(t, []string{"tags"}, slices.Collect(maps.Keys(del.Properties))) + assert.Equal(t, "tags (array)", set.Properties["tags"].Description) + assert.Equal(t, "Entries to append to tags; described under set.", add.Properties["tags"].Description) + assert.Equal(t, "Notes.", add.Properties["notes"].Description) +} + +func TestExampleSet(t *testing.T) { + fields := []model.Field{ + {Name: "image", Help: "Image.", Example: "nginx:latest,my-app:v1", Create: &model.Patch{Set: ""}}, + {Name: "ports", Example: "443:8080/http+tls", Create: &model.Patch{Set: []string(nil)}}, + {Name: "replicas", Example: "1,3", Create: &model.Patch{Set: 0}}, + {Name: "token", Create: &model.Patch{Set: "", Required: true}}, + {Name: "zeta", Example: "z", Create: &model.Patch{Set: ""}}, + {Name: "alpha", Example: "a", Create: &model.Patch{Set: ""}}, + } + set, _, _ := patchSchema(fields, true) + //nolint:testifylint // the key order is part of the contract, which JSONEq would ignore + assert.Equal(t, `{"token": "", "image": "nginx:latest", "ports": ["443:8080/http+tls"], "replicas": 1}`, exampleSet(fields, set, nil)) + //nolint:testifylint // the key order is part of the contract, which JSONEq would ignore + assert.Equal(t, `{"token": "", "alpha": "a", "zeta": "z", "image": "nginx:latest"}`, exampleSet(fields, set, []string{"alpha", "zeta"})) + assert.Equal(t, `Image. For example "nginx:latest" or "my-app:v1".`, set.Properties["image"].Description) + assert.Equal(t, `replicas (integer) For example "1" or "3".`, set.Properties["replicas"].Description) + + plain := []model.Field{{Name: "x", Create: &model.Patch{Set: ""}}} + set, _, _ = patchSchema(plain, true) + assert.Empty(t, exampleSet(plain, set, nil)) + + numeric := []model.Field{{Name: "replicas", Example: "3", Create: &model.Patch{Set: 0}}} + set, _, _ = patchSchema(numeric, true) + assert.Equal(t, `{"replicas": 3}`, exampleSet(numeric, set, nil)) +} + +func TestFieldGlossary(t *testing.T) { + fields := []model.Field{ + {Name: "name", Help: "Instance name.", Create: &model.Patch{Set: ""}}, + {Name: "state", Help: "Lifecycle state:\n running or stopped.", Edit: &model.Patch{Set: ""}}, + {Name: "uuid"}, + {Name: "timestamps", Subfields: []model.Field{{Name: "created", Help: "Creation time."}}}, + } + assert.Equal(t, " Field meanings: state: Lifecycle state: running or stopped; timestamps.created: Creation time.", fieldGlossary(fields)) + assert.Empty(t, fieldGlossary([]model.Field{{Name: "uuid"}})) +} diff --git a/internal/model/mcp/tools.go b/internal/model/mcp/tools.go new file mode 100644 index 00000000..06c910b5 --- /dev/null +++ b/internal/model/mcp/tools.go @@ -0,0 +1,381 @@ +// 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 mcp + +import ( + "bytes" + "context" + "encoding/json/jsontext" + "encoding/json/v2" + "fmt" + "io" + "maps" + "slices" + "time" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + + "unikraft.com/x/log" + + "unikraft.com/cli/internal/config" + "unikraft.com/cli/internal/model" + "unikraft.com/cli/internal/model/cmd" + wjsonschema "unikraft.com/cli/internal/w/jsonschema" +) + +// Options configures the tools registered for model types. +type Options struct { + Partition *model.Partition + ReadOnly bool + Resources []string + Stderr io.Writer +} + +// Tools registers the tools of model types on an MCP server. +type Tools struct { + srv *mcp.Server + opts Options + + resources map[string]bool +} + +func New(srv *mcp.Server, opts Options) *Tools { + if opts.Stderr == nil { + opts.Stderr = io.Discard + } + var resources map[string]bool + if len(opts.Resources) > 0 { + resources = make(map[string]bool, len(opts.Resources)) + for _, name := range opts.Resources { + resources[NormalizeName(name)] = true + } + } + return &Tools{srv: srv, opts: opts, resources: resources} +} + +func (ts *Tools) enabled(name string, mutating bool) bool { + name = NormalizeName(name) + if mutating && ts.opts.ReadOnly { + return false + } + return ts.resources == nil || ts.resources[name] +} + +func (ts *Tools) AddTool[In any](typeName string, tool *mcp.Tool, structured bool, run func(context.Context, In, config.Stdio) error) error { + if !ts.enabled(typeName, !tool.Annotations.ReadOnlyHint) { + return nil + } + return ts.addCommandTool(tool, structured, run) +} + +type listArgs struct { + Keys []string `json:"keys"` + Filter []string `json:"filter"` + Sort []string `json:"sort"` + Fields []string `json:"fields"` +} + +func (ts *Tools) AddListTool[R model.GettableListableModel](ctx context.Context) error { + typ, fields, err := fieldsOf[R](ctx) + if err != nil { + return err + } + return ts.AddTool(typ.Name, &mcp.Tool{ + Name: toolName(typ.Name, "list"), + Title: toolTitle(typ, "list"), + Description: fmt.Sprintf("List all %s, or fetch specific ones by passing their keys. Narrow with filter and trim the output with fields; both take the field paths listed on those parameters. Returns one JSON object per %s.", typ.Names, typ.Name), + InputSchema: listSchema(typ, fields), + Annotations: ReadOnlyAnnotations(), + }, true, func(ctx context.Context, args listArgs, stdio config.Stdio) error { + return (&cmd.ModelListCmd[R]{ + Targets: args.Keys, + Filter: args.Filter, + Sort: args.Sort, + FormatOpts: JSONFormat(args.Fields...), + }).Run(ctx, stdio, ts.opts.Partition) + }) +} + +type getArgs struct { + Keys []string `json:"keys"` + Fields []string `json:"fields"` +} + +func (ts *Tools) AddGetTool[R model.GettableModel](ctx context.Context, description string) error { + typ, fields, err := fieldsOf[R](ctx) + if err != nil { + return err + } + return ts.AddTool(typ.Name, &mcp.Tool{ + Name: toolName(typ.Name, "get"), + Title: toolTitle(typ, "get"), + Description: description, + InputSchema: getSchema(typ, fields), + Annotations: ReadOnlyAnnotations(), + }, true, func(ctx context.Context, args getArgs, stdio config.Stdio) error { + return (&cmd.ModelGetCmd[R]{ + Targets: args.Keys, + FormatOpts: JSONFormat(args.Fields...), + }).Run(ctx, stdio, ts.opts.Partition) + }) +} + +type createArgs struct { + Set map[string]jsontext.Value `json:"set"` + DryRun bool `json:"dry_run"` +} + +func (ts *Tools) AddCreateTool[R model.CreatableModel](ctx context.Context, opts ...ToolOption) error { + typ, fields, err := fieldsOf[R](ctx) + if err != nil { + return err + } + schema := createSchema(fields) + o := newToolOptions(opts) + for _, path := range o.createExample { + if _, ok := schema.Properties["set"].Properties[path]; !ok { + return fmt.Errorf("%s: cannot show %q in the example: not a creatable field", toolName(typ.Name, "create"), path) + } + } + defaults := make(map[string]jsontext.Value) + for path, value := range o.createDefaults { + prop, ok := schema.Properties["set"].Properties[path] + if !ok { + return fmt.Errorf("%s: cannot default %q: not a creatable field", toolName(typ.Name, "create"), path) + } + raw, err := json.Marshal(value) + if err != nil { + return fmt.Errorf("%s: cannot default %q: %w", toolName(typ.Name, "create"), path, err) + } + prop.Default = raw + defaults[path] = raw + } + return ts.AddTool(typ.Name, &mcp.Tool{ + Name: toolName(typ.Name, "create"), + Title: toolTitle(typ, "create"), + Description: createDescription(typ, fields, schema.Properties["set"], defaults, o.createExample), + InputSchema: schema, + Annotations: MutatingAnnotations(), + }, true, func(ctx context.Context, args createArgs, stdio config.Stdio) error { + if args.Set == nil { + args.Set = map[string]jsontext.Value{} + } + for path, value := range defaults { + if v, ok := args.Set[path]; !ok || v.Kind() == 'n' { + args.Set[path] = value + } + } + return (&cmd.ModelCreateCmd[R]{ + Set: patchArgs(args.Set), + DryRun: args.DryRun, + FormatOpts: JSONFormat(), + }).Run(ctx, stdio, ts.opts.Partition) + }) +} + +type editArgs struct { + Key string `json:"key"` + Set map[string]jsontext.Value `json:"set"` + Add map[string]jsontext.Value `json:"add"` + Del map[string]jsontext.Value `json:"del"` + DryRun bool `json:"dry_run"` +} + +func (ts *Tools) AddEditTool[R model.EditableModel](ctx context.Context, opts ...ToolOption) error { + typ, fields, err := fieldsOf[R](ctx) + if err != nil { + return err + } + return ts.AddTool(typ.Name, &mcp.Tool{ + Name: toolName(typ.Name, "edit"), + Title: toolTitle(typ, "edit"), + Description: editDescription(typ, newToolOptions(opts).editNote), + InputSchema: editSchema(typ, fields), + Annotations: MutatingAnnotations(), + }, true, func(ctx context.Context, args editArgs, stdio config.Stdio) error { + return (&cmd.ModelEditCmd[R]{ + Target: args.Key, + Set: patchArgs(args.Set), + Add: patchArgs(args.Add), + Del: patchArgs(args.Del), + DryRun: args.DryRun, + FormatOpts: JSONFormat(), + }).Run(ctx, stdio, ts.opts.Partition) + }) +} + +type deleteArgs struct { + Keys []string `json:"keys"` +} + +func (ts *Tools) AddDeleteTool[R model.DeletableModel](ctx context.Context) error { + var empty R + typ := empty.Type() + return ts.AddTool(typ.Name, &mcp.Tool{ + Name: toolName(typ.Name, "delete"), + Title: toolTitle(typ, "delete"), + Description: fmt.Sprintf("Delete %[1]s by key. This cannot be undone. Returns the deleted %[1]s.", typ.Names), + InputSchema: deleteSchema(typ), + Annotations: destructiveAnnotations(), + }, true, func(ctx context.Context, args deleteArgs, stdio config.Stdio) error { + if len(args.Keys) == 0 { + return fmt.Errorf("no %s specified", typ.Names) + } + return (&cmd.ModelRemoveCmd[R]{ + Targets: args.Keys, + FormatOpts: JSONFormat(), + }).Run(ctx, stdio, ts.opts.Partition) + }) +} + +func fieldsOf[R model.GettableModel](ctx context.Context) (model.Type, []model.Field, error) { + var empty R + fields, err := empty.Fields(ctx) + return empty.Type(), fields, err +} + +func (ts *Tools) addCommandTool[In any](tool *mcp.Tool, structured bool, run func(context.Context, In, config.Stdio) error) error { + var schema *jsonschema.Schema + if tool.InputSchema == nil { + var err error + if schema, err = jsonschema.For[In](nil); err != nil { + return fmt.Errorf("%s: %w", tool.Name, err) + } + } else { + schema = tool.InputSchema.(*jsonschema.Schema).CloneSchemas() + } + // For types nil-able fields as ["null", T]; clients such as the MCP + // Inspector reject array-valued types. + wjsonschema.SingleTypes(schema) + tool = new(*tool) + tool.InputSchema = schema + check := schema.CloneSchemas() + // The command checks the fields required in set once it has filled in + // defaults such as the profile's metro. + for _, prop := range check.Properties { + prop.Required = nil + } + resolved, err := check.Resolve(nil) + if err != nil { + return fmt.Errorf("%s: %w", tool.Name, err) + } + ts.srv.AddTool(tool, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.Params.Arguments + if len(args) == 0 { + args = []byte("{}") + } + ctx = log.WithLogger(ctx, new(log.G(ctx).With().Str("tool", req.Params.Name).Logger())) + log.G(ctx).Trace().RawJSON("arguments", args).Msg("calling tool") + + var instance any + err := json.Unmarshal(args, &instance) + if err == nil { + err = resolved.Validate(wjsonschema.WithoutNulls(instance)) + } + var in In + if err == nil { + err = json.Unmarshal(args, &in) + } + if err != nil { + err = fmt.Errorf("invalid arguments: %w", err) + log.G(ctx).Debug().Err(err).Msg("tool call rejected") + var res mcp.CallToolResult + res.SetError(err) + return &res, nil + } + return ts.capture(ctx, structured, func(ctx context.Context, stdio config.Stdio) error { + return run(ctx, in, stdio) + }), nil + }) + return nil +} + +func JSONFormat(fields ...string) cmd.FormatOpts { + return cmd.FormatOpts{Field: fields, Output: cmd.Printer{Type: cmd.PrinterTypeJSON}} +} + +func ReadOnlyAnnotations() *mcp.ToolAnnotations { + return &mcp.ToolAnnotations{ReadOnlyHint: true, DestructiveHint: new(false), IdempotentHint: true} +} + +func MutatingAnnotations() *mcp.ToolAnnotations { + return &mcp.ToolAnnotations{DestructiveHint: new(false)} +} + +func destructiveAnnotations() *mcp.ToolAnnotations { + return &mcp.ToolAnnotations{DestructiveHint: new(true), IdempotentHint: true} +} + +func (ts *Tools) capture(ctx context.Context, structured bool, run func(context.Context, config.Stdio) error) *mcp.CallToolResult { + start := time.Now() + var out bytes.Buffer + err := run(ctx, config.Stdio{Stdin: bytes.NewReader(nil), Stdout: &out, Stderr: ts.opts.Stderr}) + msg := "tool call complete" + if err != nil { + msg = "tool call failed" + } + log.G(ctx).Debug(). + Dur("took", time.Since(start)). + Int("output_bytes", out.Len()). + Err(err). + Msg(msg) + + res := &mcp.CallToolResult{Content: []mcp.Content{}} + if out.Len() > 0 { + res.Content = append(res.Content, &mcp.TextContent{Text: out.String()}) + var parsed any + if structured && json.Unmarshal(out.Bytes(), &parsed) == nil { + res.StructuredContent = map[string]any{"resources": parsed} + } + } + if err != nil { + res.Content = append(res.Content, &mcp.TextContent{Text: err.Error()}) + res.IsError = true + } + return res +} + +// patchArgs turns set, add or del arguments into the path=value pairs that +// the --set, --add and --del flags give, one per array element, in path order. +func patchArgs(values map[string]jsontext.Value) []map[string]string { + var args []map[string]string + for _, path := range slices.Sorted(maps.Keys(values)) { + for _, v := range patchValues(values[path]) { + args = append(args, map[string]string{path: v}) + } + } + return args +} + +// patchValues gives the CLI values of one argument: none for null, one per +// array element, and strings unquoted. +func patchValues(v jsontext.Value) []string { + switch v.Kind() { + case 'n': + return nil + case '[': + var items []jsontext.Value + if err := json.Unmarshal(v, &items); err == nil { + out := make([]string, 0, len(items)) + for _, item := range items { + out = append(out, scalarString(item)) + } + return out + } + } + return []string{scalarString(v)} +} + +func scalarString(v jsontext.Value) string { + if v.Kind() == '"' { + var s string + if err := json.Unmarshal(v, &s); err == nil { + return s + } + } + return string(bytes.TrimSpace(v)) +} diff --git a/internal/model/mcp/tools_test.go b/internal/model/mcp/tools_test.go new file mode 100644 index 00000000..8843e846 --- /dev/null +++ b/internal/model/mcp/tools_test.go @@ -0,0 +1,222 @@ +// 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 mcp_test + +import ( + "bytes" + "context" + "encoding/json" + "maps" + "slices" + "strings" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "unikraft.com/x/log" + + "unikraft.com/cli/internal/mcpserver/mcptest" + modelmcp "unikraft.com/cli/internal/model/mcp" + modelt "unikraft.com/cli/internal/model/testing" +) + +func newServer(t *testing.T, ctx context.Context, opts modelmcp.Options) *mcp.Server { + t.Helper() + srv := mcp.NewServer(&mcp.Implementation{Name: "test"}, nil) + tools := modelmcp.New(srv, opts) + require.NoError(t, tools.AddListTool[modelt.TestModel](ctx)) + require.NoError(t, tools.AddCreateTool[modelt.TestModel](ctx)) + require.NoError(t, tools.AddEditTool[modelt.TestModel](ctx)) + require.NoError(t, tools.AddDeleteTool[modelt.TestModel](ctx)) + return srv +} + +func newSession(t *testing.T, opts modelmcp.Options) (context.Context, *mcptest.Session, *modelt.TestEnv) { + t.Helper() + env := modelt.NewTestEnv() + ctx := modelt.WithTestEnv(t.Context(), env) + return ctx, mcptest.ConnectServer(t, ctx, newServer(t, ctx, opts)), env +} + +func TestResourcesAllowlist(t *testing.T) { + ctx := modelt.WithTestEnv(t.Context(), modelt.NewTestEnv()) + + srv := newServer(t, ctx, modelmcp.Options{Resources: []string{"Test"}}) + session := mcptest.ConnectServer(t, ctx, srv) + assert.Len(t, session.Tools, 4) + + srv = newServer(t, ctx, modelmcp.Options{Resources: []string{"other-thing"}}) + assert.Empty(t, mcptest.ConnectServer(t, ctx, srv).Tools) +} + +func TestListTools(t *testing.T) { + _, session, _ := newSession(t, modelmcp.Options{}) + assert.ElementsMatch(t, []string{"test_list", "test_create", "test_edit", "test_delete"}, session.ToolNames()) + + list := session.Tool("test_list") + assert.True(t, list.Annotations.ReadOnlyHint) + assert.Equal(t, "List tests", list.Title) + assert.NotContains(t, list.Description, "settings.foo") + filter := mcptest.Property(t, list.InputSchema, "filter")["description"] + assert.Contains(t, filter, "settings.foo") + assert.Contains(t, filter, "Field meanings: state: Lifecycle state: running or stopped.") + + create := session.Tool("test_create") + assert.False(t, create.Annotations.ReadOnlyHint) + assert.Equal(t, "Create test", create.Title) + assert.True(t, strings.HasPrefix(create.Description, "Create a test. "), create.Description) + assert.Contains(t, create.Description, "Required: none.") + set := mcptest.Properties(t, mcptest.Property(t, create.InputSchema, "set")) + assert.ElementsMatch(t, []string{"name", "create_only", "settings.foo", "settings.bar"}, slices.Collect(maps.Keys(set))) + foo := set["settings.foo"].(map[string]any) + assert.Equal(t, []any{map[string]any{"type": "integer"}, map[string]any{"type": "string"}}, foo["anyOf"]) + assert.Equal(t, "Foo setting.", foo["description"]) + + edit := session.Tool("test_edit") + assert.False(t, *edit.Annotations.DestructiveHint) + assert.Equal(t, []any{"key"}, edit.InputSchema.(map[string]any)["required"]) + editSet := mcptest.Properties(t, mcptest.Property(t, edit.InputSchema, "set")) + assert.ElementsMatch(t, []string{"edit_only", "settings.foo", "settings.bar"}, slices.Collect(maps.Keys(editSet))) + + del := session.Tool("test_delete") + assert.True(t, *del.Annotations.DestructiveHint) + assert.Equal(t, []any{"keys"}, del.InputSchema.(map[string]any)["required"]) +} + +func TestReadOnly(t *testing.T) { + _, session, _ := newSession(t, modelmcp.Options{ReadOnly: true}) + assert.Equal(t, []string{"test_list"}, session.ToolNames()) +} + +func TestCallList(t *testing.T) { + ctx, session, env := newSession(t, modelmcp.Options{}) + env.Add(modelt.TestModel{ID: "1", Name: "a", State: "running"}) + env.Add(modelt.TestModel{ID: "2", Name: "b", State: "stopped"}) + + res := mcptest.CallTool(t, ctx, session, "test_list", map[string]any{"filter": []string{"state==running"}, "fields": []string{"name"}}) + require.False(t, res.IsError, mcptest.Text(res)) + assert.Equal(t, []map[string]any{{"name": "a"}}, mcptest.Resources(t, res)) + assert.Contains(t, mcptest.Text(res), `"name": "a"`) + + res = mcptest.CallTool(t, ctx, session, "test_list", map[string]any{"sort": []string{"-name"}, "fields": []string{"name"}}) + require.False(t, res.IsError, mcptest.Text(res)) + assert.Equal(t, []map[string]any{{"name": "b"}, {"name": "a"}}, mcptest.Resources(t, res)) + + res = mcptest.CallTool(t, ctx, session, "test_list", map[string]any{"keys": []string{"b"}, "fields": []string{"id", "name"}}) + require.False(t, res.IsError, mcptest.Text(res)) + assert.Equal(t, []map[string]any{{"id": "2", "name": "b"}}, mcptest.Resources(t, res)) + + res = mcptest.CallTool(t, ctx, session, "test_list", map[string]any{"keys": []string{"missing"}}) + require.False(t, res.IsError, mcptest.Text(res)) + assert.Empty(t, mcptest.Resources(t, res)) + + res = mcptest.CallTool(t, ctx, session, "test_list", map[string]any{"keys": []string{"a"}, "filter": nil, "fields": []string{"name"}}) + require.False(t, res.IsError, mcptest.Text(res)) + assert.Equal(t, []map[string]any{{"name": "a"}}, mcptest.Resources(t, res)) + + res = mcptest.CallTool(t, ctx, session, "test_list", map[string]any{"nope": 1}) + assert.True(t, res.IsError) + assert.Contains(t, mcptest.Text(res), `unexpected additional properties ["nope"]`) +} + +func TestCallCreate(t *testing.T) { + ctx, session, env := newSession(t, modelmcp.Options{}) + + res := mcptest.CallTool(t, ctx, session, "test_create", map[string]any{ + "set": map[string]any{"name": "fresh", "settings.foo": 7, "settings.bar": "baz"}, + }) + require.False(t, res.IsError, mcptest.Text(res)) + require.Contains(t, env.Store, "fresh") + assert.Equal(t, 7, env.Store["fresh"].Settings.Foo) + assert.Equal(t, "baz", env.Store["fresh"].Settings.Bar) + created := mcptest.Resources(t, res) + require.Len(t, created, 1) + assert.Equal(t, "fresh", created[0]["name"]) + + res = mcptest.CallTool(t, ctx, session, "test_create", map[string]any{"set": map[string]any{"nope": "x"}}) + assert.True(t, res.IsError) + assert.Contains(t, mcptest.Text(res), "nope") + + res = mcptest.CallTool(t, ctx, session, "test_create", map[string]any{ + "set": map[string]any{"name": "preview"}, + "dry_run": true, + }) + require.False(t, res.IsError, mcptest.Text(res)) + assert.Nil(t, res.StructuredContent) + assert.Contains(t, mcptest.Text(res), "preview") + assert.NotContains(t, env.Store, "preview") +} + +func TestCallEdit(t *testing.T) { + ctx, session, env := newSession(t, modelmcp.Options{}) + env.Add(modelt.TestModel{ID: "1", Name: "a", Settings: modelt.TestSettings{Foo: 1, Bar: "old"}}) + + res := mcptest.CallTool(t, ctx, session, "test_edit", map[string]any{ + "key": "a", + "set": map[string]any{"settings.bar": "new", "settings.foo": "5"}, + }) + require.False(t, res.IsError, mcptest.Text(res)) + assert.Equal(t, "new", env.Store["a"].Settings.Bar) + assert.Equal(t, 5, env.Store["a"].Settings.Foo) + updated := mcptest.Resources(t, res) + require.Len(t, updated, 1) + assert.Equal(t, "new", updated[0]["settings"].(map[string]any)["bar"]) + + res = mcptest.CallTool(t, ctx, session, "test_edit", map[string]any{ + "key": "a", + "set": map[string]any{"settings.bar": "later"}, + "dry_run": true, + }) + require.False(t, res.IsError, mcptest.Text(res)) + assert.Equal(t, "new", env.Store["a"].Settings.Bar) + assert.Contains(t, mcptest.Text(res), "later") + + res = mcptest.CallTool(t, ctx, session, "test_edit", map[string]any{"key": "missing", "set": map[string]any{"settings.bar": "x"}}) + assert.True(t, res.IsError) + assert.Contains(t, mcptest.Text(res), "not found") +} + +func TestCallDelete(t *testing.T) { + ctx, session, env := newSession(t, modelmcp.Options{}) + env.Add(modelt.TestModel{ID: "1", Name: "a"}) + env.Add(modelt.TestModel{ID: "2", Name: "b"}) + + res := mcptest.CallTool(t, ctx, session, "test_delete", map[string]any{"keys": []string{"a"}}) + require.False(t, res.IsError, mcptest.Text(res)) + assert.NotContains(t, env.Store, "a") + assert.Contains(t, env.Store, "b") + deleted := mcptest.Resources(t, res) + require.Len(t, deleted, 1) + assert.Equal(t, "a", deleted[0]["name"]) + + res = mcptest.CallTool(t, ctx, session, "test_delete", map[string]any{}) + assert.True(t, res.IsError) + assert.Contains(t, mcptest.Text(res), `missing properties: ["keys"]`) +} + +func TestCallLogging(t *testing.T) { + var logs bytes.Buffer + logger := zerolog.New(&logs).Level(zerolog.TraceLevel) + env := modelt.NewTestEnv() + ctx := log.WithLogger(modelt.WithTestEnv(t.Context(), env), &logger) + session := mcptest.ConnectServer(t, ctx, newServer(t, ctx, modelmcp.Options{})) + + _, _, err := session.Call(ctx, "test_list", json.RawMessage(`{"fields":["name"]}`)) + require.NoError(t, err) + mcptest.CallTool(t, ctx, session, "test_create", map[string]any{"set": map[string]any{"nope": "x"}}) + mcptest.CallTool(t, ctx, session, "test_delete", map[string]any{}) + + out := logs.String() + assert.Contains(t, out, `"level":"trace","tool":"test_list","arguments":{"fields":["name"]},"message":"calling tool"`) + assert.Contains(t, out, `"level":"debug","tool":"test_list","took":`) + assert.Contains(t, out, `"message":"tool call complete"`) + assert.Contains(t, out, `"tool":"test_create","took":`) + assert.Contains(t, out, `"error":"unknown fields: [nope]","message":"tool call failed"`) + assert.Contains(t, out, `"tool":"test_delete","error":"invalid arguments: validating root: required: missing properties: [\"keys\"]","message":"tool call rejected"`) +} diff --git a/internal/w/README.md b/internal/w/README.md index 1fe260e9..d7e2b86c 100644 --- a/internal/w/README.md +++ b/internal/w/README.md @@ -4,11 +4,12 @@ is named after the library it extends and would live upstream if we could put it there. -| Package | Wish for | -| ---------------------- | --------------------------------- | -| `internal/w/kong` | `github.com/alecthomas/kong` | -| `internal/w/maps` | `maps` | -| `internal/w/shell` | `mvdan.cc/sh/v3/shell` | -| `internal/w/slices` | `slices` | -| `internal/w/sync` | `sync` | -| `internal/w/time` | `time` | +| Package | Wish for | +| ----------------------- | -------------------------------------------- | +| `internal/w/kong` | `github.com/alecthomas/kong` | +| `internal/w/jsonschema` | `github.com/google/jsonschema-go/jsonschema` | +| `internal/w/maps` | `maps` | +| `internal/w/shell` | `mvdan.cc/sh/v3/shell` | +| `internal/w/slices` | `slices` | +| `internal/w/sync` | `sync` | +| `internal/w/time` | `time` | diff --git a/internal/w/jsonschema/jsonschema.go b/internal/w/jsonschema/jsonschema.go new file mode 100644 index 00000000..5bfc125b --- /dev/null +++ b/internal/w/jsonschema/jsonschema.go @@ -0,0 +1,41 @@ +// 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 jsonschema + +import ( + "slices" + + "github.com/google/jsonschema-go/jsonschema" +) + +func SingleTypes(schema *jsonschema.Schema) { + if schema == nil { + return + } + if len(schema.Types) > 0 { + types := slices.DeleteFunc(slices.Clone(schema.Types), func(t string) bool { return t == "null" }) + if len(types) == 1 { + schema.Type, schema.Types = types[0], nil + } + } + SingleTypes(schema.Items) + for _, prop := range schema.Properties { + SingleTypes(prop) + } +} + +func WithoutNulls(v any) any { + if m, ok := v.(map[string]any); ok { + for k, e := range m { + if e == nil { + delete(m, k) + } else { + WithoutNulls(e) + } + } + } + return v +} diff --git a/internal/w/jsonschema/jsonschema_test.go b/internal/w/jsonschema/jsonschema_test.go new file mode 100644 index 00000000..3fafa1b9 --- /dev/null +++ b/internal/w/jsonschema/jsonschema_test.go @@ -0,0 +1,32 @@ +// 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 jsonschema + +import ( + "testing" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/stretchr/testify/assert" +) + +func TestSingleTypes(t *testing.T) { + schema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "instances": {Types: []string{"null", "array"}, Items: &jsonschema.Schema{Types: []string{"null", "string"}}}, + "timeout": {Types: []string{"null", "integer"}}, + "either": {Types: []string{"integer", "string"}}, + "plain": {Type: "boolean"}, + }, + } + SingleTypes(schema) + assert.Equal(t, "array", schema.Properties["instances"].Type) + assert.Empty(t, schema.Properties["instances"].Types) + assert.Equal(t, "string", schema.Properties["instances"].Items.Type) + assert.Equal(t, "integer", schema.Properties["timeout"].Type) + assert.Equal(t, []string{"integer", "string"}, schema.Properties["either"].Types) + assert.Equal(t, "boolean", schema.Properties["plain"].Type) +} From 9b0c69292fa1eddb12a4fa0d6d5f41bf48369e4b Mon Sep 17 00:00:00 2001 From: Cezar Craciunoiu Date: Tue, 27 Oct 2026 15:44:46 +0200 Subject: [PATCH 25/35] fix(cmd): Keep the instances of a partial create A create that fails for one instance, such as on a taken name, gets a partial success. The CLI dropped the instances that the response still listed, so they stayed on the platform and were never shown. The create now keeps every instance with a UUID in the response, and returns the error of the platform next to them. Signed-off-by: Cezar Craciunoiu --- cmd/unikraft/integration/instance_test.go | 16 ++++++++++++++++ internal/cmd/instances.go | 14 +++++++++----- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/cmd/unikraft/integration/instance_test.go b/cmd/unikraft/integration/instance_test.go index 354c0b81..35293745 100644 --- a/cmd/unikraft/integration/instance_test.go +++ b/cmd/unikraft/integration/instance_test.go @@ -1484,6 +1484,22 @@ cmd: ["cat", "/marker.txt"] out = r.Run(t, append([]string{"unikraft", "instance", "inspect"}, instances...)) assert.Regexp(t, `image:\s+nginx`, out) + // A taken name fails one instance, and the create still shows the replica it made. + out = r.Run(t, []string{ + "unikraft", "--log-level=error", "instance", "create", + "--output", "template={{ .name }}", + "--name", "test-" + instName, + "--metro", r.MetroName, + "--image", "nginx:latest", + "--memory", "128", + "--vcpus", "1", + "--replicas", "1", + }, integ.ExpectFail()) + assert.Contains(t, out, "already exists") + replica := regexp.MustCompile(`test-` + instName + `-\w+`).FindString(out) + require.NotEmpty(t, replica) + instances = append(instances, replica) + r.Run(t, append([]string{"unikraft", "instance", "delete"}, instances...)) }) diff --git a/internal/cmd/instances.go b/internal/cmd/instances.go index b8fcd4e1..b4531eab 100644 --- a/internal/cmd/instances.go +++ b/internal/cmd/instances.go @@ -1569,13 +1569,14 @@ func (Instance) Create(ctx context.Context, fields []model.Field) ([]model.Model keys, err := group.CollectMetro(ctx, g, metro, func(ctx context.Context, c multimetro.MetroClient) (multimetro.Keys, error) { log.G(ctx).Trace().Msg("creating instance") resp, err := timeouts.TryWithFallback(ctx, req, c.CreateInstance) - if _, err = timeouts.Tolerate(ctx, resp, err, "instance create did not complete in time"); err != nil { - if len(req.NetworkInterfaces) > 0 && strings.Contains(err.Error(), "Unknown member 'network_interfaces'") { - return nil, fmt.Errorf("%w\nhint: --network needs the net_manager permission, contact support to enable it", err) - } - return nil, err + _, err = timeouts.Tolerate(ctx, resp, err, "instance create did not complete in time") + if err != nil && len(req.NetworkInterfaces) > 0 && strings.Contains(err.Error(), "Unknown member 'network_interfaces'") { + return nil, fmt.Errorf("%w\nhint: --network needs the net_manager permission, contact support to enable it", err) } if resp == nil || resp.Data == nil || len(resp.Data.Instances) == 0 { + if err != nil { + return nil, err + } return nil, fmt.Errorf("no instances created") } created := make(multimetro.Keys, 0, len(resp.Data.Instances)) @@ -1607,6 +1608,9 @@ func (Instance) Create(ctx context.Context, fields []model.Field) ([]model.Model createdData[key.String()] = createdResource[platform.Instance]{data: instance, metro: c.Metro} created = append(created, key) } + if err != nil { + errs = []error{err} + } if len(created) == 0 { return nil, errors.Join(errs...) } From 86a531085be6d7805901aa570b588cdb20334221 Mon Sep 17 00:00:00 2001 From: Cezar Craciunoiu Date: Tue, 3 Nov 2026 15:37:36 +0200 Subject: [PATCH 26/35] refactor(model): Pass the create fields in A command that needed a create value before the create had to resolve the flags again, which runs an editor mode twice. 'CreateFields' resolves the flags and the edit mode into the create patches, and 'Create' takes them. A command can change the patches between the two calls. Signed-off-by: Cezar Craciunoiu --- internal/model/cmd/cmd.go | 62 ++++++++++++++++++++++++---------- internal/model/cmd/cmd_test.go | 34 +++++++++++++++++++ 2 files changed, 79 insertions(+), 17 deletions(-) diff --git a/internal/model/cmd/cmd.go b/internal/model/cmd/cmd.go index 3607e962..88587b51 100644 --- a/internal/model/cmd/cmd.go +++ b/internal/model/cmd/cmd.go @@ -828,14 +828,15 @@ func (cmd *ModelCreateCmd[R]) Run(ctx context.Context, stdio config.Stdio, parti return err } -func (cmd *ModelCreateCmd[R]) RunModels(ctx context.Context, stdio config.Stdio, partition *model.Partition) ([]model.Model, error) { +// resolve reads the flags and the edit mode into the resource fields and +// their patches. +func (cmd *ModelCreateCmd[R]) resolve(ctx context.Context) (fields, patched []model.Field, err error) { spec, err := cmd.toPatchSpec() if err != nil { - return nil, err + return nil, nil, err } var empty R - r := partition.WrapCreatable(empty) fieldsResource := model.Model(empty) if typed, ok := any(empty).(interface { WithType(string) model.Model @@ -846,18 +847,13 @@ func (cmd *ModelCreateCmd[R]) RunModels(ctx context.Context, stdio config.Stdio, fieldsResource = typed.WithType(values[0]) } } - fields, err := fieldsResource.Fields(ctx) + fields, err = fieldsResource.Fields(ctx) if err != nil { - return nil, fmt.Errorf("failed to get fields: %w", err) + return nil, nil, fmt.Errorf("failed to get fields: %w", err) } - patched, err := patch.PatchedFields(ctx, fields, spec) + patched, err = patch.PatchedFields(ctx, fields, spec) if err != nil { - return nil, err - } - - // Handle --save: write YAML to file and exit - if cmd.Save != "" { - return nil, saveYAML(cmd.Save, stdio, empty, fields, patched, true) + return nil, nil, err } // Handle different editing modes (mutually exclusive via xor:"edit-mode" tag) @@ -866,7 +862,7 @@ func (cmd *ModelCreateCmd[R]) RunModels(ctx context.Context, stdio config.Stdio, case cmd.Visual: editor, err = patch.VisualCommandEditorFunc() if err != nil { - return nil, err + return nil, nil, err } case cmd.Cmd != "": editor = patch.CommandEditorFunc(cmd.Cmd) @@ -876,22 +872,36 @@ func (cmd *ModelCreateCmd[R]) RunModels(ctx context.Context, stdio config.Stdio, if editor != nil { patched, err = patch.Create(ctx, empty, fields, patched, editor) if err != nil { - return nil, err + return nil, nil, err } } + return fields, patched, nil +} - patchedFields := patch.FilterCreateFields(patched) +// CreateFields returns the create patches of the flags and the edit mode. Each +// call runs the edit mode again. +func (cmd *ModelCreateCmd[R]) CreateFields(ctx context.Context) ([]model.Field, error) { + fields, patched, err := cmd.resolve(ctx) + if err != nil { + return nil, err + } // Validate required fields before printing/applying if err := patch.ValidateRequired(fields, patched, true); err != nil { return nil, err } + return patch.FilterCreateFields(patched), nil +} +// Create prints the create patches on a dry run, and else creates the +// resources from them. +func (cmd *ModelCreateCmd[R]) Create(ctx context.Context, stdio config.Stdio, partition *model.Partition, fields []model.Field) ([]model.Model, error) { if cmd.DryRun { - return nil, PrintPatches(stdio.Stdout, patchedFields, true) + return nil, PrintPatches(stdio.Stdout, fields, true) } - resources, opErr := r.Create(ctx, patchedFields) + var empty R + resources, opErr := partition.WrapCreatable(empty).Create(ctx, fields) if opErr != nil && len(resources) == 0 { return nil, opErr } @@ -904,6 +914,24 @@ func (cmd *ModelCreateCmd[R]) RunModels(ctx context.Context, stdio config.Stdio, return resources, opErr } +func (cmd *ModelCreateCmd[R]) RunModels(ctx context.Context, stdio config.Stdio, partition *model.Partition) ([]model.Model, error) { + // Handle --save: write YAML to file and exit + if cmd.Save != "" { + fields, patched, err := cmd.resolve(ctx) + if err != nil { + return nil, err + } + var empty R + return nil, saveYAML(cmd.Save, stdio, empty, fields, patched, true) + } + + fields, err := cmd.CreateFields(ctx) + if err != nil { + return nil, err + } + return cmd.Create(ctx, stdio, partition, fields) +} + // saveYAML writes YAML to a file or stdout (if filename is "-"). func saveYAML(filename string, stdio config.Stdio, res model.Model, fields, patches []model.Field, create bool) error { var w io.Writer diff --git a/internal/model/cmd/cmd_test.go b/internal/model/cmd/cmd_test.go index 373205ec..44c50ddc 100644 --- a/internal/model/cmd/cmd_test.go +++ b/internal/model/cmd/cmd_test.go @@ -2217,3 +2217,37 @@ func TestFilterComparisonOperators(t *testing.T) { assert.Contains(t, out.String(), "test3") }) } + +func TestCreateWithChangedFields(t *testing.T) { + env := modelt.NewTestEnv() + ctx := modelt.WithTestEnv(context.Background(), env) + partition := &model.Partition{} + + var out bytes.Buffer + cmd := &ModelCreateCmd[modelt.TestModel]{ + Set: []map[string]string{ + {"name": "test-wrapped"}, + {"settings.foo": "100"}, + }, + Output: Printer{Type: PrinterTypeQuiet}, + } + + fields, err := cmd.CreateFields(ctx) + require.NoError(t, err) + + assert.Empty(t, model.GetFieldByPathString(fields, "edit_only")) + + fields = append(fields, model.Field{ + Name: "settings.bar", + Create: &model.Patch{Set: "added"}, + }) + + resources, err := cmd.Create(ctx, testStdio(&out), partition, fields) + require.NoError(t, err) + require.Len(t, resources, 1) + + created := resources[0].(modelt.TestModel) + assert.Equal(t, "test-wrapped", created.Name) + assert.Equal(t, 100, created.Settings.Foo) + assert.Equal(t, "added", created.Settings.Bar) +} From f93d5ffa530771d5ec6b8990aa53e076c78b7f79 Mon Sep 17 00:00:00 2001 From: Cezar Craciunoiu Date: Mon, 7 Sep 2026 14:57:05 +0300 Subject: [PATCH 27/35] feat(cmd): Add '--rollout' when creating Moving a service group onto a new image meant creating the new instances and deleting the old ones by hand. '--rollout' replaces the group one batch at a time ('batch', 1 by default). Each old batch stops when its new batch runs, and the old set is deleted at the end. A failed step starts the old set again and deletes the new instances. Autoscale groups are refused. Signed-off-by: Cezar Craciunoiu --- cmd/unikraft/integration/instance_test.go | 144 +++++++ cmd/unikraft/testdata/TestHelp/instances | 34 ++ cmd/unikraft/testdata/TestHelp/run | 8 + internal/cmd/instances.go | 20 +- internal/cmd/instances_rollout.go | 487 ++++++++++++++++++++++ internal/cmd/instances_test.go | 53 +++ internal/cmd/run.go | 6 + internal/multimetro/key.go | 13 + internal/rollout/rolling.go | 23 + internal/rollout/rollout.go | 162 +++++++ internal/rollout/rollout_test.go | 164 ++++++++ 11 files changed, 1113 insertions(+), 1 deletion(-) create mode 100644 internal/cmd/instances_rollout.go create mode 100644 internal/rollout/rolling.go create mode 100644 internal/rollout/rollout.go create mode 100644 internal/rollout/rollout_test.go diff --git a/cmd/unikraft/integration/instance_test.go b/cmd/unikraft/integration/instance_test.go index 35293745..4e0042c5 100644 --- a/cmd/unikraft/integration/instance_test.go +++ b/cmd/unikraft/integration/instance_test.go @@ -1503,6 +1503,150 @@ cmd: ["cat", "/marker.txt"] r.Run(t, append([]string{"unikraft", "instance", "delete"}, instances...)) }) + t.Run("rollout", func(t *testing.T) { + r := runner(t, true, []string{staging, stable}) + svcName := "test-" + uniq() + common := []string{ + "--metro", r.MetroName, + "--memory", "128", + "--vcpus", "1", + } + + r.Run(t, []string{ + "unikraft", "service", "create", + "--output", "quiet", + "--name", svcName, + "--metro", r.MetroName, + "--service", "443:8080/http+tls", + }) + before := strings.Fields(r.Run(t, append([]string{ + "unikraft", "instance", "create", + "--output", "template={{ .name }}", + "--name", "test-" + uniq(), + "--image", "nginx:latest", + "--service", svcName, + "--autostart", + "--replicas", "1", + }, common...))) + require.Len(t, before, 2) + + controlName := "test-" + uniq() + r.Run(t, append([]string{ + "unikraft", "instance", "create", + "--output", "quiet", + "--name", controlName, + "--image", "nginx:1.25", + }, common...)) + controlImage := strings.TrimSpace(r.Run(t, []string{ + "unikraft", "--log-level=error", "instance", "inspect", + "--output", "template={{ .image }}", controlName, + })) + + // A dry run shows the patches of the first batch and the old set, and changes nothing. + out := r.Run(t, append([]string{ + "unikraft", "--log-level=error", "instance", "create", + "--image", "nginx:1.25", + "--service", svcName, + "--autostart", + "--rollout=batch=2", + "--dry-run", + }, common...)) + assert.Regexp(t, `replicas\s+:= 1`, out) + assert.Regexp(t, `autostart\s+:= false`, out) + for _, name := range before { + assert.Regexp(t, `replaces := .*`+name, out) + } + list := r.Run(t, []string{"unikraft", "instance", "list", "--output", "quiet"}) + for _, name := range before { + assert.Contains(t, list, name) + } + + // A rollout refuses --name, as the old and the new instances exist at once. + out = r.Run(t, append([]string{ + "unikraft", "--log-level=error", "instance", "create", + "--name", "test-" + uniq(), + "--image", "nginx:latest", + "--service", svcName, + "--autostart", + "--rollout", + }, common...), integ.ExpectFail()) + assert.Contains(t, out, "--name cannot be used with --rollout") + + // A rolling rollout replaces one instance at a time. + // A stopped old instance gets a stopped new one. + r.Run(t, []string{"unikraft", "instance", "stop", before[0]}) + rolled := strings.Fields(r.Run(t, append([]string{ + "unikraft", "--log-level=error", "instance", "run", + "--output", "template={{ .name }}", + "--image", "nginx:1.25", + "--service", svcName, + "--rollout", + }, common...))) + require.Len(t, rolled, 2) + list = r.Run(t, []string{"unikraft", "instance", "list", "--output", "quiet"}) + for _, name := range before { + assert.NotContains(t, list, name) + } + assert.Contains(t, list, controlName) + images := r.Run(t, append([]string{ + "unikraft", "--log-level=error", "instance", "inspect", "--output", "template={{ .image }}", + }, rolled...)) + assert.Equal(t, []string{controlImage, controlImage}, strings.Fields(images)) + states := r.Run(t, append([]string{ + "unikraft", "--log-level=error", "instance", "inspect", "--output", "template={{ .state }}", + }, rolled...)) + assert.ElementsMatch(t, []string{"running", "stopped"}, strings.Fields(states)) + + r.Run(t, append([]string{"unikraft", "instance", "delete", controlName}, rolled...)) + r.Run(t, []string{"unikraft", "service", "delete", svcName}) + }) + + t.Run("rollout-delete-on-stop", func(t *testing.T) { + r := runner(t, true, []string{staging, stable}) + svcName := "test-" + uniq() + common := []string{ + "--metro", r.MetroName, + "--memory", "128", + "--vcpus", "1", + } + + r.Run(t, []string{ + "unikraft", "service", "create", + "--output", "quiet", + "--name", svcName, + "--metro", r.MetroName, + "--service", "443:8080/http+tls", + }) + r.Run(t, append([]string{ + "unikraft", "instance", "create", + "--output", "quiet", + "--image", "nginx:latest", + "--service", svcName, + "--autostart", + "--rm", + "--replicas", "1", + }, common...)) + + // Each old instance goes when it stops, and the rollout counts it as stopped. + rolled := strings.Fields(r.Run(t, append([]string{ + "unikraft", "--log-level=error", "instance", "create", + "--output", "template={{ .name }}", + "--image", "nginx:1.25", + "--service", svcName, + "--autostart", + "--rollout", + }, common...))) + require.Len(t, rolled, 2) + members := r.Run(t, []string{ + "unikraft", "--log-level=error", "service", "get", + "--output", "template={{ range .instances }}{{ .name }} {{ end }}", svcName, + }) + assert.ElementsMatch(t, rolled, strings.Fields(members)) + + r.Run(t, append([]string{"unikraft", "instance", "delete"}, rolled...)) + r.Run(t, []string{"unikraft", "service", "delete", svcName}) + }) + t.Run("watch-timeout", func(t *testing.T) { r := runner(t, true, []string{staging, stable}) r.Run(t, []string{"unikraft", "--timeout=1s", "instance", "ls", "-w"}, integ.AllowFail()) diff --git a/cmd/unikraft/testdata/TestHelp/instances b/cmd/unikraft/testdata/TestHelp/instances index c2f1f877..880ee9ab 100644 --- a/cmd/unikraft/testdata/TestHelp/instances +++ b/cmd/unikraft/testdata/TestHelp/instances @@ -426,6 +426,14 @@ Examples: --plugin 'name=sandbox,image=plugins/sandbox:latest,config={"persist_path":"/data"}' + # Replace every instance in a service group with a new image + unikraft instance create \ + --metro fra \ + --image my-app:v2 \ + --service my-service \ + --autostart \ + --rollout + Fields: metro name @@ -617,6 +625,11 @@ Create flags: Create from a checkpoint, by name or UUID, restoring its memory and disk. Cannot be combined with image, args, env, memory, vcpus, type or gpus. --rm Automatically delete the instance when it stops. + --rollout== + Replace every instance in the service group with a new one and delete the old ones. Requires --autostart. + type: rolling (default) + batch: instances that a rolling rollout replaces at a time (default: 1). + [examples: type=rolling, batch=2] $ unikraft instance new --help @@ -656,6 +669,14 @@ Examples: --plugin 'name=sandbox,image=plugins/sandbox:latest,config={"persist_path":"/data"}' + # Replace every instance in a service group with a new image + unikraft instance create \ + --metro fra \ + --image my-app:v2 \ + --service my-service \ + --autostart \ + --rollout + Fields: metro name @@ -847,6 +868,11 @@ Create flags: Create from a checkpoint, by name or UUID, restoring its memory and disk. Cannot be combined with image, args, env, memory, vcpus, type or gpus. --rm Automatically delete the instance when it stops. + --rollout== + Replace every instance in the service group with a new one and delete the old ones. Requires --autostart. + type: rolling (default) + batch: instances that a rolling rollout replaces at a time (default: 1). + [examples: type=rolling, batch=2] $ unikraft instance run --help @@ -894,6 +920,9 @@ Examples: unikraft instance run --metro=fra --image=my-app:latest --plugin 'name=sandbox,image=plugins/sandbox:latest,config={"persist_path":"/data"}' + # Roll every instance in a service group over to a new image + unikraft instance run --image=my-app:v2 --service=api --rollout + Fields: metro name @@ -1087,6 +1116,11 @@ Create flags: Create from a checkpoint, by name or UUID, restoring its memory and disk. Cannot be combined with image, args, env, memory, vcpus, type or gpus. --rm Automatically delete the instance when it stops. + --rollout== + Replace every instance in the service group with a new one and delete the old ones. Requires --autostart. + type: rolling (default) + batch: instances that a rolling rollout replaces at a time (default: 1). + [examples: type=rolling, batch=2] $ unikraft instance edit --help diff --git a/cmd/unikraft/testdata/TestHelp/run b/cmd/unikraft/testdata/TestHelp/run index c90e555e..b8667f43 100644 --- a/cmd/unikraft/testdata/TestHelp/run +++ b/cmd/unikraft/testdata/TestHelp/run @@ -43,6 +43,9 @@ Examples: unikraft run --metro=fra --image=my-app:latest --plugin 'name=sandbox,image=plugins/sandbox:latest,config={"persist_path":"/data"}' + # Roll every instance in a service group over to a new image + unikraft run --image=my-app:v2 --service=api --rollout + Fields: metro name @@ -236,3 +239,8 @@ Create flags: Create from a checkpoint, by name or UUID, restoring its memory and disk. Cannot be combined with image, args, env, memory, vcpus, type or gpus. --rm Automatically delete the instance when it stops. + --rollout== + Replace every instance in the service group with a new one and delete the old ones. Requires --autostart. + type: rolling (default) + batch: instances that a rolling rollout replaces at a time (default: 1). + [examples: type=rolling, batch=2] diff --git a/internal/cmd/instances.go b/internal/cmd/instances.go index b4531eab..d123ffef 100644 --- a/internal/cmd/instances.go +++ b/internal/cmd/instances.go @@ -76,7 +76,8 @@ type InstancesCmd struct { type InstanceCreateCmd struct { cmd.ModelCreateCmd[Instance] - DeleteOnStop bool `group:"flag-create" name:"rm" help:"Automatically delete the instance when it stops."` + DeleteOnStop bool `group:"flag-create" name:"rm" help:"Automatically delete the instance when it stops."` + Rollout *InstanceRollout `group:"flag-create" name:"rollout" type:"optional" help:"Replace every instance in the service group with a new one and delete the old ones. Requires --autostart.\n type: rolling (default)\n batch: instances that a rolling rollout replaces at a time (default: 1)" placeholder:"=" example:"type=rolling,batch=2"` } func (c *InstanceCreateCmd) Run(ctx context.Context, stdio config.Stdio, partition *model.Partition) error { @@ -97,6 +98,12 @@ func (c *InstanceCreateCmd) RunModels(ctx context.Context, stdio config.Stdio, p return nil, fmt.Errorf("--domain cannot be used with --service") } } + if c.Rollout != nil { + if c.Save == "" { + return c.runRollout(ctx, stdio, partition) + } + log.G(ctx).Info().Msg("--save only saves the fields, so --rollout has no effect") + } return c.ModelCreateCmd.RunModels(ctx, stdio, partition) } @@ -1700,6 +1707,17 @@ func (Instance) Examples() map[cmd.CmdType][]kingkong.Example { --plugin 'name=sandbox,image=plugins/sandbox:latest,config={"persist_path":"/data"}'`, }, }, + { + Description: "Replace every instance in a service group with a new image", + Commands: []string{ + `unikraft instance create \ + --metro fra \ + --image my-app:v2 \ + --service my-service \ + --autostart \ + --rollout`, + }, + }, }, cmd.CmdTypeEdit: { { diff --git a/internal/cmd/instances_rollout.go b/internal/cmd/instances_rollout.go new file mode 100644 index 00000000..9fb97ffd --- /dev/null +++ b/internal/cmd/instances_rollout.go @@ -0,0 +1,487 @@ +// 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 ( + "cmp" + "context" + "errors" + "fmt" + "io" + "slices" + "strings" + "time" + + "unikraft.com/cloud/sdk/platform" + "unikraft.com/cloud/sdk/platform/group" + "unikraft.com/cloud/sdk/platform/timeouts" + "unikraft.com/x/log" + + "unikraft.com/cli/internal/config" + "unikraft.com/cli/internal/model" + "unikraft.com/cli/internal/model/cmd" + "unikraft.com/cli/internal/model/value" + "unikraft.com/cli/internal/multimetro" + "unikraft.com/cli/internal/rollout" +) + +// InstanceRollout holds the options of a rollout. +type InstanceRollout struct { + Type InstanceRolloutMode `name:"type" json:"type,omitempty"` + Batch int `name:"batch" json:"batch,omitempty"` +} + +// UnmarshalText reads the rollout options as = pairs or as JSON. +func (r *InstanceRollout) UnmarshalText(data []byte) error { + type rolloutAlias InstanceRollout + parsed, err := value.Parse[rolloutAlias]([]string{string(data)}) + if err != nil { + return err + } + if parsed.Batch < 0 { + return fmt.Errorf("rollout batch cannot be negative") + } + *r = InstanceRollout(parsed) + return nil +} + +// InstanceRolloutMode is how a rollout swaps the old instances for the new. +type InstanceRolloutMode string + +const ( + // RolloutRolling means the new instances replace the old ones a batch at a time. + RolloutRolling InstanceRolloutMode = "rolling" +) + +// UnmarshalText reads a rollout type and refuses an unknown one. +func (m *InstanceRolloutMode) UnmarshalText(data []byte) error { + if mode := InstanceRolloutMode(data); mode == RolloutRolling { + *m = mode + return nil + } + return fmt.Errorf("unknown rollout type %q, want %q", data, RolloutRolling) +} + +// rolloutPollInterval is how long a rollout leaves between two reads. +const rolloutPollInterval = 2 * time.Second + +// runRollout replaces every instance in the service group with a new one, and +// deletes the old set once the new one runs. +func (c *InstanceCreateCmd) runRollout(ctx context.Context, stdio config.Stdio, partition *model.Partition) ([]model.Model, error) { + opts := *c.Rollout + opts.Type = cmp.Or(opts.Type, RolloutRolling) + opts.Batch = cmp.Or(opts.Batch, 1) + + fields, err := c.CreateFields(ctx) + if err != nil { + return nil, err + } + var metro string + var svc *InstanceService + named, replicas, autostart := false, false, false + for key, field := range model.IterFields(fields) { + if field.Create == nil || field.Create.Set == nil { + continue + } + switch key.String() { + case "metro": + metro = string(field.Create.Set.(LinkName[Metro])) + case "name": + named = true + case "replicas": + replicas = true + case "autostart": + autostart = field.Create.Set.(bool) + case "service": + svc = field.Create.Set.(*InstanceService) + } + } + if named { + return nil, fmt.Errorf("--name cannot be used with --rollout") + } + if replicas { + return nil, fmt.Errorf("--replicas cannot be used with --rollout") + } + if !autostart { + return nil, fmt.Errorf("--rollout requires --autostart") + } + if svc == nil || (svc.Name == "" && svc.UUID == "") { + return nil, fmt.Errorf("--rollout requires an existing service group") + } + + svcGroup, old, err := rolloutTargetService(ctx, metro, svc) + if err != nil { + if !c.DryRun { + return nil, err + } + log.G(ctx).Warn().Err(err). + Msg("cannot read the service group, replaced instances not shown") + } else if svcGroup.Autoscale { + return nil, fmt.Errorf("autoscaling enabled, can't rollout service group %q", cmp.Or(svcGroup.Name, svcGroup.UUID)) + } + old = slices.DeleteFunc(old, func(inst Instance) bool { return partition.Missing(inst) }) + if err == nil && len(old) == 0 { + log.G(ctx).Info(). + Str("service", svcGroup.Name). + Msg("no instances to replace, creating without a rollout") + return c.Create(ctx, stdio, partition, fields) + } + oldKeys := make(multimetro.Keys, len(old)) + for i, inst := range old { + oldKeys[i] = inst.key + } + + var running multimetro.Keys + for _, inst := range old { + if inst.State.IsRunning() { + running = append(running, inst.key) + } + } + + var strategy rollout.Strategy = rollout.Rolling{Batch: opts.Batch} + first := min(opts.Batch, len(oldKeys)) + fieldsFor := func(n int) []model.Field { + batch := slices.Clone(fields) + for i, field := range batch { + if field.Name == "autostart" && field.Create != nil { + create := *field.Create + create.Set = false + batch[i].Create = &create + } + } + if n > 1 { + batch = append(batch, model.Field{Name: "replicas", Create: &model.Patch{Set: int64(n - 1)}}) + } + return batch + } + + if c.DryRun { + if _, err := c.Create(ctx, stdio, partition, fieldsFor(first)); err != nil { + return nil, err + } + if len(oldKeys) > 0 { + fmt.Fprintf(stdio.Stdout, "replaces := %s\n", strings.Join(oldKeys.Strings(), " ")) + } + return nil, nil + } + + g, err := multimetro.NewClient(ctx) + if err != nil { + return nil, err + } + log.G(ctx).Info(). + Str("service", svcGroup.Name). + Str("mode", string(opts.Type)). + Int("instances", len(oldKeys)). + Msg("rolling out") + + quiet := stdio + quiet.Stdout = io.Discard + var created []*instanceSet + done, err := strategy.Apply(ctx, + &instanceSet{g: g, partition: partition, keys: oldKeys, running: running}, + func(ctx context.Context, replaces rollout.Set) (rollout.Set, error) { + old := replaces.(*instanceSet) + n := len(old.keys) + res, err := c.Create(ctx, quiet, partition, fieldsFor(n)) + set := &instanceSet{g: g, partition: partition} + for _, r := range res { + set.keys = append(set.keys, r.(Instance).key) + } + set.running = set.keys[:min(len(old.running), len(set.keys))] + created = append(created, set) + if err == nil && len(res) < n { + err = fmt.Errorf("rollout created %d of %d instances", len(res), n) + } + return set, err + }) + if !done { + return nil, err + } + var keys multimetro.Keys + for _, set := range created { + keys = append(keys, set.keys...) + } + results, readErr := (Instance{}).Get(ctx, keys.ByUUID()) + err = errors.Join(err, readErr) + printed := make([]model.Model, 0, len(results)) + for _, r := range results { + inst := r.(Instance) + inst.key.Display = "" + printed = append(printed, inst) + } + if printErr := c.Output.WithDefault(cmd.PrinterTypeKeyValue).Print(ctx, stdio.Stdout, c.Field, Instance{}, printed...); printErr != nil { + return printed, errors.Join(err, printErr) + } + return printed, err +} + +// rolloutTargetService reads back the service group whose instances a rollout +// replaces, and those instances. +func rolloutTargetService(ctx context.Context, metro string, svc *InstanceService) (ServiceGroup, []Instance, error) { + var none ServiceGroup + key := multimetro.Key{Metro: cmp.Or(svc.Metro, metro), UUID: svc.UUID} + if svc.UUID == "" { + key.Name = svc.Name + } + results, err := (ServiceGroup{}).Get(ctx, []string{key.Canonical()}) + if err != nil { + return none, nil, fmt.Errorf("looking up service group %q: %w", key.Canonical(), err) + } + if len(results) == 0 { + return none, nil, fmt.Errorf("service group %q not found", key.Canonical()) + } + svcGroup := results[0].(ServiceGroup) + + keys := make(multimetro.Keys, 0, len(svcGroup.Instances)) + for _, inst := range svcGroup.Instances { + key := multimetro.Key{ + Metro: cmp.Or(inst.Metro, string(svcGroup.Metro)), + Name: inst.Name, + UUID: inst.UUID, + } + if key.Name == "" && key.UUID == "" { + continue + } + keys = append(keys, key) + } + if len(keys) == 0 { + return svcGroup, nil, nil + } + results, err = (Instance{}).Get(ctx, keys.Strings()) + if err != nil { + return none, nil, fmt.Errorf("reading the instances of service group %q: %w", svcGroup.Name, err) + } + old := make([]Instance, len(results)) + for i, r := range results { + old[i] = r.(Instance) + } + return svcGroup, old, nil +} + +// instanceSet is a set of instances that a rollout acts on. Start and Up act +// only on the running instances, and Stop counts a gone instance as stopped. +type instanceSet struct { + g *group.Group[multimetro.MetroClient] + partition *model.Partition + keys multimetro.Keys + running multimetro.Keys + seen map[string]bool + restarts map[string]int +} + +func (s *instanceSet) Len() int { + return len(s.keys) +} + +func (s *instanceSet) Take(n int) (rollout.Set, rollout.Set) { + n = min(n, len(s.keys)) + batch := &instanceSet{g: s.g, partition: s.partition, keys: s.keys[:n:n]} + rest := &instanceSet{g: s.g, partition: s.partition, keys: s.keys[n:]} + for _, key := range s.running { + if slices.Contains(batch.keys, key) { + batch.running = append(batch.running, key) + } else { + rest.running = append(rest.running, key) + } + } + return batch, rest +} + +func (s *instanceSet) Names() []string { + return s.keys.Strings() +} + +func (s *instanceSet) Start(ctx context.Context) error { + if len(s.running) == 0 { + return nil + } + _, err := startInstances(ctx, s.g, s.running) + return err +} + +func (s *instanceSet) Stop(ctx context.Context) error { + pending := s.keys + _, err := stopInstances(ctx, s.g, s.keys, StopOpts{DrainTimeout: -1}) + if notFound, ok := errors.AsType[group.ErrRefNotFound](err); ok { + log.G(ctx).Warn(). + Strs("instances", notFound.Refs.Strings()). + Msg("the instances are gone, so they count as stopped") + pending = withoutRefs(pending, notFound.Refs) + } else if err != nil { + return err + } + state := platform.InstanceStateStopped + ask := func(ctx context.Context, keys multimetro.Keys) error { + return group.DoRefs(ctx, s.g, keys.Refs(), func(ctx context.Context, c multimetro.MetroClient, refs group.Refs) (group.Refs, error) { + log.G(ctx).Trace().Str("state", string(state)).Msg("waiting for instances") + reqs := make([]platform.WaitInstancesRequestItem, 0, len(refs)) + for _, ref := range refs.NameOrUUIDs() { + reqs = append(reqs, platform.WaitInstancesRequestItem{ + Name: ref.Name, + Uuid: ref.Uuid, + State: &state, + TimeoutS: new(int64(-1)), + }) + } + resp, err := timeouts.TryWithFallback(ctx, reqs, func(ctx context.Context, reqs []platform.WaitInstancesRequestItem) (*platform.Response[platform.WaitInstancesResponseData], error) { + return c.WaitInstances(ctx, reqs, platform.WaitInstancesOpts{}) + }) + if _, err := timeouts.Tolerate(ctx, resp, err, "instances did not reach the state in time"); err != nil && !platform.ErrorContainsOnly(err, platform.APIHTTPErrorNotFound) { + return nil, err + } + if resp == nil || resp.Data == nil { + return nil, nil + } + var waited group.Refs + for _, instance := range resp.Data.Instances { + if instance.Error != nil && platform.APIHTTPError(*instance.Error) == platform.APIHTTPErrorNotFound { + i := slices.IndexFunc(refs, func(ref group.Ref) bool { + return cmp.Or(ref.UUID, ref.Name) == cmp.Or(instance.Uuid, instance.Name) + }) + if i >= 0 { + log.G(ctx).Warn(). + Str("instance", refs[i].String()). + Msg("the instance is gone, so it counts as stopped") + waited = append(waited, refs[i]) + } + continue + } + if instance.State != state { + log.G(ctx).Trace(). + Str("instance", cmp.Or(instance.Name, instance.Uuid)). + Str("state", string(instance.State)). + Msgf("instance is not %s yet", state) + continue + } + waited = append(waited, group.Ref{ + Metro: c.Metro.Name, + Name: instance.Name, + UUID: instance.Uuid, + }) + } + return waited, nil + }) + } + + for { + err := ask(ctx, pending) + notFound, ok := errors.AsType[group.ErrRefNotFound](err) + if err == nil || !ok { + return err + } + pending = make(multimetro.Keys, 0, len(notFound.Refs)) + for _, ref := range notFound.Refs { + pending = append(pending, multimetro.Key(ref)) + } + select { + case <-ctx.Done(): + return err + case <-time.After(rolloutPollInterval): + } + } +} + +func (s *instanceSet) Up(ctx context.Context) error { + if len(s.running) == 0 { + return nil + } + if s.seen == nil { + s.seen = make(map[string]bool, len(s.running)) + } + var last []Instance + for { + results, err := (Instance{}).Get(ctx, s.running.ByUUID()) + notFound, ok := errors.AsType[group.ErrRefNotFound](err) + if err != nil && !ok { + return err + } + last = make([]Instance, 0, len(results)) + var pending, failed []error + for _, r := range results { + inst := r.(Instance) + s.seen[inst.UUID] = true + if inst.Name != "" { + s.seen[inst.Name] = true + } + last = append(last, inst) + name := cmp.Or(inst.Name, inst.UUID) + switch platform.InstanceState(inst.State) { + case platform.InstanceStateRunning, platform.InstanceStateStandby: + case platform.InstanceStateStarting: + pending = append(pending, fmt.Errorf("instance %s is %s", name, inst.State)) + default: + failed = append(failed, fmt.Errorf("instance %s is %s, not running", name, inst.State)) + } + } + for _, ref := range notFound.Refs { + name := cmp.Or(ref.Name, ref.UUID) + if (ref.UUID != "" && s.seen[ref.UUID]) || (ref.Name != "" && s.seen[ref.Name]) { + failed = append(failed, fmt.Errorf("instance %s was deleted", name)) + continue + } + pending = append(pending, fmt.Errorf("instance %s is not listed yet", name)) + } + if len(failed) > 0 { + return errors.Join(failed...) + } + if len(pending) == 0 { + break + } + select { + case <-ctx.Done(): + return errors.Join(pending...) + case <-time.After(rolloutPollInterval): + } + } + + var restarted []error + for _, inst := range last { + if count, ok := s.restarts[inst.UUID]; ok && inst.Restart.RestartCount > count { + restarted = append(restarted, fmt.Errorf("instance %s restarted", cmp.Or(inst.Name, inst.UUID))) + } + } + s.restarts = make(map[string]int, len(last)) + for _, inst := range last { + s.restarts[inst.UUID] = inst.Restart.RestartCount + } + return errors.Join(restarted...) +} + +func (s *instanceSet) Delete(ctx context.Context) error { + if len(s.keys) == 0 { + return nil + } + err := s.partition.WrapDeletable(Instance{}).Delete(ctx, s.keys.ByUUID()) + if err == nil { + return nil + } + var left []string + results, getErr := (Instance{}).Get(ctx, s.keys.ByUUID()) + if _, ok := errors.AsType[group.ErrRefNotFound](getErr); getErr == nil || ok { + for _, r := range results { + inst := r.(Instance) + left = append(left, cmp.Or(inst.Name, inst.UUID)) + } + } else { + left = s.keys.Strings() + } + if len(left) == 0 { + log.G(ctx).Warn().Err(err). + Msg("the instances are gone, but their delete reported an error") + return nil + } + return fmt.Errorf("%s still exist: %w", strings.Join(left, " "), err) +} + +// withoutRefs returns the keys that none of refs names. +func withoutRefs(keys multimetro.Keys, refs group.Refs) multimetro.Keys { + return slices.DeleteFunc(slices.Clone(keys), func(key multimetro.Key) bool { + return slices.ContainsFunc(refs, func(ref group.Ref) bool { + return cmp.Or(ref.UUID, ref.Name) == cmp.Or(key.UUID, key.Name) + }) + }) +} diff --git a/internal/cmd/instances_test.go b/internal/cmd/instances_test.go index 50a29158..bc74b6f7 100644 --- a/internal/cmd/instances_test.go +++ b/internal/cmd/instances_test.go @@ -244,3 +244,56 @@ func TestInstanceCreateRejectsNetworks(t *testing.T) { }) } } + +func TestInstanceRolloutUnmarshalText(t *testing.T) { + tests := []struct { + name string + in string + want cmd.InstanceRollout + wantErr string + }{ + { + name: "empty", + in: "", + want: cmd.InstanceRollout{}, + }, + { + name: "every key", + in: "type=rolling,batch=3", + want: cmd.InstanceRollout{Type: cmd.RolloutRolling, Batch: 3}, + }, + { + name: "json", + in: `{"type":"rolling","batch":2}`, + want: cmd.InstanceRollout{Type: cmd.RolloutRolling, Batch: 2}, + }, + { + name: "unknown type", + in: "type=sideways", + wantErr: `unknown rollout type "sideways"`, + }, + { + name: "negative batch", + in: "batch=-1", + wantErr: "batch cannot be negative", + }, + { + name: "type without its key", + in: "rolling", + wantErr: `invalid value "rolling": expected =`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got cmd.InstanceRollout + err := got.UnmarshalText([]byte(tt.in)) + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/cmd/run.go b/internal/cmd/run.go index eb50d3ab..74cfbc01 100644 --- a/internal/cmd/run.go +++ b/internal/cmd/run.go @@ -100,6 +100,12 @@ func (InstanceRunCmd) Examples() []kingkong.Example { `unikraft instance run --metro=fra --image=my-app:latest --plugin 'name=sandbox,image=plugins/sandbox:latest,config={"persist_path":"/data"}'`, }, }, + { + Description: "Roll every instance in a service group over to a new image", + Commands: []string{ + "unikraft instance run --image=my-app:v2 --service=api --rollout", + }, + }, } } diff --git a/internal/multimetro/key.go b/internal/multimetro/key.go index a97540a6..2580610b 100644 --- a/internal/multimetro/key.go +++ b/internal/multimetro/key.go @@ -173,3 +173,16 @@ func (ks Keys) Strings() []string { } return ss } + +// ByUUID returns the keys as strings. A key with a UUID gives only its UUID, so +// that a new resource with the same name does not match. +func (ks Keys) ByUUID() []string { + ss := make([]string, 0, len(ks)) + for _, k := range ks { + if k.UUID != "" { + k = Key{Metro: k.Metro, UUID: k.UUID} + } + ss = append(ss, k.String()) + } + return ss +} diff --git a/internal/rollout/rolling.go b/internal/rollout/rolling.go new file mode 100644 index 00000000..ffde5312 --- /dev/null +++ b/internal/rollout/rolling.go @@ -0,0 +1,23 @@ +// 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 rollout + +import ( + "context" +) + +// Rolling replaces the old set one batch at a time, and starts each new batch +// while the old instances still run. +type Rolling struct { + // Batch is how many instances each step replaces. It is at least 1. + Batch int +} + +// Apply stops each old batch when its new batch runs, and deletes the old set +// at the end. +func (r Rolling) Apply(ctx context.Context, old Set, create CreateFunc) (bool, error) { + return apply(ctx, old, create, max(r.Batch, 1)) +} diff --git a/internal/rollout/rollout.go b/internal/rollout/rollout.go new file mode 100644 index 00000000..c5842149 --- /dev/null +++ b/internal/rollout/rollout.go @@ -0,0 +1,162 @@ +// 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 rollout swaps an old set of instances for a new one. +package rollout + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "unikraft.com/x/log" +) + +// stepTimeout bounds each step of a rollout, so a stuck instance cannot hang +// it. +const stepTimeout = 900 * time.Second + +// Set is a group of instances that a rollout acts on as one. +type Set interface { + // Len returns the number of instances. + Len() int + // Take splits the first n instances off the set. + Take(n int) (batch, rest Set) + // Names returns the names of the instances. + Names() []string + // Start starts the instances that must run. + Start(ctx context.Context) error + // Stop stops the instances and waits until they stop. + Stop(ctx context.Context) error + // Up waits until the instances run. It fails when an instance restarted + // or was deleted after the last Up. + Up(ctx context.Context) error + // Delete deletes the instances. + Delete(ctx context.Context) error +} + +// CreateFunc makes the new set that replaces a batch of the old set. The new +// instances do not run yet. +type CreateFunc func(ctx context.Context, replaces Set) (Set, error) + +// Strategy swaps the old set of instances for new sets that create makes. +type Strategy interface { + // Apply reports done when the new sets take the place of the old one. + // When a step before that fails, it undoes the steps before it. + Apply(ctx context.Context, old Set, create CreateFunc) (done bool, err error) +} + +// apply replaces old in batches. Each old batch stops when its new batch +// runs. +func apply(ctx context.Context, old Set, create CreateFunc, batch int) (bool, error) { + p := &progress{old: old} + for rest := old; rest.Len() > 0; { + var next Set + next, rest = rest.Take(batch) + + var created Set + err := within(context.WithoutCancel(ctx), func(ctx context.Context) error { + var err error + created, err = create(ctx, next) + return err + }) + if created != nil { + p.news = append(p.news, created) + } + if err != nil { + return false, p.undo(ctx, err) + } + if ctx.Err() != nil { + return false, p.undo(ctx, fmt.Errorf("rollout interrupted while the new instances were created: %s", ctx.Err())) + } + + if err := within(ctx, created.Start); err != nil { + return false, p.undo(ctx, fmt.Errorf("starting the new instances: %w", err)) + } + if err := within(ctx, created.Up); err != nil { + return false, p.undo(ctx, fmt.Errorf("the new instances did not come up: %w", err)) + } + for _, set := range p.news[:len(p.news)-1] { + if err := within(ctx, set.Up); err != nil { + return false, p.undo(ctx, fmt.Errorf("the new instances did not stay up: %w", err)) + } + } + log.G(ctx).Info(). + Strs("instances", next.Names()). + Msg("stopping the replaced instances") + p.stopped += next.Len() + if err := within(ctx, next.Stop); err != nil { + return false, p.undo(ctx, fmt.Errorf("stopping the replaced instances: %w", err)) + } + } + + if err := within(context.WithoutCancel(ctx), old.Delete); err != nil { + return true, fmt.Errorf("the new instances are up, but deleting the replaced ones failed: %w", err) + } + log.G(ctx).Info(). + Strs("instances", old.Names()). + Msg("replaced instances deleted") + return true, nil +} + +// progress records the steps of a rollout that an undo must reverse. +type progress struct { + old Set + stopped int + news []Set +} + +// undo reverses the steps of a rollout that cause stopped. An interrupt +// cannot stop the undo half way. +func (p *progress) undo(ctx context.Context, cause error) error { + ctx = context.WithoutCancel(ctx) + + var created []string + for _, set := range p.news { + created = append(created, set.Names()...) + } + // Keep only the text of the cause, as the CLI shows no error that wraps a cancel. + errs := []error{errors.New(cause.Error())} + if p.stopped > 0 { + stopped, _ := p.old.Take(p.stopped) + log.G(ctx).Warn(). + Strs("instances", stopped.Names()). + Msg("unwinding the rollout, starting the replaced instances again") + var err error + if err = within(ctx, stopped.Start); err != nil { + err = fmt.Errorf("starting the replaced instances again: %w", err) + } else if err = within(ctx, stopped.Up); err != nil { + err = fmt.Errorf("the replaced instances did not come back: %w", err) + } + if err != nil { + log.G(ctx).Warn(). + Strs("instances", created). + Msg("unwinding the rollout, keeping the new instances") + errs = append(errs, err, fmt.Errorf("the new instances %s are kept", strings.Join(created, " "))) + return errors.Join(errs...) + } + } + if len(created) == 0 { + return errors.Join(errs...) + } + log.G(ctx).Warn(). + Strs("instances", created). + Msg("unwinding the rollout, deleting the new instances") + for _, set := range p.news { + if err := within(ctx, set.Delete); err != nil { + errs = append(errs, fmt.Errorf("deleting the new instances: %w", err)) + } + } + return errors.Join(errs...) +} + +// within runs one step of a rollout with a time limit. +func within(ctx context.Context, step func(context.Context) error) error { + ctx, cancel := context.WithTimeout(ctx, stepTimeout) + defer cancel() + return step(ctx) +} diff --git a/internal/rollout/rollout_test.go b/internal/rollout/rollout_test.go new file mode 100644 index 00000000..17eb08c7 --- /dev/null +++ b/internal/rollout/rollout_test.go @@ -0,0 +1,164 @@ +// 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 rollout_test + +import ( + "context" + "fmt" + "slices" + "testing" + + "github.com/stretchr/testify/assert" + + "unikraft.com/cli/internal/rollout" +) + +type recorder struct { + calls []string + count map[string]int + fail []string + cancelAt string + cancel context.CancelFunc + created int +} + +type fakeSet struct { + name string + lo, hi int + rec *recorder +} + +func (f *fakeSet) do(ctx context.Context, step string) error { + step = fmt.Sprintf("%s[%d:%d].%s", f.name, f.lo, f.hi, step) + if step == f.rec.cancelAt { + f.rec.cancel() + } + call := step + if ctx.Err() != nil { + call += " cancelled" + } + f.rec.calls = append(f.rec.calls, call) + f.rec.count[step]++ + if slices.Contains(f.rec.fail, step) || slices.Contains(f.rec.fail, fmt.Sprintf("%s#%d", step, f.rec.count[step])) { + return fmt.Errorf("%s failed", step) + } + return nil +} + +func (f *fakeSet) Len() int { return f.hi - f.lo } + +func (f *fakeSet) Take(n int) (rollout.Set, rollout.Set) { + mid := min(f.lo+n, f.hi) + return &fakeSet{name: f.name, lo: f.lo, hi: mid, rec: f.rec}, &fakeSet{name: f.name, lo: mid, hi: f.hi, rec: f.rec} +} + +func (f *fakeSet) Names() []string { + var names []string + for i := f.lo; i < f.hi; i++ { + names = append(names, fmt.Sprintf("%s-%d", f.name, i)) + } + return names +} + +func (f *fakeSet) Start(ctx context.Context) error { return f.do(ctx, "start") } +func (f *fakeSet) Stop(ctx context.Context) error { return f.do(ctx, "stop") } +func (f *fakeSet) Up(ctx context.Context) error { return f.do(ctx, "up") } +func (f *fakeSet) Delete(ctx context.Context) error { return f.do(ctx, "delete") } + +// TestApply pins the steps of each strategy and the steps of its undo. +func TestApply(t *testing.T) { + tests := []struct { + name string + strategy rollout.Strategy + fail []string + cancelAt string + calls []string + done bool + err string + }{ + { + name: "rolling", + strategy: rollout.Rolling{}, + calls: []string{ + "new[0:1].create", "new[0:1].start", "new[0:1].up", "old[0:1].stop", + "new[1:2].create", "new[1:2].start", "new[1:2].up", "new[0:1].up", "old[1:2].stop", + "old[0:2].delete", + }, + done: true, + }, + { + name: "rolling in one batch", + strategy: rollout.Rolling{Batch: 5}, + calls: []string{"new[0:2].create", "new[0:2].start", "new[0:2].up", "old[0:2].stop", "old[0:2].delete"}, + done: true, + }, + { + name: "rolling deletes a partial new set", + strategy: rollout.Rolling{}, + fail: []string{"new[0:1].create"}, + calls: []string{"new[0:1].create", "new[0:1].delete"}, + err: "new[0:1].create failed", + }, + { + name: "rolling starts the stopped batches again when a batch is not up", + strategy: rollout.Rolling{}, + fail: []string{"new[1:2].up"}, + calls: []string{ + "new[0:1].create", "new[0:1].start", "new[0:1].up", "old[0:1].stop", + "new[1:2].create", "new[1:2].start", "new[1:2].up", + "old[0:1].start", "old[0:1].up", "new[0:1].delete", "new[1:2].delete", + }, + err: "the new instances did not come up: new[1:2].up failed", + }, + { + name: "rolling starts the stopped batches again when an earlier batch goes down", + strategy: rollout.Rolling{}, + fail: []string{"new[0:1].up#2"}, + calls: []string{ + "new[0:1].create", "new[0:1].start", "new[0:1].up", "old[0:1].stop", + "new[1:2].create", "new[1:2].start", "new[1:2].up", "new[0:1].up", + "old[0:1].start", "old[0:1].up", "new[0:1].delete", "new[1:2].delete", + }, + err: "the new instances did not stay up: new[0:1].up failed", + }, + { + name: "rolling is done when the old set stays", + strategy: rollout.Rolling{Batch: 2}, + fail: []string{"old[0:2].delete"}, + calls: []string{"new[0:2].create", "new[0:2].start", "new[0:2].up", "old[0:2].stop", "old[0:2].delete"}, + done: true, + err: "deleting the replaced ones failed: old[0:2].delete failed", + }, + { + name: "rolling finishes the create before an interrupt", + strategy: rollout.Rolling{}, + cancelAt: "new[0:1].create", + calls: []string{"new[0:1].create", "new[0:1].delete"}, + err: "rollout interrupted while the new instances were created", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + rec := &recorder{count: map[string]int{}, fail: tt.fail, cancelAt: tt.cancelAt, cancel: cancel} + + done, err := tt.strategy.Apply(ctx, &fakeSet{name: "old", hi: 2, rec: rec}, func(ctx context.Context, replaces rollout.Set) (rollout.Set, error) { + set := &fakeSet{name: "new", lo: rec.created, hi: rec.created + replaces.Len(), rec: rec} + rec.created += replaces.Len() + return set, set.do(ctx, "create") + }) + + assert.Equal(t, tt.calls, rec.calls) + assert.Equal(t, tt.done, done) + if tt.err == "" { + assert.NoError(t, err) + } else { + assert.ErrorContains(t, err, tt.err) + } + }) + } +} From 1fcb6b08d571f94af68c46ecf3aea0cb6f2c10c8 Mon Sep 17 00:00:00 2001 From: Cezar Craciunoiu Date: Tue, 3 Nov 2026 13:06:06 +0200 Subject: [PATCH 28/35] feat(cmd): Add a recreate mode to the rollout A rolling rollout runs both sets at once, which a workload with sole access to its volume cannot do, and a new instance that crashes just after it starts takes the group with it. '--rollout=type=recreate' stops the full old set before the new one starts and restores it on failure. 'healthy-after' checks each new batch again after a hold. Signed-off-by: Cezar Craciunoiu --- cmd/unikraft/integration/instance_test.go | 77 +++++++++++++++++++-- cmd/unikraft/testdata/TestHelp/instances | 39 ++++++++--- cmd/unikraft/testdata/TestHelp/run | 7 +- internal/cmd/instances.go | 13 +++- internal/cmd/instances_rollout.go | 26 ++++++-- internal/cmd/instances_test.go | 19 ++++-- internal/rollout/recreate.go | 24 +++++++ internal/rollout/rolling.go | 5 +- internal/rollout/rollout.go | 81 ++++++++++++++++++----- internal/rollout/rollout_test.go | 58 +++++++++++++++- 10 files changed, 302 insertions(+), 47 deletions(-) create mode 100644 internal/rollout/recreate.go diff --git a/cmd/unikraft/integration/instance_test.go b/cmd/unikraft/integration/instance_test.go index 4e0042c5..c52d5338 100644 --- a/cmd/unikraft/integration/instance_test.go +++ b/cmd/unikraft/integration/instance_test.go @@ -1542,13 +1542,13 @@ cmd: ["cat", "/marker.txt"] "--output", "template={{ .image }}", controlName, })) - // A dry run shows the patches of the first batch and the old set, and changes nothing. + // A dry run shows the recreate patches and the old set, and changes nothing. out := r.Run(t, append([]string{ "unikraft", "--log-level=error", "instance", "create", "--image", "nginx:1.25", "--service", svcName, "--autostart", - "--rollout=batch=2", + "--rollout=type=recreate", "--dry-run", }, common...)) assert.Regexp(t, `replicas\s+:= 1`, out) @@ -1572,7 +1572,7 @@ cmd: ["cat", "/marker.txt"] }, common...), integ.ExpectFail()) assert.Contains(t, out, "--name cannot be used with --rollout") - // A rolling rollout replaces one instance at a time. + // A rolling rollout replaces one instance at a time and keeps each new one up for healthy-after first. // A stopped old instance gets a stopped new one. r.Run(t, []string{"unikraft", "instance", "stop", before[0]}) rolled := strings.Fields(r.Run(t, append([]string{ @@ -1580,7 +1580,7 @@ cmd: ["cat", "/marker.txt"] "--output", "template={{ .name }}", "--image", "nginx:1.25", "--service", svcName, - "--rollout", + "--rollout=type=rolling,healthy-after=2s", }, common...))) require.Len(t, rolled, 2) list = r.Run(t, []string{"unikraft", "instance", "list", "--output", "quiet"}) @@ -1597,7 +1597,74 @@ cmd: ["cat", "/marker.txt"] }, rolled...)) assert.ElementsMatch(t, []string{"running", "stopped"}, strings.Fields(states)) - r.Run(t, append([]string{"unikraft", "instance", "delete", controlName}, rolled...)) + // Recreate stops the old set before the new set starts. + recreated := strings.Fields(r.Run(t, append([]string{ + "unikraft", "--log-level=error", "instance", "create", + "--output", "template={{ .name }}", + "--image", "nginx:latest", + "--service", svcName, + "--autostart", + "--rollout=type=recreate", + }, common...))) + require.Len(t, recreated, 2) + list = r.Run(t, []string{"unikraft", "instance", "list", "--output", "quiet"}) + for _, name := range rolled { + assert.NotContains(t, list, name) + } + states = r.Run(t, append([]string{ + "unikraft", "--log-level=error", "instance", "inspect", "--output", "template={{ .state }}", + }, recreated...)) + assert.ElementsMatch(t, []string{"running", "stopped"}, strings.Fields(states)) + + r.Run(t, append([]string{"unikraft", "instance", "delete", controlName}, recreated...)) + r.Run(t, []string{"unikraft", "service", "delete", svcName}) + }) + + t.Run("rollout-rollback", func(t *testing.T) { + r := runner(t, true, []string{staging, stable}) + svcName := "test-" + uniq() + common := []string{ + "--metro", r.MetroName, + "--memory", "128", + "--vcpus", "1", + } + + r.Run(t, []string{ + "unikraft", "service", "create", + "--output", "quiet", + "--name", svcName, + "--metro", r.MetroName, + "--service", "443:8080/http+tls", + }) + before := strings.TrimSpace(r.Run(t, append([]string{ + "unikraft", "instance", "create", + "--output", "template={{ .name }}", + "--name", "test-" + uniq(), + "--image", "nginx:latest", + "--service", svcName, + "--autostart", + }, common...))) + + // The timeout cancels the rollout while it holds the new set up, and the + // unwind starts the stopped old set again. + out := r.Run(t, append([]string{ + "unikraft", "--timeout=90s", "instance", "create", + "--output", "quiet", + "--image", "nginx:1.25", + "--service", svcName, + "--autostart", + "--rollout=type=recreate,healthy-after=300s", + }, common...), integ.ExpectFail()) + assert.Contains(t, out, "rollout interrupted") + + members := r.Run(t, []string{ + "unikraft", "--log-level=error", "service", "get", + "--output", "template={{ range .instances }}{{ .name }} {{ end }}", svcName, + }) + assert.Equal(t, []string{before}, strings.Fields(members)) + assert.Regexp(t, `state:\s+running`, r.Run(t, []string{"unikraft", "instance", "inspect", before})) + + r.Run(t, []string{"unikraft", "instance", "delete", before}) r.Run(t, []string{"unikraft", "service", "delete", svcName}) }) diff --git a/cmd/unikraft/testdata/TestHelp/instances b/cmd/unikraft/testdata/TestHelp/instances index 880ee9ab..44ba6fbf 100644 --- a/cmd/unikraft/testdata/TestHelp/instances +++ b/cmd/unikraft/testdata/TestHelp/instances @@ -434,6 +434,15 @@ Examples: --autostart \ --rollout + # Roll a service group whose instances need sole access to their volume, and + # hold the new ones up for a minute first + unikraft instance create \ + --metro fra \ + --image my-db:v2 \ + --service my-db \ + --autostart \ + --rollout=type=recreate,healthy-after=1m + Fields: metro name @@ -627,9 +636,10 @@ Create flags: Automatically delete the instance when it stops. --rollout== Replace every instance in the service group with a new one and delete the old ones. Requires --autostart. - type: rolling (default) - batch: instances that a rolling rollout replaces at a time (default: 1). - [examples: type=rolling, batch=2] + type: rolling (default) | recreate + batch: instances that a rolling rollout replaces at a time (default: 1) + healthy-after: time each new batch must keep running before the rollout goes on. + [examples: type=recreate, batch=2,healthy-after=30s] $ unikraft instance new --help @@ -677,6 +687,15 @@ Examples: --autostart \ --rollout + # Roll a service group whose instances need sole access to their volume, and + # hold the new ones up for a minute first + unikraft instance create \ + --metro fra \ + --image my-db:v2 \ + --service my-db \ + --autostart \ + --rollout=type=recreate,healthy-after=1m + Fields: metro name @@ -870,9 +889,10 @@ Create flags: Automatically delete the instance when it stops. --rollout== Replace every instance in the service group with a new one and delete the old ones. Requires --autostart. - type: rolling (default) - batch: instances that a rolling rollout replaces at a time (default: 1). - [examples: type=rolling, batch=2] + type: rolling (default) | recreate + batch: instances that a rolling rollout replaces at a time (default: 1) + healthy-after: time each new batch must keep running before the rollout goes on. + [examples: type=recreate, batch=2,healthy-after=30s] $ unikraft instance run --help @@ -1118,9 +1138,10 @@ Create flags: Automatically delete the instance when it stops. --rollout== Replace every instance in the service group with a new one and delete the old ones. Requires --autostart. - type: rolling (default) - batch: instances that a rolling rollout replaces at a time (default: 1). - [examples: type=rolling, batch=2] + type: rolling (default) | recreate + batch: instances that a rolling rollout replaces at a time (default: 1) + healthy-after: time each new batch must keep running before the rollout goes on. + [examples: type=recreate, batch=2,healthy-after=30s] $ unikraft instance edit --help diff --git a/cmd/unikraft/testdata/TestHelp/run b/cmd/unikraft/testdata/TestHelp/run index b8667f43..15f75f91 100644 --- a/cmd/unikraft/testdata/TestHelp/run +++ b/cmd/unikraft/testdata/TestHelp/run @@ -241,6 +241,7 @@ Create flags: Automatically delete the instance when it stops. --rollout== Replace every instance in the service group with a new one and delete the old ones. Requires --autostart. - type: rolling (default) - batch: instances that a rolling rollout replaces at a time (default: 1). - [examples: type=rolling, batch=2] + type: rolling (default) | recreate + batch: instances that a rolling rollout replaces at a time (default: 1) + healthy-after: time each new batch must keep running before the rollout goes on. + [examples: type=recreate, batch=2,healthy-after=30s] diff --git a/internal/cmd/instances.go b/internal/cmd/instances.go index d123ffef..771565bc 100644 --- a/internal/cmd/instances.go +++ b/internal/cmd/instances.go @@ -77,7 +77,7 @@ type InstanceCreateCmd struct { cmd.ModelCreateCmd[Instance] DeleteOnStop bool `group:"flag-create" name:"rm" help:"Automatically delete the instance when it stops."` - Rollout *InstanceRollout `group:"flag-create" name:"rollout" type:"optional" help:"Replace every instance in the service group with a new one and delete the old ones. Requires --autostart.\n type: rolling (default)\n batch: instances that a rolling rollout replaces at a time (default: 1)" placeholder:"=" example:"type=rolling,batch=2"` + Rollout *InstanceRollout `group:"flag-create" name:"rollout" type:"optional" help:"Replace every instance in the service group with a new one and delete the old ones. Requires --autostart.\n type: rolling (default) | recreate\n batch: instances that a rolling rollout replaces at a time (default: 1)\n healthy-after: time each new batch must keep running before the rollout goes on" placeholder:"=" example:"type=recreate,batch=2\\,healthy-after=30s"` } func (c *InstanceCreateCmd) Run(ctx context.Context, stdio config.Stdio, partition *model.Partition) error { @@ -1718,6 +1718,17 @@ func (Instance) Examples() map[cmd.CmdType][]kingkong.Example { --rollout`, }, }, + { + Description: "Roll a service group whose instances need sole access to their volume, and hold the new ones up for a minute first", + Commands: []string{ + `unikraft instance create \ + --metro fra \ + --image my-db:v2 \ + --service my-db \ + --autostart \ + --rollout=type=recreate,healthy-after=1m`, + }, + }, }, cmd.CmdTypeEdit: { { diff --git a/internal/cmd/instances_rollout.go b/internal/cmd/instances_rollout.go index 9fb97ffd..94a74347 100644 --- a/internal/cmd/instances_rollout.go +++ b/internal/cmd/instances_rollout.go @@ -26,12 +26,14 @@ import ( "unikraft.com/cli/internal/model/value" "unikraft.com/cli/internal/multimetro" "unikraft.com/cli/internal/rollout" + "unikraft.com/cli/internal/types" ) // InstanceRollout holds the options of a rollout. type InstanceRollout struct { - Type InstanceRolloutMode `name:"type" json:"type,omitempty"` - Batch int `name:"batch" json:"batch,omitempty"` + Type InstanceRolloutMode `name:"type" json:"type,omitempty"` + Batch int `name:"batch" json:"batch,omitempty"` + HealthyAfter types.DurationS `name:"healthy-after" json:"healthy-after,omitempty"` } // UnmarshalText reads the rollout options as = pairs or as JSON. @@ -41,9 +43,15 @@ func (r *InstanceRollout) UnmarshalText(data []byte) error { if err != nil { return err } + if parsed.HealthyAfter < 0 { + return fmt.Errorf("rollout healthy-after cannot be negative") + } if parsed.Batch < 0 { return fmt.Errorf("rollout batch cannot be negative") } + if parsed.Batch != 0 && parsed.Type == RolloutRecreate { + return fmt.Errorf("rollout batch cannot be used with %q", RolloutRecreate) + } *r = InstanceRollout(parsed) return nil } @@ -54,15 +62,18 @@ type InstanceRolloutMode string const ( // RolloutRolling means the new instances replace the old ones a batch at a time. RolloutRolling InstanceRolloutMode = "rolling" + // RolloutRecreate means the old instances stop before the new ones start. + RolloutRecreate InstanceRolloutMode = "recreate" ) // UnmarshalText reads a rollout type and refuses an unknown one. func (m *InstanceRolloutMode) UnmarshalText(data []byte) error { - if mode := InstanceRolloutMode(data); mode == RolloutRolling { + switch mode := InstanceRolloutMode(data); mode { + case RolloutRolling, RolloutRecreate: *m = mode return nil } - return fmt.Errorf("unknown rollout type %q, want %q", data, RolloutRolling) + return fmt.Errorf("unknown rollout type %q, want %q or %q", data, RolloutRolling, RolloutRecreate) } // rolloutPollInterval is how long a rollout leaves between two reads. @@ -141,8 +152,13 @@ func (c *InstanceCreateCmd) runRollout(ctx context.Context, stdio config.Stdio, } } - var strategy rollout.Strategy = rollout.Rolling{Batch: opts.Batch} + healthyAfter := time.Duration(opts.HealthyAfter) * time.Second + var strategy rollout.Strategy = rollout.Rolling{Batch: opts.Batch, HealthyAfter: healthyAfter} first := min(opts.Batch, len(oldKeys)) + if opts.Type == RolloutRecreate { + strategy = rollout.Recreate{HealthyAfter: healthyAfter} + first = len(oldKeys) + } fieldsFor := func(n int) []model.Field { batch := slices.Clone(fields) for i, field := range batch { diff --git a/internal/cmd/instances_test.go b/internal/cmd/instances_test.go index bc74b6f7..9b6f17c0 100644 --- a/internal/cmd/instances_test.go +++ b/internal/cmd/instances_test.go @@ -17,6 +17,7 @@ import ( "unikraft.com/cli/internal/cmd" "unikraft.com/cli/internal/mirror" "unikraft.com/cli/internal/model/patch" + "unikraft.com/cli/internal/types" ) func TestInstancePluginUnmarshalText(t *testing.T) { @@ -259,24 +260,34 @@ func TestInstanceRolloutUnmarshalText(t *testing.T) { }, { name: "every key", - in: "type=rolling,batch=3", - want: cmd.InstanceRollout{Type: cmd.RolloutRolling, Batch: 3}, + in: "type=rolling,batch=3,healthy-after=30s", + want: cmd.InstanceRollout{Type: cmd.RolloutRolling, Batch: 3, HealthyAfter: types.DurationS(30)}, }, { name: "json", - in: `{"type":"rolling","batch":2}`, - want: cmd.InstanceRollout{Type: cmd.RolloutRolling, Batch: 2}, + in: `{"type":"rolling","healthy-after":"1m"}`, + want: cmd.InstanceRollout{Type: cmd.RolloutRolling, HealthyAfter: types.DurationS(60)}, }, { name: "unknown type", in: "type=sideways", wantErr: `unknown rollout type "sideways"`, }, + { + name: "negative healthy-after", + in: "healthy-after=-30s", + wantErr: "healthy-after cannot be negative", + }, { name: "negative batch", in: "batch=-1", wantErr: "batch cannot be negative", }, + { + name: "batch with recreate", + in: "type=recreate,batch=2", + wantErr: `batch cannot be used with "recreate"`, + }, { name: "type without its key", in: "rolling", diff --git a/internal/rollout/recreate.go b/internal/rollout/recreate.go new file mode 100644 index 00000000..f7b0180c --- /dev/null +++ b/internal/rollout/recreate.go @@ -0,0 +1,24 @@ +// 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 rollout + +import ( + "context" + "time" +) + +// Recreate stops the full old set before the new set starts, so the two sets +// never run at the same time. +type Recreate struct { + // HealthyAfter is how long the new set must run before the old set goes. + HealthyAfter time.Duration +} + +// Apply creates the new set, stops the old set, starts the new set and +// deletes the old set when the new one runs. +func (r Recreate) Apply(ctx context.Context, old Set, create CreateFunc) (bool, error) { + return apply(ctx, old, create, old.Len(), true, r.HealthyAfter) +} diff --git a/internal/rollout/rolling.go b/internal/rollout/rolling.go index ffde5312..16e795ad 100644 --- a/internal/rollout/rolling.go +++ b/internal/rollout/rolling.go @@ -7,6 +7,7 @@ package rollout import ( "context" + "time" ) // Rolling replaces the old set one batch at a time, and starts each new batch @@ -14,10 +15,12 @@ import ( type Rolling struct { // Batch is how many instances each step replaces. It is at least 1. Batch int + // HealthyAfter is how long each new batch must run before its old batch stops. + HealthyAfter time.Duration } // Apply stops each old batch when its new batch runs, and deletes the old set // at the end. func (r Rolling) Apply(ctx context.Context, old Set, create CreateFunc) (bool, error) { - return apply(ctx, old, create, max(r.Batch, 1)) + return apply(ctx, old, create, max(r.Batch, 1), false, r.HealthyAfter) } diff --git a/internal/rollout/rollout.go b/internal/rollout/rollout.go index c5842149..115ed648 100644 --- a/internal/rollout/rollout.go +++ b/internal/rollout/rollout.go @@ -50,10 +50,10 @@ type Strategy interface { Apply(ctx context.Context, old Set, create CreateFunc) (done bool, err error) } -// apply replaces old in batches. Each old batch stops when its new batch -// runs. -func apply(ctx context.Context, old Set, create CreateFunc, batch int) (bool, error) { - p := &progress{old: old} +// apply replaces old in batches. With stopFirst, each old batch stops before +// its new batch starts, and else after the new batch runs. +func apply(ctx context.Context, old Set, create CreateFunc, batch int, stopFirst bool, healthyAfter time.Duration) (bool, error) { + p := &progress{old: old, stopFirst: stopFirst} for rest := old; rest.Len() > 0; { var next Set next, rest = rest.Take(batch) @@ -74,23 +74,35 @@ func apply(ctx context.Context, old Set, create CreateFunc, batch int) (bool, er return false, p.undo(ctx, fmt.Errorf("rollout interrupted while the new instances were created: %s", ctx.Err())) } + if stopFirst { + if err := p.stop(ctx, next); err != nil { + return false, p.undo(ctx, err) + } + } + p.started = len(p.news) if err := within(ctx, created.Start); err != nil { return false, p.undo(ctx, fmt.Errorf("starting the new instances: %w", err)) } if err := within(ctx, created.Up); err != nil { return false, p.undo(ctx, fmt.Errorf("the new instances did not come up: %w", err)) } - for _, set := range p.news[:len(p.news)-1] { - if err := within(ctx, set.Up); err != nil { + if healthyAfter > 0 { + log.G(ctx).Info(). + Str("healthy-after", healthyAfter.String()). + Msg("waiting for healthy") + select { + case <-ctx.Done(): + return false, p.undo(ctx, fmt.Errorf("rollout interrupted while the new instances were up: %s", ctx.Err())) + case <-time.After(healthyAfter): + } + if err := within(ctx, created.Up); err != nil { return false, p.undo(ctx, fmt.Errorf("the new instances did not stay up: %w", err)) } } - log.G(ctx).Info(). - Strs("instances", next.Names()). - Msg("stopping the replaced instances") - p.stopped += next.Len() - if err := within(ctx, next.Stop); err != nil { - return false, p.undo(ctx, fmt.Errorf("stopping the replaced instances: %w", err)) + if !stopFirst { + if err := p.stop(ctx, next); err != nil { + return false, p.undo(ctx, err) + } } } @@ -105,9 +117,29 @@ func apply(ctx context.Context, old Set, create CreateFunc, batch int) (bool, er // progress records the steps of a rollout that an undo must reverse. type progress struct { - old Set - stopped int - news []Set + old Set + stopFirst bool + stopped int + news []Set + started int +} + +// stop stops a batch of the old set when the earlier new sets still run. The +// undo starts the batch again. +func (p *progress) stop(ctx context.Context, batch Set) error { + for _, set := range p.news[:len(p.news)-1] { + if err := within(ctx, set.Up); err != nil { + return fmt.Errorf("the new instances did not stay up: %w", err) + } + } + log.G(ctx).Info(). + Strs("instances", batch.Names()). + Msg("stopping the replaced instances") + p.stopped += batch.Len() + if err := within(ctx, batch.Stop); err != nil { + return fmt.Errorf("stopping the replaced instances: %w", err) + } + return nil } // undo reverses the steps of a rollout that cause stopped. An interrupt @@ -122,6 +154,13 @@ func (p *progress) undo(ctx context.Context, cause error) error { // Keep only the text of the cause, as the CLI shows no error that wraps a cancel. errs := []error{errors.New(cause.Error())} if p.stopped > 0 { + if p.stopFirst { + for _, set := range p.news[:p.started] { + if err := within(ctx, set.Stop); err != nil { + errs = append(errs, fmt.Errorf("stopping the new instances: %w", err)) + } + } + } stopped, _ := p.old.Take(p.stopped) log.G(ctx).Warn(). Strs("instances", stopped.Names()). @@ -133,10 +172,20 @@ func (p *progress) undo(ctx context.Context, cause error) error { err = fmt.Errorf("the replaced instances did not come back: %w", err) } if err != nil { + errs = append(errs, err) log.G(ctx).Warn(). Strs("instances", created). Msg("unwinding the rollout, keeping the new instances") - errs = append(errs, err, fmt.Errorf("the new instances %s are kept", strings.Join(created, " "))) + kept := p.news[p.started:] + if p.stopFirst { + kept = p.news + } + for _, set := range kept { + if err := within(ctx, set.Start); err != nil { + errs = append(errs, fmt.Errorf("starting the kept new instances: %w", err)) + } + } + errs = append(errs, fmt.Errorf("the new instances %s are kept", strings.Join(created, " "))) return errors.Join(errs...) } } diff --git a/internal/rollout/rollout_test.go b/internal/rollout/rollout_test.go index 17eb08c7..426a0928 100644 --- a/internal/rollout/rollout_test.go +++ b/internal/rollout/rollout_test.go @@ -10,6 +10,7 @@ import ( "fmt" "slices" "testing" + "time" "github.com/stretchr/testify/assert" @@ -90,9 +91,9 @@ func TestApply(t *testing.T) { done: true, }, { - name: "rolling in one batch", - strategy: rollout.Rolling{Batch: 5}, - calls: []string{"new[0:2].create", "new[0:2].start", "new[0:2].up", "old[0:2].stop", "old[0:2].delete"}, + name: "rolling in one batch holds the new set up", + strategy: rollout.Rolling{Batch: 5, HealthyAfter: time.Millisecond}, + calls: []string{"new[0:2].create", "new[0:2].start", "new[0:2].up", "new[0:2].up", "old[0:2].stop", "old[0:2].delete"}, done: true, }, { @@ -124,6 +125,17 @@ func TestApply(t *testing.T) { }, err: "the new instances did not stay up: new[0:1].up failed", }, + { + name: "rolling starts the kept batch that did not run when the restore fails", + strategy: rollout.Rolling{}, + fail: []string{"old[0:1].start"}, + cancelAt: "new[1:2].create", + calls: []string{ + "new[0:1].create", "new[0:1].start", "new[0:1].up", "old[0:1].stop", + "new[1:2].create", "old[0:1].start", "new[1:2].start", + }, + err: "the new instances new-0 new-1 are kept", + }, { name: "rolling is done when the old set stays", strategy: rollout.Rolling{Batch: 2}, @@ -139,6 +151,46 @@ func TestApply(t *testing.T) { calls: []string{"new[0:1].create", "new[0:1].delete"}, err: "rollout interrupted while the new instances were created", }, + { + name: "rolling undoes an interrupt", + strategy: rollout.Rolling{HealthyAfter: time.Hour}, + cancelAt: "new[0:1].up", + calls: []string{"new[0:1].create", "new[0:1].start", "new[0:1].up cancelled", "new[0:1].delete"}, + err: "rollout interrupted while the new instances were up", + }, + { + name: "recreate", + strategy: rollout.Recreate{}, + calls: []string{"new[0:2].create", "old[0:2].stop", "new[0:2].start", "new[0:2].up", "old[0:2].delete"}, + done: true, + }, + { + name: "recreate restores an old set that does not stop", + strategy: rollout.Recreate{}, + fail: []string{"old[0:2].stop"}, + calls: []string{"new[0:2].create", "old[0:2].stop", "old[0:2].start", "old[0:2].up", "new[0:2].delete"}, + err: "stopping the replaced instances: old[0:2].stop failed", + }, + { + name: "recreate restores the old set when the new set is not up", + strategy: rollout.Recreate{}, + fail: []string{"new[0:2].up"}, + calls: []string{ + "new[0:2].create", "old[0:2].stop", "new[0:2].start", "new[0:2].up", + "new[0:2].stop", "old[0:2].start", "old[0:2].up", "new[0:2].delete", + }, + err: "the new instances did not come up", + }, + { + name: "recreate keeps the new set running when the restore fails", + strategy: rollout.Recreate{}, + fail: []string{"new[0:2].up", "old[0:2].start"}, + calls: []string{ + "new[0:2].create", "old[0:2].stop", "new[0:2].start", "new[0:2].up", + "new[0:2].stop", "old[0:2].start", "new[0:2].start", + }, + err: "the new instances new-0 new-1 are kept", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { From 96eaf20946d78ca41637984f4dba13980187e54e Mon Sep 17 00:00:00 2001 From: Cezar Craciunoiu Date: Tue, 3 Nov 2026 13:06:07 +0200 Subject: [PATCH 29/35] feat(cmd): Select a rollout by tags A rollout could only replace the instances of one service group, so instances that share a role through their tags, but not a service group, still had to be swapped by hand. '--rollout=by=tags' replaces every instance in the metro that has all the tags of the new instance, and the new set keeps those tags for the next rollout. An autoscale group on either side is refused. Signed-off-by: Cezar Craciunoiu --- cmd/unikraft/integration/instance_test.go | 52 +++++++ cmd/unikraft/testdata/TestHelp/instances | 15 +- cmd/unikraft/testdata/TestHelp/run | 5 +- internal/cmd/instances.go | 2 +- internal/cmd/instances_rollout.go | 163 ++++++++++++++++++---- internal/cmd/instances_test.go | 9 +- 6 files changed, 210 insertions(+), 36 deletions(-) diff --git a/cmd/unikraft/integration/instance_test.go b/cmd/unikraft/integration/instance_test.go index c52d5338..8d721167 100644 --- a/cmd/unikraft/integration/instance_test.go +++ b/cmd/unikraft/integration/instance_test.go @@ -1620,6 +1620,58 @@ cmd: ["cat", "/marker.txt"] r.Run(t, []string{"unikraft", "service", "delete", svcName}) }) + t.Run("rollout-by-tags", func(t *testing.T) { + r := runner(t, true, []string{staging, stable}) + tag := "t-" + uniq() + common := []string{ + "--metro", r.MetroName, + "--memory", "128", + "--vcpus", "1", + } + + before := strings.Fields(r.Run(t, append([]string{ + "unikraft", "instance", "create", + "--output", "template={{ .name }}", + "--name", "test-" + uniq(), + "--image", "nginx:latest", + "--tag", tag, + "--autostart", + "--replicas", "1", + }, common...))) + require.Len(t, before, 2) + + controlName := "test-" + uniq() + r.Run(t, append([]string{ + "unikraft", "instance", "create", + "--output", "quiet", + "--name", controlName, + "--image", "nginx:latest", + }, common...)) + + rolled := strings.Fields(r.Run(t, append([]string{ + "unikraft", "--log-level=error", "instance", "create", + "--output", "template={{ .name }}", + "--image", "nginx:1.25", + "--tag", tag, + "--autostart", + "--rollout=by=tags,batch=2", + }, common...))) + require.Len(t, rolled, 2) + + list := r.Run(t, []string{"unikraft", "instance", "list", "--output", "quiet"}) + for _, name := range before { + assert.NotContains(t, list, name) + } + assert.Contains(t, list, controlName) + for _, name := range rolled { + inspected := r.Run(t, []string{"unikraft", "instance", "inspect", name}) + assert.Regexp(t, `state:\s+running`, inspected) + assert.Regexp(t, `tags:.*`+tag, inspected) + } + + r.Run(t, append([]string{"unikraft", "instance", "delete", controlName}, rolled...)) + }) + t.Run("rollout-rollback", func(t *testing.T) { r := runner(t, true, []string{staging, stable}) svcName := "test-" + uniq() diff --git a/cmd/unikraft/testdata/TestHelp/instances b/cmd/unikraft/testdata/TestHelp/instances index 44ba6fbf..532d3db3 100644 --- a/cmd/unikraft/testdata/TestHelp/instances +++ b/cmd/unikraft/testdata/TestHelp/instances @@ -635,11 +635,12 @@ Create flags: --rm Automatically delete the instance when it stops. --rollout== - Replace every instance in the service group with a new one and delete the old ones. Requires --autostart. + Replace every instance the rollout selects with a new one and delete the old ones. Requires --autostart. type: rolling (default) | recreate + by: service (default) | tags batch: instances that a rolling rollout replaces at a time (default: 1) healthy-after: time each new batch must keep running before the rollout goes on. - [examples: type=recreate, batch=2,healthy-after=30s] + [examples: type=recreate, batch=2,healthy-after=30s, by=tags] $ unikraft instance new --help @@ -888,11 +889,12 @@ Create flags: --rm Automatically delete the instance when it stops. --rollout== - Replace every instance in the service group with a new one and delete the old ones. Requires --autostart. + Replace every instance the rollout selects with a new one and delete the old ones. Requires --autostart. type: rolling (default) | recreate + by: service (default) | tags batch: instances that a rolling rollout replaces at a time (default: 1) healthy-after: time each new batch must keep running before the rollout goes on. - [examples: type=recreate, batch=2,healthy-after=30s] + [examples: type=recreate, batch=2,healthy-after=30s, by=tags] $ unikraft instance run --help @@ -1137,11 +1139,12 @@ Create flags: --rm Automatically delete the instance when it stops. --rollout== - Replace every instance in the service group with a new one and delete the old ones. Requires --autostart. + Replace every instance the rollout selects with a new one and delete the old ones. Requires --autostart. type: rolling (default) | recreate + by: service (default) | tags batch: instances that a rolling rollout replaces at a time (default: 1) healthy-after: time each new batch must keep running before the rollout goes on. - [examples: type=recreate, batch=2,healthy-after=30s] + [examples: type=recreate, batch=2,healthy-after=30s, by=tags] $ unikraft instance edit --help diff --git a/cmd/unikraft/testdata/TestHelp/run b/cmd/unikraft/testdata/TestHelp/run index 15f75f91..a960fb13 100644 --- a/cmd/unikraft/testdata/TestHelp/run +++ b/cmd/unikraft/testdata/TestHelp/run @@ -240,8 +240,9 @@ Create flags: --rm Automatically delete the instance when it stops. --rollout== - Replace every instance in the service group with a new one and delete the old ones. Requires --autostart. + Replace every instance the rollout selects with a new one and delete the old ones. Requires --autostart. type: rolling (default) | recreate + by: service (default) | tags batch: instances that a rolling rollout replaces at a time (default: 1) healthy-after: time each new batch must keep running before the rollout goes on. - [examples: type=recreate, batch=2,healthy-after=30s] + [examples: type=recreate, batch=2,healthy-after=30s, by=tags] diff --git a/internal/cmd/instances.go b/internal/cmd/instances.go index 771565bc..0b112fc9 100644 --- a/internal/cmd/instances.go +++ b/internal/cmd/instances.go @@ -77,7 +77,7 @@ type InstanceCreateCmd struct { cmd.ModelCreateCmd[Instance] DeleteOnStop bool `group:"flag-create" name:"rm" help:"Automatically delete the instance when it stops."` - Rollout *InstanceRollout `group:"flag-create" name:"rollout" type:"optional" help:"Replace every instance in the service group with a new one and delete the old ones. Requires --autostart.\n type: rolling (default) | recreate\n batch: instances that a rolling rollout replaces at a time (default: 1)\n healthy-after: time each new batch must keep running before the rollout goes on" placeholder:"=" example:"type=recreate,batch=2\\,healthy-after=30s"` + Rollout *InstanceRollout `group:"flag-create" name:"rollout" type:"optional" help:"Replace every instance the rollout selects with a new one and delete the old ones. Requires --autostart.\n type: rolling (default) | recreate\n by: service (default) | tags\n batch: instances that a rolling rollout replaces at a time (default: 1)\n healthy-after: time each new batch must keep running before the rollout goes on" placeholder:"=" example:"type=recreate,batch=2\\,healthy-after=30s,by=tags"` } func (c *InstanceCreateCmd) Run(ctx context.Context, stdio config.Stdio, partition *model.Partition) error { diff --git a/internal/cmd/instances_rollout.go b/internal/cmd/instances_rollout.go index 94a74347..b5892ce6 100644 --- a/internal/cmd/instances_rollout.go +++ b/internal/cmd/instances_rollout.go @@ -31,9 +31,10 @@ import ( // InstanceRollout holds the options of a rollout. type InstanceRollout struct { - Type InstanceRolloutMode `name:"type" json:"type,omitempty"` - Batch int `name:"batch" json:"batch,omitempty"` - HealthyAfter types.DurationS `name:"healthy-after" json:"healthy-after,omitempty"` + Type InstanceRolloutMode `name:"type" json:"type,omitempty"` + By InstanceRolloutSelector `name:"by" json:"by,omitempty"` + Batch int `name:"batch" json:"batch,omitempty"` + HealthyAfter types.DurationS `name:"healthy-after" json:"healthy-after,omitempty"` } // UnmarshalText reads the rollout options as = pairs or as JSON. @@ -76,14 +77,35 @@ func (m *InstanceRolloutMode) UnmarshalText(data []byte) error { return fmt.Errorf("unknown rollout type %q, want %q or %q", data, RolloutRolling, RolloutRecreate) } +// InstanceRolloutSelector is the field of the new instance that selects the old set. +type InstanceRolloutSelector string + +const ( + // RolloutByService selects the instances in the service group of the new one. + RolloutByService InstanceRolloutSelector = "service" + // RolloutByTags selects the instances that have every tag of the new one. + RolloutByTags InstanceRolloutSelector = "tags" +) + +// UnmarshalText reads a rollout selector and refuses an unknown one. +func (b *InstanceRolloutSelector) UnmarshalText(data []byte) error { + switch by := InstanceRolloutSelector(data); by { + case RolloutByService, RolloutByTags: + *b = by + return nil + } + return fmt.Errorf("unknown rollout by %q, want %q or %q", data, RolloutByService, RolloutByTags) +} + // rolloutPollInterval is how long a rollout leaves between two reads. const rolloutPollInterval = 2 * time.Second -// runRollout replaces every instance in the service group with a new one, and +// runRollout replaces every instance the rollout selects with a new one, and // deletes the old set once the new one runs. func (c *InstanceCreateCmd) runRollout(ctx context.Context, stdio config.Stdio, partition *model.Partition) ([]model.Model, error) { opts := *c.Rollout opts.Type = cmp.Or(opts.Type, RolloutRolling) + opts.By = cmp.Or(opts.By, RolloutByService) opts.Batch = cmp.Or(opts.Batch, 1) fields, err := c.CreateFields(ctx) @@ -92,6 +114,7 @@ func (c *InstanceCreateCmd) runRollout(ctx context.Context, stdio config.Stdio, } var metro string var svc *InstanceService + var tags []string named, replicas, autostart := false, false, false for key, field := range model.IterFields(fields) { if field.Create == nil || field.Create.Set == nil { @@ -108,6 +131,8 @@ func (c *InstanceCreateCmd) runRollout(ctx context.Context, stdio config.Stdio, autostart = field.Create.Set.(bool) case "service": svc = field.Create.Set.(*InstanceService) + case "tags": + tags = field.Create.Set.([]string) } } if named { @@ -119,24 +144,38 @@ func (c *InstanceCreateCmd) runRollout(ctx context.Context, stdio config.Stdio, if !autostart { return nil, fmt.Errorf("--rollout requires --autostart") } - if svc == nil || (svc.Name == "" && svc.UUID == "") { - return nil, fmt.Errorf("--rollout requires an existing service group") + var groups []ServiceGroup + var old []Instance + selected := strings.Join(tags, ",") + switch opts.By { + case RolloutByService: + if svc == nil || (svc.Name == "" && svc.UUID == "") { + return nil, fmt.Errorf("--rollout requires an existing service group") + } + selected = cmp.Or(svc.Name, svc.UUID) + groups, old, err = rolloutTargetService(ctx, metro, svc) + case RolloutByTags: + if len(tags) == 0 { + return nil, fmt.Errorf("--rollout=by=tags requires --tag") + } + groups, old, err = rolloutTargetTags(ctx, metro, svc, tags) } - - svcGroup, old, err := rolloutTargetService(ctx, metro, svc) if err != nil { if !c.DryRun { return nil, err } log.G(ctx).Warn().Err(err). - Msg("cannot read the service group, replaced instances not shown") - } else if svcGroup.Autoscale { - return nil, fmt.Errorf("autoscaling enabled, can't rollout service group %q", cmp.Or(svcGroup.Name, svcGroup.UUID)) + Msg("cannot read the instances to replace, replaced instances not shown") } old = slices.DeleteFunc(old, func(inst Instance) bool { return partition.Missing(inst) }) + for _, svcGroup := range groups { + if svcGroup.Autoscale { + return nil, fmt.Errorf("autoscaling enabled, can't rollout service group %q", cmp.Or(svcGroup.Name, svcGroup.UUID)) + } + } if err == nil && len(old) == 0 { log.G(ctx).Info(). - Str("service", svcGroup.Name). + Str(string(opts.By), selected). Msg("no instances to replace, creating without a rollout") return c.Create(ctx, stdio, partition, fields) } @@ -189,7 +228,7 @@ func (c *InstanceCreateCmd) runRollout(ctx context.Context, stdio config.Stdio, return nil, err } log.G(ctx).Info(). - Str("service", svcGroup.Name). + Str(string(opts.By), selected). Str("mode", string(opts.Type)). Int("instances", len(oldKeys)). Msg("rolling out") @@ -237,18 +276,14 @@ func (c *InstanceCreateCmd) runRollout(ctx context.Context, stdio config.Stdio, // rolloutTargetService reads back the service group whose instances a rollout // replaces, and those instances. -func rolloutTargetService(ctx context.Context, metro string, svc *InstanceService) (ServiceGroup, []Instance, error) { - var none ServiceGroup - key := multimetro.Key{Metro: cmp.Or(svc.Metro, metro), UUID: svc.UUID} - if svc.UUID == "" { - key.Name = svc.Name - } - results, err := (ServiceGroup{}).Get(ctx, []string{key.Canonical()}) +func rolloutTargetService(ctx context.Context, metro string, svc *InstanceService) ([]ServiceGroup, []Instance, error) { + key := serviceGroupKey(svc, metro) + results, err := (ServiceGroup{}).Get(ctx, []string{key}) if err != nil { - return none, nil, fmt.Errorf("looking up service group %q: %w", key.Canonical(), err) + return nil, nil, fmt.Errorf("looking up service group %q: %w", key, err) } if len(results) == 0 { - return none, nil, fmt.Errorf("service group %q not found", key.Canonical()) + return nil, nil, fmt.Errorf("service group %q not found", key) } svcGroup := results[0].(ServiceGroup) @@ -265,17 +300,95 @@ func rolloutTargetService(ctx context.Context, metro string, svc *InstanceServic keys = append(keys, key) } if len(keys) == 0 { - return svcGroup, nil, nil + return []ServiceGroup{svcGroup}, nil, nil } results, err = (Instance{}).Get(ctx, keys.Strings()) if err != nil { - return none, nil, fmt.Errorf("reading the instances of service group %q: %w", svcGroup.Name, err) + return nil, nil, fmt.Errorf("reading the instances of service group %q: %w", svcGroup.Name, err) } old := make([]Instance, len(results)) for i, r := range results { old[i] = r.(Instance) } - return svcGroup, old, nil + return []ServiceGroup{svcGroup}, old, nil +} + +// rolloutTargetTags reads back the instances that have every tag, and the +// service groups that the old and the new instances are in. +func rolloutTargetTags(ctx context.Context, metro string, svc *InstanceService, tags []string) ([]ServiceGroup, []Instance, error) { + if metro == "" { + return nil, nil, fmt.Errorf("--rollout=by=tags requires --metro") + } + g, err := multimetro.NewClient(ctx) + if err != nil { + return nil, nil, err + } + profile, err := config.G(ctx).CurrentProfile() + if err != nil { + return nil, nil, err + } + old, err := group.CollectMetro(ctx, g, metro, func(ctx context.Context, c multimetro.MetroClient) ([]Instance, error) { + resp, err := c.GetInstances(ctx, nil, platform.GetInstancesOpts{Details: new(true), Tags: tags}) + if err != nil { + return nil, err + } + if resp == nil || resp.Data == nil { + return nil, nil + } + var found []Instance + for _, instance := range resp.Data.Instances { + inst, err := Instance{}.load(nil, instance, &c.Metro, profile) + if err != nil { + return nil, err + } + missing := slices.ContainsFunc(tags, func(tag string) bool { + return !slices.Contains(inst.Tags, tag) + }) + if !missing { + found = append(found, inst) + } + } + return found, nil + }) + if err != nil { + return nil, nil, fmt.Errorf("listing the instances with tags %s: %w", strings.Join(tags, ","), err) + } + + links := []*InstanceService{svc} + for _, inst := range old { + links = append(links, inst.Service) + } + var keys []string + for _, link := range links { + if link == nil || (link.Name == "" && link.UUID == "") { + continue + } + keys = append(keys, serviceGroupKey(link, metro)) + } + slices.Sort(keys) + keys = slices.Compact(keys) + if len(keys) == 0 { + return nil, old, nil + } + results, err := (ServiceGroup{}).Get(ctx, keys) + if err != nil { + return nil, nil, fmt.Errorf("reading the service groups of the instances with tags %s: %w", strings.Join(tags, ","), err) + } + groups := make([]ServiceGroup, len(results)) + for i, r := range results { + groups[i] = r.(ServiceGroup) + } + return groups, old, nil +} + +// serviceGroupKey keys the service group a link names, by its UUID when the +// link has one. +func serviceGroupKey(link *InstanceService, metro string) string { + key := multimetro.Key{Metro: cmp.Or(link.Metro, metro), UUID: link.UUID} + if link.UUID == "" { + key.Name = link.Name + } + return key.Canonical() } // instanceSet is a set of instances that a rollout acts on. Start and Up act diff --git a/internal/cmd/instances_test.go b/internal/cmd/instances_test.go index 9b6f17c0..52259865 100644 --- a/internal/cmd/instances_test.go +++ b/internal/cmd/instances_test.go @@ -260,8 +260,8 @@ func TestInstanceRolloutUnmarshalText(t *testing.T) { }, { name: "every key", - in: "type=rolling,batch=3,healthy-after=30s", - want: cmd.InstanceRollout{Type: cmd.RolloutRolling, Batch: 3, HealthyAfter: types.DurationS(30)}, + in: "type=rolling,by=tags,batch=3,healthy-after=30s", + want: cmd.InstanceRollout{Type: cmd.RolloutRolling, By: cmd.RolloutByTags, Batch: 3, HealthyAfter: types.DurationS(30)}, }, { name: "json", @@ -273,6 +273,11 @@ func TestInstanceRolloutUnmarshalText(t *testing.T) { in: "type=sideways", wantErr: `unknown rollout type "sideways"`, }, + { + name: "unknown selector", + in: "by=sideways", + wantErr: `unknown rollout by "sideways"`, + }, { name: "negative healthy-after", in: "healthy-after=-30s", From 2883ec17543e94a3409af124f3f9606a56ef758e Mon Sep 17 00:00:00 2001 From: Cezar Craciunoiu Date: Mon, 12 Oct 2026 14:23:52 +0300 Subject: [PATCH 30/35] feat(cmd): Add a target to the rollout A rollout selected the old instances only by every tag of the new instance or by its service. A new instance with an added tag, such as a version, selected no old instance to replace. The new 'target' key of '--rollout' takes one service or tag, or a JSON list. It selects only by the new instance's service or by a subset of its tags, and refuses a value that the new instance lacks. Signed-off-by: Cezar Craciunoiu --- cmd/unikraft/integration/instance_test.go | 47 ++++++++++++++++++++++- cmd/unikraft/testdata/TestHelp/instances | 29 ++++++++++++-- cmd/unikraft/testdata/TestHelp/run | 3 +- internal/cmd/instances.go | 14 ++++++- internal/cmd/instances_rollout.go | 29 ++++++++++++-- internal/cmd/instances_test.go | 14 ++++++- 6 files changed, 123 insertions(+), 13 deletions(-) diff --git a/cmd/unikraft/integration/instance_test.go b/cmd/unikraft/integration/instance_test.go index 8d721167..3204563b 100644 --- a/cmd/unikraft/integration/instance_test.go +++ b/cmd/unikraft/integration/instance_test.go @@ -1597,6 +1597,15 @@ cmd: ["cat", "/marker.txt"] }, rolled...)) assert.ElementsMatch(t, []string{"running", "stopped"}, strings.Fields(states)) + out = r.Run(t, append([]string{ + "unikraft", "--log-level=error", "instance", "create", + "--image", "nginx:latest", + "--service", svcName, + "--autostart", + "--rollout=target=test-" + uniq(), + }, common...), integ.ExpectFail()) + assert.Contains(t, out, "is not a service of the new instance") + // Recreate stops the old set before the new set starts. recreated := strings.Fields(r.Run(t, append([]string{ "unikraft", "--log-level=error", "instance", "create", @@ -1604,7 +1613,7 @@ cmd: ["cat", "/marker.txt"] "--image", "nginx:latest", "--service", svcName, "--autostart", - "--rollout=type=recreate", + "--rollout=type=recreate,target=" + svcName, }, common...))) require.Len(t, recreated, 2) list = r.Run(t, []string{"unikraft", "instance", "list", "--output", "quiet"}) @@ -1669,7 +1678,41 @@ cmd: ["cat", "/marker.txt"] assert.Regexp(t, `tags:.*`+tag, inspected) } - r.Run(t, append([]string{"unikraft", "instance", "delete", controlName}, rolled...)) + version := "v-" + uniq() + out := r.Run(t, append([]string{ + "unikraft", "--log-level=error", "instance", "create", + "--image", "nginx:latest", + "--tag", tag, + "--tag", version, + "--autostart", + "--rollout=by=tags,target=t-" + uniq(), + }, common...), integ.ExpectFail()) + assert.Contains(t, out, "is not a tag of the new instance") + + versioned := strings.Fields(r.Run(t, append([]string{ + "unikraft", "--log-level=error", "instance", "create", + "--output", "template={{ .name }}", + "--image", "nginx:latest", + "--tag", tag, + "--tag", version, + "--autostart", + "--rollout=by=tags,target=" + tag, + }, common...))) + require.Len(t, versioned, 2) + + list = r.Run(t, []string{"unikraft", "instance", "list", "--output", "quiet"}) + for _, name := range rolled { + assert.NotContains(t, list, name) + } + assert.Contains(t, list, controlName) + for _, name := range versioned { + inspected := r.Run(t, []string{"unikraft", "instance", "inspect", name}) + assert.Regexp(t, `state:\s+running`, inspected) + assert.Regexp(t, `tags:.*`+tag, inspected) + assert.Regexp(t, `tags:.*`+version, inspected) + } + + r.Run(t, append([]string{"unikraft", "instance", "delete", controlName}, versioned...)) }) t.Run("rollout-rollback", func(t *testing.T) { diff --git a/cmd/unikraft/testdata/TestHelp/instances b/cmd/unikraft/testdata/TestHelp/instances index 532d3db3..f4179510 100644 --- a/cmd/unikraft/testdata/TestHelp/instances +++ b/cmd/unikraft/testdata/TestHelp/instances @@ -443,6 +443,16 @@ Examples: --autostart \ --rollout=type=recreate,healthy-after=1m + # Replace the app-web instances two at a time with ones that also carry a + # version tag + unikraft instance create \ + --metro fra \ + --image my-app:v2 \ + --tag app-web \ + --tag v2 \ + --autostart \ + --rollout=by=tags,target=app-web,batch=2 + Fields: metro name @@ -638,9 +648,10 @@ Create flags: Replace every instance the rollout selects with a new one and delete the old ones. Requires --autostart. type: rolling (default) | recreate by: service (default) | tags + target: the new instance's service, or a subset of its tags, to select by (default: all of them) batch: instances that a rolling rollout replaces at a time (default: 1) healthy-after: time each new batch must keep running before the rollout goes on. - [examples: type=recreate, batch=2,healthy-after=30s, by=tags] + [examples: type=recreate, batch=2,healthy-after=30s, by=tags,target=env-prod] $ unikraft instance new --help @@ -697,6 +708,16 @@ Examples: --autostart \ --rollout=type=recreate,healthy-after=1m + # Replace the app-web instances two at a time with ones that also carry a + # version tag + unikraft instance create \ + --metro fra \ + --image my-app:v2 \ + --tag app-web \ + --tag v2 \ + --autostart \ + --rollout=by=tags,target=app-web,batch=2 + Fields: metro name @@ -892,9 +913,10 @@ Create flags: Replace every instance the rollout selects with a new one and delete the old ones. Requires --autostart. type: rolling (default) | recreate by: service (default) | tags + target: the new instance's service, or a subset of its tags, to select by (default: all of them) batch: instances that a rolling rollout replaces at a time (default: 1) healthy-after: time each new batch must keep running before the rollout goes on. - [examples: type=recreate, batch=2,healthy-after=30s, by=tags] + [examples: type=recreate, batch=2,healthy-after=30s, by=tags,target=env-prod] $ unikraft instance run --help @@ -1142,9 +1164,10 @@ Create flags: Replace every instance the rollout selects with a new one and delete the old ones. Requires --autostart. type: rolling (default) | recreate by: service (default) | tags + target: the new instance's service, or a subset of its tags, to select by (default: all of them) batch: instances that a rolling rollout replaces at a time (default: 1) healthy-after: time each new batch must keep running before the rollout goes on. - [examples: type=recreate, batch=2,healthy-after=30s, by=tags] + [examples: type=recreate, batch=2,healthy-after=30s, by=tags,target=env-prod] $ unikraft instance edit --help diff --git a/cmd/unikraft/testdata/TestHelp/run b/cmd/unikraft/testdata/TestHelp/run index a960fb13..aa6579be 100644 --- a/cmd/unikraft/testdata/TestHelp/run +++ b/cmd/unikraft/testdata/TestHelp/run @@ -243,6 +243,7 @@ Create flags: Replace every instance the rollout selects with a new one and delete the old ones. Requires --autostart. type: rolling (default) | recreate by: service (default) | tags + target: the new instance's service, or a subset of its tags, to select by (default: all of them) batch: instances that a rolling rollout replaces at a time (default: 1) healthy-after: time each new batch must keep running before the rollout goes on. - [examples: type=recreate, batch=2,healthy-after=30s, by=tags] + [examples: type=recreate, batch=2,healthy-after=30s, by=tags,target=env-prod] diff --git a/internal/cmd/instances.go b/internal/cmd/instances.go index 0b112fc9..e48dc99f 100644 --- a/internal/cmd/instances.go +++ b/internal/cmd/instances.go @@ -77,7 +77,7 @@ type InstanceCreateCmd struct { cmd.ModelCreateCmd[Instance] DeleteOnStop bool `group:"flag-create" name:"rm" help:"Automatically delete the instance when it stops."` - Rollout *InstanceRollout `group:"flag-create" name:"rollout" type:"optional" help:"Replace every instance the rollout selects with a new one and delete the old ones. Requires --autostart.\n type: rolling (default) | recreate\n by: service (default) | tags\n batch: instances that a rolling rollout replaces at a time (default: 1)\n healthy-after: time each new batch must keep running before the rollout goes on" placeholder:"=" example:"type=recreate,batch=2\\,healthy-after=30s,by=tags"` + Rollout *InstanceRollout `group:"flag-create" name:"rollout" type:"optional" help:"Replace every instance the rollout selects with a new one and delete the old ones. Requires --autostart.\n type: rolling (default) | recreate\n by: service (default) | tags\n target: the new instance's service, or a subset of its tags, to select by (default: all of them)\n batch: instances that a rolling rollout replaces at a time (default: 1)\n healthy-after: time each new batch must keep running before the rollout goes on" placeholder:"=" example:"type=recreate,batch=2\\,healthy-after=30s,by=tags\\,target=env-prod"` } func (c *InstanceCreateCmd) Run(ctx context.Context, stdio config.Stdio, partition *model.Partition) error { @@ -1729,6 +1729,18 @@ func (Instance) Examples() map[cmd.CmdType][]kingkong.Example { --rollout=type=recreate,healthy-after=1m`, }, }, + { + Description: "Replace the app-web instances two at a time with ones that also carry a version tag", + Commands: []string{ + `unikraft instance create \ + --metro fra \ + --image my-app:v2 \ + --tag app-web \ + --tag v2 \ + --autostart \ + --rollout=by=tags,target=app-web,batch=2`, + }, + }, }, cmd.CmdTypeEdit: { { diff --git a/internal/cmd/instances_rollout.go b/internal/cmd/instances_rollout.go index b5892ce6..3175666e 100644 --- a/internal/cmd/instances_rollout.go +++ b/internal/cmd/instances_rollout.go @@ -33,6 +33,7 @@ import ( type InstanceRollout struct { Type InstanceRolloutMode `name:"type" json:"type,omitempty"` By InstanceRolloutSelector `name:"by" json:"by,omitempty"` + Target []string `name:"target" json:"target,omitempty"` Batch int `name:"batch" json:"batch,omitempty"` HealthyAfter types.DurationS `name:"healthy-after" json:"healthy-after,omitempty"` } @@ -53,6 +54,7 @@ func (r *InstanceRollout) UnmarshalText(data []byte) error { if parsed.Batch != 0 && parsed.Type == RolloutRecreate { return fmt.Errorf("rollout batch cannot be used with %q", RolloutRecreate) } + parsed.Target = slices.DeleteFunc(parsed.Target, func(t string) bool { return t == "" }) *r = InstanceRollout(parsed) return nil } @@ -152,12 +154,21 @@ func (c *InstanceCreateCmd) runRollout(ctx context.Context, stdio config.Stdio, if svc == nil || (svc.Name == "" && svc.UUID == "") { return nil, fmt.Errorf("--rollout requires an existing service group") } - selected = cmp.Or(svc.Name, svc.UUID) - groups, old, err = rolloutTargetService(ctx, metro, svc) + selected = cmp.Or(strings.Join(opts.Target, ","), svc.Name, svc.UUID) + groups, old, err = rolloutTargetService(ctx, metro, svc, opts.Target) case RolloutByTags: if len(tags) == 0 { return nil, fmt.Errorf("--rollout=by=tags requires --tag") } + for _, tag := range opts.Target { + if !slices.Contains(tags, tag) { + return nil, fmt.Errorf("rollout target %q is not a tag of the new instance", tag) + } + } + if len(opts.Target) > 0 { + tags = opts.Target + selected = strings.Join(tags, ",") + } groups, old, err = rolloutTargetTags(ctx, metro, svc, tags) } if err != nil { @@ -275,8 +286,8 @@ func (c *InstanceCreateCmd) runRollout(ctx context.Context, stdio config.Stdio, } // rolloutTargetService reads back the service group whose instances a rollout -// replaces, and those instances. -func rolloutTargetService(ctx context.Context, metro string, svc *InstanceService) ([]ServiceGroup, []Instance, error) { +// replaces, and those instances. Each target must name that group. +func rolloutTargetService(ctx context.Context, metro string, svc *InstanceService, targets []string) ([]ServiceGroup, []Instance, error) { key := serviceGroupKey(svc, metro) results, err := (ServiceGroup{}).Get(ctx, []string{key}) if err != nil { @@ -286,6 +297,16 @@ func rolloutTargetService(ctx context.Context, metro string, svc *InstanceServic return nil, nil, fmt.Errorf("service group %q not found", key) } svcGroup := results[0].(ServiceGroup) + for _, target := range targets { + link, err := ParseLink[ServiceGroup]([]byte(target)) + if err != nil { + return nil, nil, err + } + named := (link.UUID != "" && link.UUID == svcGroup.UUID) || (link.Name != "" && link.Name == svcGroup.Name) + if !named || (link.Metro != "" && link.Metro != string(svcGroup.Metro)) { + return nil, nil, fmt.Errorf("rollout target %q is not a service of the new instance", target) + } + } keys := make(multimetro.Keys, 0, len(svcGroup.Instances)) for _, inst := range svcGroup.Instances { diff --git a/internal/cmd/instances_test.go b/internal/cmd/instances_test.go index 52259865..0d88df75 100644 --- a/internal/cmd/instances_test.go +++ b/internal/cmd/instances_test.go @@ -260,8 +260,18 @@ func TestInstanceRolloutUnmarshalText(t *testing.T) { }, { name: "every key", - in: "type=rolling,by=tags,batch=3,healthy-after=30s", - want: cmd.InstanceRollout{Type: cmd.RolloutRolling, By: cmd.RolloutByTags, Batch: 3, HealthyAfter: types.DurationS(30)}, + in: "type=rolling,by=tags,target=env-prod,batch=3,healthy-after=30s", + want: cmd.InstanceRollout{Type: cmd.RolloutRolling, By: cmd.RolloutByTags, Target: []string{"env-prod"}, Batch: 3, HealthyAfter: types.DurationS(30)}, + }, + { + name: "target list", + in: `by=tags,target=["app=web","env-prod"]`, + want: cmd.InstanceRollout{By: cmd.RolloutByTags, Target: []string{"app=web", "env-prod"}}, + }, + { + name: "empty target", + in: "target=", + want: cmd.InstanceRollout{Target: []string{}}, }, { name: "json", From 740cade7551afc8b69eaf207bd71aaaa8f79816a Mon Sep 17 00:00:00 2001 From: Dragos Gheorghioiu Date: Tue, 3 Nov 2026 15:40:31 +0200 Subject: [PATCH 31/35] feat(cmd): Get, list and delete builders The builder commands could create a builder and look after its cache, but not show the builders or the builds on them, nor delete one. builder get, list and delete call the build service's new builder endpoints. A builder shows its running and waiting builds, the images they push and its build limit, and links to its instance. Signed-off-by: Dragos Gheorghioiu --- cmd/unikraft/help_test.go | 3 + cmd/unikraft/integration/builder_test.go | 19 +++ cmd/unikraft/testdata/TestHelp/build | 190 +++++++++++++++++++++- cmd/unikraft/testdata/TestHelp/config | 16 +- go.mod | 2 +- go.sum | 4 +- internal/cmd/builder.go | 198 ++++++++++++++++++++++- internal/cmd/builder_test.go | 49 ++++++ internal/cmd/root.go | 3 +- internal/config/profile.go | 1 + 10 files changed, 464 insertions(+), 21 deletions(-) create mode 100644 internal/cmd/builder_test.go diff --git a/cmd/unikraft/help_test.go b/cmd/unikraft/help_test.go index d18dfdee..e9394156 100644 --- a/cmd/unikraft/help_test.go +++ b/cmd/unikraft/help_test.go @@ -209,6 +209,9 @@ func buildHelpTests(t *testing.T, unikraftPath string) { []string{"unikraft", "build", "--help"}, []string{"unikraft", "builder", "--help"}, []string{"unikraft", "builder", "create", "--help"}, + []string{"unikraft", "builder", "get", "--help"}, + []string{"unikraft", "builder", "list", "--help"}, + []string{"unikraft", "builder", "delete", "--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 index 31dcba38..e34d0a74 100644 --- a/cmd/unikraft/integration/builder_test.go +++ b/cmd/unikraft/integration/builder_test.go @@ -34,9 +34,28 @@ func TestBuilder(t *testing.T) { out := r.Run(t, []string{"unikraft", "builder", "create"}, env) assert.Regexp(t, `builder ready`, out) + out = r.Run(t, []string{"unikraft", "builder", "get"}, env) + assert.Regexp(t, `(?m)^instance: +.*name=remote-builder-default\b`, out) + + out = r.Run(t, []string{"unikraft", "builder", "list"}, env) + assert.Regexp(t, `(?m)^default +true +`, 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) + + // A builder of its own, so that deleting it leaves the shared one. + name := "cli-test-" + uniq() + r.Run(t, []string{"unikraft", "builder", "create", name}, env) + t.Cleanup(func() { + r.Run(t, []string{"unikraft", "builder", "delete", name}, env, integ.WithoutCancel(), integ.AllowFail()) + }) + + out = r.Run(t, []string{"unikraft", "builder", "delete", name}, env) + assert.Regexp(t, `(?m)^`+name+`$`, out) + + out = r.Run(t, []string{"unikraft", "builder", "get", name}, env, integ.ExpectFail()) + assert.Regexp(t, `there is no builder `+name, out) } diff --git a/cmd/unikraft/testdata/TestHelp/build b/cmd/unikraft/testdata/TestHelp/build index 586ababb..b71b2139 100644 --- a/cmd/unikraft/testdata/TestHelp/build +++ b/cmd/unikraft/testdata/TestHelp/build @@ -80,11 +80,26 @@ Usage: Commands: create Create a builder that remote builds run on. + get, inspect, show + Inspect a builder. + list, ls + List builders. + delete, rm, remove + Delete a builder and its cache. du Show the disk usage of a builder's cache. prune Free a builder's cache, keeping the builder. +Fields: + name + default + running + waiting + max-builds + images + instance + Global flags: -h, --help Show context-sensitive help. @@ -109,7 +124,7 @@ $ unikraft builder create --help Create a builder that remote builds run on. Usage: - unikraft builder create --remote=url [] + unikraft builder create [] [flags] Arguments: [] @@ -122,7 +137,170 @@ Examples: Flags: --remote= ($UNIKRAFT_BUILD_URL) - URL of the build service that manages the builder. + URL of the build service that manages the builder. Defaults to the build_url of the profile. + +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 get --help + +Inspect a builder. + +Usage: + unikraft builder get [ ...] [flags] + +Arguments: + [ ...] + Target builders to get. + +Examples: + # Show the builds on the builder of remote builds, and its instance + unikraft builder get --remote https://build.example.com + +Fields: + name + default + running + waiting + max-builds + images + instance + +Flags: + -w, --watch= + Watch for changes and refresh output. Defaults to 2s. + -f, --field + Specify which fields to include in the output. + -o, --output + Output format. One of: kv, table, json, yaml, raw, quiet, template. + --remote= ($UNIKRAFT_BUILD_URL) + URL of the build service that manages the builder. Defaults to the build_url of the profile. + +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 list --help + +List builders. + +Usage: + unikraft builder list [ ...] [flags] + +Arguments: + [ ...] + Target builders to list. + +Examples: + # List the builders of the build service + unikraft builder list --remote https://build.example.com + +Fields: + name + default + running + waiting + max-builds + images + instance + +Flags: + --filter + Filter output based on a field value. + [examples: name==my-instance, metro==fra] + -w, --watch= + Watch for changes and refresh output. Defaults to 2s. + --sort + Sort output by field values. Use - prefix for descending, + for ascending. + [examples: name, -timestamps.created-at] + -f, --field + Specify which fields to include in the output. + -o, --output + Output format. One of: kv, table, json, yaml, raw, quiet, template. + --remote= ($UNIKRAFT_BUILD_URL) + URL of the build service that manages the builders. Defaults to the build_url of the profile. + +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 delete --help + +Delete a builder and its cache. + +Usage: + unikraft builder delete ... [flags] + +Arguments: + ... + Target builders to remove. + +Examples: + # Delete a builder and its cache + unikraft builder delete ci --remote https://build.example.com + +Fields: + name + default + running + waiting + max-builds + images + instance + +Flags: + -f, --field + Specify which fields to include in the output. + -o, --output + Output format. One of: kv, table, json, yaml, raw, quiet, template. + --remote= ($UNIKRAFT_BUILD_URL) + URL of the build service that manages the builder. Defaults to the build_url of the profile. Global flags: -h, --help @@ -148,7 +326,7 @@ $ unikraft builder du --help Show the disk usage of a builder's cache. Usage: - unikraft builder du --remote=url [] + unikraft builder du [] [flags] Arguments: [] @@ -161,7 +339,7 @@ Examples: Flags: --remote= ($UNIKRAFT_BUILD_URL) - URL of the build service that manages the builder. + URL of the build service that manages the builder. Defaults to the build_url of the profile. Global flags: -h, --help @@ -187,7 +365,7 @@ $ unikraft builder prune --help Free a builder's cache, keeping the builder. Usage: - unikraft builder prune --remote=url [] + unikraft builder prune [] [flags] Arguments: [] @@ -200,7 +378,7 @@ Examples: Flags: --remote= ($UNIKRAFT_BUILD_URL) - URL of the build service that manages the builder. + URL of the build service that manages the builder. Defaults to the build_url of the profile. Global flags: -h, --help diff --git a/cmd/unikraft/testdata/TestHelp/config b/cmd/unikraft/testdata/TestHelp/config index 3e43ccc4..6edf20c8 100644 --- a/cmd/unikraft/testdata/TestHelp/config +++ b/cmd/unikraft/testdata/TestHelp/config @@ -15,10 +15,10 @@ Fields: profiles, profiles.*, profiles.*.name, profiles.*.type, profiles.*.token, profiles.*.organization, profiles.*.user-uuid, profiles.*.organization-uuid, profiles.*.control-plane, profiles.*.insecure, profiles.*.registry-mirror, - profiles.*.metros, profiles.*.metros.*, profiles.*.metros.*.name, - profiles.*.metros.*.endpoint, profiles.*.metros.*.proxy, - profiles.*.metros.*.location, profiles.*.metros.*.insecure, profiles.*.metro- - default + profiles.*.build-url, profiles.*.metros, profiles.*.metros.*, + profiles.*.metros.*.name, profiles.*.metros.*.endpoint, + profiles.*.metros.*.proxy, profiles.*.metros.*.location, + profiles.*.metros.*.insecure, profiles.*.metro-default Global flags: -h, --help @@ -60,10 +60,10 @@ Fields: profiles, profiles.*, profiles.*.name, profiles.*.type, profiles.*.token, profiles.*.organization, profiles.*.user-uuid, profiles.*.organization-uuid, profiles.*.control-plane, profiles.*.insecure, profiles.*.registry-mirror, - profiles.*.metros, profiles.*.metros.*, profiles.*.metros.*.name, - profiles.*.metros.*.endpoint, profiles.*.metros.*.proxy, - profiles.*.metros.*.location, profiles.*.metros.*.insecure, profiles.*.metro- - default + profiles.*.build-url, profiles.*.metros, profiles.*.metros.*, + profiles.*.metros.*.name, profiles.*.metros.*.endpoint, + profiles.*.metros.*.proxy, profiles.*.metros.*.location, + profiles.*.metros.*.insecure, profiles.*.metro-default Flags: -w, --watch= diff --git a/go.mod b/go.mod index 744eb027..f5f26498 100644 --- a/go.mod +++ b/go.mod @@ -53,7 +53,7 @@ require ( sigs.k8s.io/yaml v1.6.0 unikraft.com/cloud/plugins/sandbox v0.0.0-20260916204449-fe2a902ae04c unikraft.com/cloud/sdk v0.4.1-staging.3 - unikraft.com/cloud/sdk/build v0.0.0-20261030150712-62563f400155 + unikraft.com/cloud/sdk/build v0.0.0-20261103133358-47eb7dbdbcc8 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 623e15e9..19e3f521 100644 --- a/go.sum +++ b/go.sum @@ -495,8 +495,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.4.1-staging.3 h1:k7E4HUcnRnqJ5Ev8Jefnmmm9Ah9MqnkXeR/+Lx6IyzE= unikraft.com/cloud/sdk v0.4.1-staging.3/go.mod h1:/wX3Ltbi+rSzpS6zn2v6qLSP/7OLQqDWDp5VJ5ZVHlg= -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/build v0.0.0-20261103133358-47eb7dbdbcc8 h1:+iit5C7QQISE/FFFVwLszAFOct0hWIgLjwM6jQ2Beq8= +unikraft.com/cloud/sdk/build v0.0.0-20261103133358-47eb7dbdbcc8/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 index e59bd9ba..7d89c1fc 100644 --- a/internal/cmd/builder.go +++ b/internal/cmd/builder.go @@ -6,6 +6,7 @@ package cmd import ( + "cmp" "context" "errors" "fmt" @@ -16,17 +17,192 @@ import ( "unikraft.com/x/log" "unikraft.com/cli/internal/config" + "unikraft.com/cli/internal/mirror" + "unikraft.com/cli/internal/model" + "unikraft.com/cli/internal/model/cmd" ) type BuilderCmd struct { + cmd.ModelCmd[Builder] + Create BuilderCreateCmd `cmd:"" help:"Create a builder that remote builds run on."` + Get BuilderGetCmd `cmd:"" help:"Inspect a builder." aliases:"inspect,show"` + List BuilderListCmd `cmd:"" help:"List builders." aliases:"ls"` + Delete BuilderDeleteCmd `cmd:"" help:"Delete a builder and its cache." aliases:"rm,remove"` 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."` } +// Builder is a builder of the build service, and the builds the service runs on it. +type Builder struct { + Name string `mirror:"status.name" field:",short" help:"Name of the builder."` + IsDefault bool `mirror:"status.default" field:"default,short" help:"Whether builds that name no builder run on it."` + Running int `mirror:"status.builds.#" field:",short" help:"Number of builds running on the builder."` + Waiting int32 `mirror:"status.waiting_builds" field:",short" help:"Number of builds waiting for the builder, or for any builder of the pool for default."` + MaxBuilds int32 `mirror:"status.max_builds" field:",short" help:"Number of builds that run on the builder at once."` + Images []string `field:",long" help:"Images that the running builds push."` + Instance Link[Instance] `mirror:"status.instance" field:",long" help:"Instance the builder runs as, for its image, sizes and state."` + + Status buildapi.BuilderStatus `field:"-" json:"status"` +} + +func (Builder) Type() model.Type { + return model.Type{ + Name: "builder", + Names: "builders", + } +} + +func (b Builder) Key() model.Key { + return staticKey(b.Name) +} + +func (b Builder) Raw() any { + return b.Status +} + +func (b Builder) Fields(ctx context.Context) ([]model.Field, error) { + return model.FieldsFromStruct(b) +} + +func (Builder) Get(ctx context.Context, keys []string) ([]model.Model, error) { + client, profile, err := builderClientOf(ctx) + if err != nil { + return nil, err + } + var results []model.Model + for _, name := range keys { + s, err := client.GetBuilder(ctx, name) + if err != nil { + return results, builderError(err, name) + } + b, err := builderOf(profile, *s) + if err != nil { + return results, err + } + results = append(results, b) + } + return results, nil +} + +func (Builder) Default(ctx context.Context) (model.Model, error) { + results, err := Builder{}.Get(ctx, []string{"default"}) + if err != nil { + return nil, err + } + return results[0], nil +} + +func (Builder) List(ctx context.Context) ([]model.Model, error) { + client, profile, err := builderClientOf(ctx) + if err != nil { + return nil, err + } + resp, err := client.ListBuilders(ctx) + if err != nil { + return nil, err + } + results := make([]model.Model, 0, len(resp.Builders)) + for _, s := range resp.Builders { + b, err := builderOf(profile, s) + if err != nil { + return nil, err + } + results = append(results, b) + } + return results, nil +} + +func (Builder) Delete(ctx context.Context, keys []string) error { + client, _, err := builderClientOf(ctx) + if err != nil { + return err + } + for _, name := range keys { + if err := client.DeleteBuilder(ctx, name); err != nil { + return builderError(err, name) + } + } + return nil +} + +func (Builder) Examples() map[cmd.CmdType][]kingkong.Example { + return map[cmd.CmdType][]kingkong.Example{ + cmd.CmdTypeGet: { + { + Description: "Show the builds on the builder of remote builds, and its instance", + Commands: []string{"unikraft builder get --remote https://build.example.com"}, + }, + }, + cmd.CmdTypeList: { + { + Description: "List the builders of the build service", + Commands: []string{"unikraft builder list --remote https://build.example.com"}, + }, + }, + cmd.CmdTypeDelete: { + { + Description: "Delete a builder and its cache", + Commands: []string{"unikraft builder delete ci --remote https://build.example.com"}, + }, + }, + } +} + +// builderOf returns the builder of s, linking to its instance in the profile's metro of that endpoint. +func builderOf(profile *config.Profile, s buildapi.BuilderStatus) (Builder, error) { + b := Builder{Status: s} + if err := mirror.Mirror(b, &b); err != nil { + return Builder{}, fmt.Errorf("could not mirror builder data: %w", err) + } + for _, metro := range profile.Metros { + if metro.Endpoint == s.Instance.Metro { + b.Instance.Metro = metro.Name + } + } + for _, build := range s.Builds { + for _, out := range build.Output { + if out.Image != nil { + b.Images = append(b.Images, *out.Image) + } + } + } + return b, nil +} + +// builderRemoteKey holds the URL of the build service for the methods of Builder. +type builderRemoteKey struct{} + +type BuilderGetCmd struct { + cmd.ModelGetCmd[Builder] + Remote string `env:"UNIKRAFT_BUILD_URL" placeholder:"url" help:"URL of the build service that manages the builder. Defaults to the build_url of the profile."` +} + +func (c *BuilderGetCmd) Run(ctx context.Context, stdio config.Stdio) error { + return c.ModelGetCmd.Run(context.WithValue(ctx, builderRemoteKey{}, c.Remote), stdio, nil) +} + +type BuilderListCmd struct { + cmd.ModelListCmd[Builder] + Remote string `env:"UNIKRAFT_BUILD_URL" placeholder:"url" help:"URL of the build service that manages the builders. Defaults to the build_url of the profile."` +} + +func (c *BuilderListCmd) Run(ctx context.Context, stdio config.Stdio) error { + return c.ModelListCmd.Run(context.WithValue(ctx, builderRemoteKey{}, c.Remote), stdio, nil) +} + +type BuilderDeleteCmd struct { + cmd.ModelRemoveCmd[Builder] + Remote string `env:"UNIKRAFT_BUILD_URL" placeholder:"url" help:"URL of the build service that manages the builder. Defaults to the build_url of the profile."` +} + +func (c *BuilderDeleteCmd) Run(ctx context.Context, stdio config.Stdio) error { + return c.ModelRemoveCmd.Run(context.WithValue(ctx, builderRemoteKey{}, c.Remote), stdio, nil) +} + 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."` + Remote string `env:"UNIKRAFT_BUILD_URL" placeholder:"url" help:"URL of the build service that manages the builder. Defaults to the build_url of the profile."` } func (BuilderCreateCmd) Examples() []kingkong.Example { @@ -40,7 +216,7 @@ func (BuilderCreateCmd) Examples() []kingkong.Example { 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."` + Remote string `env:"UNIKRAFT_BUILD_URL" placeholder:"url" help:"URL of the build service that manages the builder. Defaults to the build_url of the profile."` } func (BuilderDuCmd) Examples() []kingkong.Example { @@ -54,7 +230,7 @@ func (BuilderDuCmd) Examples() []kingkong.Example { 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."` + Remote string `env:"UNIKRAFT_BUILD_URL" placeholder:"url" help:"URL of the build service that manages the builder. Defaults to the build_url of the profile."` } func (BuilderPruneCmd) Examples() []kingkong.Example { @@ -110,9 +286,25 @@ func builderClient(cfg *config.Config, url string) (*buildapi.Client, error) { if err != nil { return nil, err } + url = cmp.Or(url, profile.BuildURL) + if url == "" { + return nil, fmt.Errorf("no build service: set build_url in profile %s, or pass --remote", profile.Name) + } return buildapi.New(url, buildapi.WithToken(profile.Token)) } +// builderClientOf returns the client of the build service in ctx, and the current profile. +func builderClientOf(ctx context.Context) (*buildapi.Client, *config.Profile, error) { + cfg := config.G(ctx) + url, _ := ctx.Value(builderRemoteKey{}).(string) + client, err := builderClient(cfg, url) + if err != nil { + return nil, nil, err + } + profile, err := cfg.CurrentProfile() + return client, profile, err +} + // builderError tells how to create the named builder when the service has none. func builderError(err error, name string) error { var apiErr *buildapi.APIError diff --git a/internal/cmd/builder_test.go b/internal/cmd/builder_test.go new file mode 100644 index 00000000..5d0aa696 --- /dev/null +++ b/internal/cmd/builder_test.go @@ -0,0 +1,49 @@ +// 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" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "unikraft.com/cli/internal/config" +) + +func TestBuilderBuildURL(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"builders": [{"name": "default", "default": true}]}`) + })) + t.Cleanup(srv.Close) + + list := func(buildURL, remote string) error { + profile := "{type: cloud, token: a}" + if buildURL != "" { + profile = "{type: cloud, token: a, build_url: " + buildURL + "}" + } + path := filepath.Join(t.TempDir(), "config.yaml") + require.NoError(t, os.WriteFile(path, []byte("profile: a\nprofiles:\n a: "+profile+"\n"), 0o600)) + cfg, err := loadConfig(path, "") + require.NoError(t, err) + ctx := context.WithValue(config.WithConfig(t.Context(), cfg), builderRemoteKey{}, remote) + _, err = Builder{}.List(ctx) + return err + } + + assert.NoError(t, list(srv.URL, "")) + assert.NoError(t, list("http://127.0.0.1:1", srv.URL)) + assert.ErrorContains(t, list("", ""), "set build_url in profile a, or pass --remote") +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index f10a8304..0ddfcd4d 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -43,7 +43,7 @@ type UnikraftCLI struct { 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."` + Builder BuilderCmd `cmd:"" group:"cmd-commands" help:"Manage the builders that remote builds run on." set:"name=builder" set:"names=builders"` 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"` @@ -144,6 +144,7 @@ func NewRootCmd(ctx context.Context, args []string, stdio config.Stdio, signals kongcompletion.WithPredictor("resource-key-certificate", cmd.PredictModelKey[Certificate](ctx)), kongcompletion.WithPredictor("resource-key-image", cmd.PredictModelKey[ImageEntry](ctx)), kongcompletion.WithPredictor("resource-key-resource", cmd.PredictModelKey[AnyResource](ctx)), + kongcompletion.WithPredictor("resource-key-builder", complete.PredictNothing), ) kctx, err := parser.Parse(args) diff --git a/internal/config/profile.go b/internal/config/profile.go index 35ad8e50..1d3cae19 100644 --- a/internal/config/profile.go +++ b/internal/config/profile.go @@ -84,6 +84,7 @@ type Profile struct { Insecure bool `json:"controlplane_insecure,omitempty" field:",long" help:"Skip TLS verification when connecting to the control plane."` // RegistryMirror is the endpoint that gets the requests for the Unikraft image registry. RegistryMirror string `json:"registry_mirror,omitempty" field:",long" help:"Endpoint that serves requests for the Unikraft image registry."` + BuildURL string `json:"build_url,omitempty" field:",long" help:"URL of the build service that the builder commands use."` // Metros is a static list of metros. Metros []Metro `json:"metros,omitempty" field:",long,embed" help:"Metros of the profile."` // MetroDefault is the default metro to use when creating resources. From 666718bd89ece7c2bb2f1ef8cb39666d550b8f78 Mon Sep 17 00:00:00 2001 From: Dragos Gheorghioiu Date: Fri, 6 Nov 2026 12:29:38 +0200 Subject: [PATCH 32/35] ci: Log in to Docker Hub in integration tests The integration jobs pull from Docker Hub anonymously and hit its rate limit (429 Too Many Requests), failing the rootfs OCI tests. Log in with the shared x login-docker-hub action. Fork pull requests get no secrets and keep pulling anonymously. Signed-off-by: Dragos Gheorghioiu --- .github/workflows/test.yaml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 5b0935e4..9373d7e0 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -106,6 +106,12 @@ jobs: with: platforms: arm64 + - name: Login to Docker Hub + uses: unikraft-cloud/x/.github/actions/login-docker-hub@prod-staging + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Set Go variables id: goenv run: | @@ -197,6 +203,12 @@ jobs: with: platforms: arm64 + - name: Login to Docker Hub + uses: unikraft-cloud/x/.github/actions/login-docker-hub@prod-staging + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Set Go variables id: goenv run: | From 1a8fe820ceff68a0a0d89fea234f415fc7ae6cd2 Mon Sep 17 00:00:00 2001 From: Cezar Craciunoiu Date: Fri, 6 Nov 2026 14:40:57 +0200 Subject: [PATCH 33/35] gomod(deps): Bump go-archivefs cli used an old go-archivefs, without the cpio package that takes the place of the archived github.com/unikraft/go-cpio. Bump go-archivefs to 364b178. go-cpio stays as an indirect dependency until the Go SDK drops it. Signed-off-by: Cezar Craciunoiu --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index f5f26498..3a8a2b29 100644 --- a/go.mod +++ b/go.mod @@ -42,7 +42,7 @@ require ( 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-archivefs v0.0.0-20261106123111-364b178a11ba golang.org/x/mod v0.41.0 golang.org/x/sync v0.23.0 golang.org/x/sys v0.49.0 diff --git a/go.sum b/go.sum index 19e3f521..4000c15e 100644 --- a/go.sum +++ b/go.sum @@ -383,8 +383,8 @@ github.com/tonistiigi/vt100 v0.0.0-20240514184818-90bafcd6abab h1:H6aJ0yKQ0gF49Q github.com/tonistiigi/vt100 v0.0.0-20240514184818-90bafcd6abab/go.mod h1:ulncasL3N9uLrVann0m+CDlJKWsIAP34MPcOJF6VRvc= github.com/unikraft-cloud/lipgloss/v2 v2.0.0-20260921145830-9a285e5dd507 h1:Pshbef+pR9j+qiRihFkbpBZK5ptj6ikk70uO0MJj1D8= github.com/unikraft-cloud/lipgloss/v2 v2.0.0-20260921145830-9a285e5dd507/go.mod h1:wwfOgrNqUA9yjL8nmG6BIO0qQnmkEgJ8ISptbme83gI= -github.com/unikraft/go-archivefs v0.0.0-20261019091157-8981da32f72d h1:CFQ2ZM61liqOWeqXzQohPseZCsfqkMvmGfan1H7I+uI= -github.com/unikraft/go-archivefs v0.0.0-20261019091157-8981da32f72d/go.mod h1:NLWtSTdsTRLGQN87zqm9+vN01Ei9JX+FWwuOme2wJXw= +github.com/unikraft/go-archivefs v0.0.0-20261106123111-364b178a11ba h1:v9Gc/bx6ifAQ0/XK33mwoMT5Ad4n3+qoqQ5FHnraACQ= +github.com/unikraft/go-archivefs v0.0.0-20261106123111-364b178a11ba/go.mod h1:NLWtSTdsTRLGQN87zqm9+vN01Ei9JX+FWwuOme2wJXw= github.com/unikraft/go-cpio v0.0.0-20260415131742-4f1984ca41f3 h1:C8GONmMNDK3spdZEHBIffmq1b1hjCm9nZrquv3+4H+U= github.com/unikraft/go-cpio v0.0.0-20260415131742-4f1984ca41f3/go.mod h1:OFJWAQVOq1qppx75rzF7qDoE5TqGjM1oCXW5N06Lftg= github.com/vbatts/tar-split v0.12.3 h1:Cd46rkGXI3Td4yrVNwU8ripbxFaQbmesqhjBUUYAJSw= From bbd832b05d05a99a0af1798db3759668bf29df79 Mon Sep 17 00:00:00 2001 From: Cezar Craciunoiu Date: Fri, 6 Nov 2026 14:49:50 +0200 Subject: [PATCH 34/35] gomod(deps): Bump the Go SDK The Go SDK pulled in the archived github.com/unikraft/go-cpio through its volimport package. Bump the Go SDK to v0.4.1-staging.4, which uses the cpio package of go-archivefs. Signed-off-by: Cezar Craciunoiu --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 3a8a2b29..9bf749c8 100644 --- a/go.mod +++ b/go.mod @@ -52,7 +52,7 @@ require ( mvdan.cc/sh/v3 v3.14.1 sigs.k8s.io/yaml v1.6.0 unikraft.com/cloud/plugins/sandbox v0.0.0-20260916204449-fe2a902ae04c - unikraft.com/cloud/sdk v0.4.1-staging.3 + unikraft.com/cloud/sdk v0.4.1-staging.4 unikraft.com/cloud/sdk/build v0.0.0-20261103133358-47eb7dbdbcc8 unikraft.com/cloud/sdk/plugins/sandbox v0.0.0-20261027110526-41cac40eecd3 unikraft.com/x/build v0.0.0-20261020121654-15b23b7bb4a5 diff --git a/go.sum b/go.sum index 4000c15e..faf72747 100644 --- a/go.sum +++ b/go.sum @@ -493,8 +493,8 @@ tailscale.com v1.102.5 h1:2jK9VxQU4Vq/tyR7f2U2NqINxU0pV7R14TDBFsfguHE= tailscale.com v1.102.5/go.mod h1:47bv91Xbg4K1p5wti7F1dmKvUVWV5BXF78d9EWJ+d6c= unikraft.com/cloud/plugins/sandbox v0.0.0-20260916204449-fe2a902ae04c h1:ZNaDV2bBfC1FQO5YgKQsXgaJ+jc5sM9oVwV7EjPdEmg= unikraft.com/cloud/plugins/sandbox v0.0.0-20260916204449-fe2a902ae04c/go.mod h1:QFvqlzpb/oOceeLG9Rbcy/B8Q6VVVRZcLzH/TZCF2os= -unikraft.com/cloud/sdk v0.4.1-staging.3 h1:k7E4HUcnRnqJ5Ev8Jefnmmm9Ah9MqnkXeR/+Lx6IyzE= -unikraft.com/cloud/sdk v0.4.1-staging.3/go.mod h1:/wX3Ltbi+rSzpS6zn2v6qLSP/7OLQqDWDp5VJ5ZVHlg= +unikraft.com/cloud/sdk v0.4.1-staging.4 h1:5ybtABl1gPgMrBDWjp9Kh5m96TaacbiFy6SYlSEF+WA= +unikraft.com/cloud/sdk v0.4.1-staging.4/go.mod h1:2/40ezHQrLelFR/o82nbp3WLcAcGG+oOccvOScmTpCU= unikraft.com/cloud/sdk/build v0.0.0-20261103133358-47eb7dbdbcc8 h1:+iit5C7QQISE/FFFVwLszAFOct0hWIgLjwM6jQ2Beq8= unikraft.com/cloud/sdk/build v0.0.0-20261103133358-47eb7dbdbcc8/go.mod h1:OjYfwRrWPqedgEELmpFini+YtCUT8wrP7+oqxhbn07M= unikraft.com/cloud/sdk/plugins/sandbox v0.0.0-20261027110526-41cac40eecd3 h1:hNg3DuK7QTb2Ybx63qoJ+J0M2zDRETDZiDJbaDy0jPU= From a1598023bc01af35c00c1184c262ab9135171156 Mon Sep 17 00:00:00 2001 From: Cezar Craciunoiu Date: Fri, 6 Nov 2026 15:50:21 +0200 Subject: [PATCH 35/35] gomod(deps): Bump x/build and x/buildkit x/build and x/buildkit pulled in the archived github.com/unikraft/go-cpio. Bump both to 637dd37, which use the cpio package of go-archivefs. This removes go-cpio from the module graph. Signed-off-by: Cezar Craciunoiu --- go.mod | 7 +++---- go.sum | 16 ++++++++-------- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/go.mod b/go.mod index 9bf749c8..317d9da0 100644 --- a/go.mod +++ b/go.mod @@ -42,7 +42,7 @@ require ( 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-20261106123111-364b178a11ba + github.com/unikraft/go-archivefs v0.0.0-20261106133356-c81ad57e55c4 golang.org/x/mod v0.41.0 golang.org/x/sync v0.23.0 golang.org/x/sys v0.49.0 @@ -55,8 +55,8 @@ require ( unikraft.com/cloud/sdk v0.4.1-staging.4 unikraft.com/cloud/sdk/build v0.0.0-20261103133358-47eb7dbdbcc8 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/build v0.0.0-20261106134551-637dd37aa195 + unikraft.com/x/buildkit v0.0.0-20261106134551-637dd37aa195 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 @@ -179,7 +179,6 @@ require ( 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 - github.com/unikraft/go-cpio v0.0.0-20260415131742-4f1984ca41f3 // indirect github.com/xo/terminfo v1.0.0 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect diff --git a/go.sum b/go.sum index faf72747..36279cff 100644 --- a/go.sum +++ b/go.sum @@ -383,10 +383,8 @@ github.com/tonistiigi/vt100 v0.0.0-20240514184818-90bafcd6abab h1:H6aJ0yKQ0gF49Q github.com/tonistiigi/vt100 v0.0.0-20240514184818-90bafcd6abab/go.mod h1:ulncasL3N9uLrVann0m+CDlJKWsIAP34MPcOJF6VRvc= github.com/unikraft-cloud/lipgloss/v2 v2.0.0-20260921145830-9a285e5dd507 h1:Pshbef+pR9j+qiRihFkbpBZK5ptj6ikk70uO0MJj1D8= github.com/unikraft-cloud/lipgloss/v2 v2.0.0-20260921145830-9a285e5dd507/go.mod h1:wwfOgrNqUA9yjL8nmG6BIO0qQnmkEgJ8ISptbme83gI= -github.com/unikraft/go-archivefs v0.0.0-20261106123111-364b178a11ba h1:v9Gc/bx6ifAQ0/XK33mwoMT5Ad4n3+qoqQ5FHnraACQ= -github.com/unikraft/go-archivefs v0.0.0-20261106123111-364b178a11ba/go.mod h1:NLWtSTdsTRLGQN87zqm9+vN01Ei9JX+FWwuOme2wJXw= -github.com/unikraft/go-cpio v0.0.0-20260415131742-4f1984ca41f3 h1:C8GONmMNDK3spdZEHBIffmq1b1hjCm9nZrquv3+4H+U= -github.com/unikraft/go-cpio v0.0.0-20260415131742-4f1984ca41f3/go.mod h1:OFJWAQVOq1qppx75rzF7qDoE5TqGjM1oCXW5N06Lftg= +github.com/unikraft/go-archivefs v0.0.0-20261106133356-c81ad57e55c4 h1:Do8BfD6VM6bYg44KYvaB+xd9Q+BQs10QDziwMkMqdQk= +github.com/unikraft/go-archivefs v0.0.0-20261106133356-c81ad57e55c4/go.mod h1:NLWtSTdsTRLGQN87zqm9+vN01Ei9JX+FWwuOme2wJXw= github.com/vbatts/tar-split v0.12.3 h1:Cd46rkGXI3Td4yrVNwU8ripbxFaQbmesqhjBUUYAJSw= github.com/vbatts/tar-split v0.12.3/go.mod h1:sQOc6OlqGCr7HkGx/IDBeKiTIvqhmj8KffNhEXG4Nq0= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= @@ -499,10 +497,10 @@ unikraft.com/cloud/sdk/build v0.0.0-20261103133358-47eb7dbdbcc8 h1:+iit5C7QQISE/ unikraft.com/cloud/sdk/build v0.0.0-20261103133358-47eb7dbdbcc8/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-20261027124450-dfcf25878f57 h1:9MIeRql7SH/fNACV6ssGVFEmlW9M/bfmBFcPEVuYV3k= -unikraft.com/x/buildkit v0.0.0-20261027124450-dfcf25878f57/go.mod h1:dv0325XVI1G1IsJDY6qjfFJKemm2u4UWRnvCDg0rFrg= +unikraft.com/x/build v0.0.0-20261106134551-637dd37aa195 h1:FJhDOyeIsRE14CTVpsldfme6Yg9dWdq8qB2x5jUK/Fk= +unikraft.com/x/build v0.0.0-20261106134551-637dd37aa195/go.mod h1:zGDiqeqsV+8HaDN8Iuev/iXJVrTPtNe9IGDlxxvND7w= +unikraft.com/x/buildkit v0.0.0-20261106134551-637dd37aa195 h1:Nh5WDiOU8uwU/pEpInzJcNuxuRJgvMTgRtGvRDWFPcY= +unikraft.com/x/buildkit v0.0.0-20261106134551-637dd37aa195/go.mod h1:l/Y8EIkIeCxRhoA3hkmZqjymhjsEEkhMYKpFcdaRB8c= 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= @@ -513,6 +511,8 @@ unikraft.com/x/guesstermwidth v0.0.0-20260924124354-cba1e2dc1921 h1:YJdES2J8wVqK unikraft.com/x/guesstermwidth v0.0.0-20260924124354-cba1e2dc1921/go.mod h1:8IHIgC5UVXjpfafOnv4a/3Y5Mvj1uq5BSBePZvZuBb4= unikraft.com/x/image-spec v0.0.0-20261020121654-15b23b7bb4a5 h1:+d2WCbFskWzwszOjNgxZa9oXQkdclYBNgSW28ySXyQA= unikraft.com/x/image-spec v0.0.0-20261020121654-15b23b7bb4a5/go.mod h1:mC1URJ+2Xoxzk+pF5P4L9CbMMfCkgiW0COhW6FKJNE4= +unikraft.com/x/integration v0.0.0-20261029083025-f2d161e6abc8 h1:R48wpvUv46CfArM0GdTKWxhDlkagFnRtAaFmRu45O7g= +unikraft.com/x/integration v0.0.0-20261029083025-f2d161e6abc8/go.mod h1:yUzpOnSm68CH1Ujou0bTEJimecGiAjyqfCPiRrFWQI4= unikraft.com/x/io v0.0.0-20261013095502-1607ff4bc624 h1:aNS+lX92H5pBhMo3srxyX1iQGwylYKegzrpJy5B0ns4= unikraft.com/x/io v0.0.0-20261013095502-1607ff4bc624/go.mod h1:kIUpmhaVEiZdxrTYAGIcythxPbPdVzAvuH4KEn8T1rE= unikraft.com/x/joinerrgroup v0.0.0-20260304162956-523940cab1de h1:cm4FnPvnahRIK0derbI+T4ds1LsD5CFeyyAvIqcOCek=