diff --git a/cmd/unikraft/integration/instance_test.go b/cmd/unikraft/integration/instance_test.go index c4fbbc7b..92337b2b 100644 --- a/cmd/unikraft/integration/instance_test.go +++ b/cmd/unikraft/integration/instance_test.go @@ -53,6 +53,8 @@ cmd: ["python3", "/app/server.py"] // instance stays running while the test reads the output of the boot. const followArgs = `runtime.args=["sh","-c","n=$(cat /data/n 2>/dev/null || echo 0); n=$((n+1)); echo $n > /data/n; echo starting $n; sleep 30s"]` +const sandboxPluginRom = "plugins/sandbox:staging" + // tunnelProxyUUIDs queries the platform directly (bypassing the CLI's // resource sandbox, which never tracks the tunnel command's internal proxy // instance since it's created via the raw platform client rather than @@ -704,6 +706,145 @@ func TestInstances(t *testing.T) { r.Run(t, []string{"unikraft", "instance", "delete", "test-" + instName}) }) + t.Run("plugin-create", func(t *testing.T) { + r := runner(t, true, []string{staging}) + instName := uniq() + + out := r.Run(t, []string{ + "unikraft", "instance", "create", + "--set", "name=test-" + instName, + "--set", "metro=" + r.Config.MetroName, + "--set", "image=nginx:latest", + "--set", "autostart=false", + "--set", "resources.memory=128", + "--set", "resources.vcpus=1", + "--plugin", "name=sandbox,rom=" + sandboxPluginRom, + }) + assert.Regexp(t, `name:\s+sandbox`, out) + assert.Regexp(t, `rom:\s+\S*plugins/sandbox`, out) + + out = r.Run(t, []string{"unikraft", "instance", "inspect", "test-" + instName}) + assert.Regexp(t, `name:\s+sandbox`, out) + assert.Regexp(t, `rom:\s+\S*plugins/sandbox`, out) + + r.Run(t, []string{"unikraft", "instance", "delete", "test-" + instName}) + }) + + t.Run("plugin-config", func(t *testing.T) { + r := runner(t, true, []string{staging}) + instName := uniq() + const config = `{"level":"debug","tags":["a","b"],"msg":"p,q"}` + + r.Run(t, []string{ + "unikraft", "instance", "create", + "--output", "quiet", + "--set", "name=test-" + instName, + "--set", "metro=" + r.Config.MetroName, + "--set", "image=nginx:latest", + "--set", "autostart=false", + "--set", "resources.memory=128", + "--set", "resources.vcpus=1", + "--plugin", `name=sandbox,rom=` + sandboxPluginRom + `,config=` + config, + }) + + out := r.Run(t, []string{"unikraft", "instance", "inspect", "test-" + instName, "--output", "json"}) + var instances []struct { + Plugins []struct { + Name string `json:"name"` + Rom string `json:"rom"` + Config json.RawMessage `json:"config"` + } `json:"plugins"` + } + require.NoError(t, json.Unmarshal([]byte(out), &instances)) + require.Len(t, instances, 1) + require.Len(t, instances[0].Plugins, 1) + assert.Equal(t, "sandbox", instances[0].Plugins[0].Name) + assert.Contains(t, instances[0].Plugins[0].Rom, "plugins/sandbox") + assert.JSONEq(t, config, string(instances[0].Plugins[0].Config)) + + r.Run(t, []string{"unikraft", "instance", "delete", "test-" + instName}) + }) + + t.Run("plugin-edit", func(t *testing.T) { + r := runner(t, true, []string{staging}) + instName := uniq() + + r.Run(t, []string{ + "unikraft", "instance", "create", + "--output", "quiet", + "--set", "name=test-" + instName, + "--set", "metro=" + r.Config.MetroName, + "--set", "image=nginx:latest", + "--set", "autostart=false", + "--set", "resources.memory=128", + "--set", "resources.vcpus=1", + }) + + r.Run(t, []string{ + "unikraft", "instance", "edit", "test-" + instName, + "--output", "quiet", + "--plugin", "name=first,rom=" + sandboxPluginRom, + }) + out := r.Run(t, []string{"unikraft", "instance", "inspect", "test-" + instName}) + assert.Regexp(t, `name:\s+first`, out) + + r.Run(t, []string{ + "unikraft", "instance", "edit", "test-" + instName, + "--output", "quiet", + "--add", "plugins=name=second,rom=" + sandboxPluginRom, + }) + out = r.Run(t, []string{"unikraft", "instance", "inspect", "test-" + instName}) + assert.Regexp(t, `name:\s+first`, out) + assert.Regexp(t, `name:\s+second`, out) + + r.Run(t, []string{ + "unikraft", "instance", "edit", "test-" + instName, + "--output", "quiet", + "--del", "plugins=first", + }) + out = r.Run(t, []string{"unikraft", "instance", "inspect", "test-" + instName}) + assert.NotRegexp(t, `name:\s+first`, out) + assert.Regexp(t, `name:\s+second`, out) + + r.Run(t, []string{ + "unikraft", "instance", "edit", "test-" + instName, + "--output", "quiet", + "--plugin", "name=only,rom=" + sandboxPluginRom, + }) + out = r.Run(t, []string{"unikraft", "instance", "inspect", "test-" + instName}) + assert.Regexp(t, `name:\s+only`, out) + assert.NotRegexp(t, `name:\s+second`, out) + + r.Run(t, []string{"unikraft", "instance", "delete", "test-" + instName}) + }) + + t.Run("plugin-invalid", func(t *testing.T) { + tests := []struct { + name string + plugin string + want string + }{ + {"missing-name", "rom=" + sandboxPluginRom, "must specify name= for a plugin"}, + {"missing-rom", "name=sandbox", `must specify rom= for plugin "sandbox"`}, + {"config-only", `config={"level":"debug"}`, "must specify name= for a plugin"}, + {"invalid-json", "name=sandbox,rom=" + sandboxPluginRom + ",config={oops}", "config is not valid JSON"}, + {"truncated-json", `name=sandbox,rom=` + sandboxPluginRom + `,config={"level":"debug"`, `missing "}"`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := runner(t, false, []string{staging, stable}) + out := r.Run(t, []string{ + "unikraft", "instance", "create", + "--set", "name=test-plugin-invalid", + "--set", "metro=fra", + "--set", "image=nginx:latest", + "--plugin", tt.plugin, + }, integ.ExpectFail()) + assert.Contains(t, out, tt.want) + }) + } + }) + t.Run("volume-add", func(t *testing.T) { r := runner(t, true, []string{staging, stable}) instName := uniq() diff --git a/cmd/unikraft/testdata/TestHelp/instances b/cmd/unikraft/testdata/TestHelp/instances index ee580e2d..e4fb2805 100644 --- a/cmd/unikraft/testdata/TestHelp/instances +++ b/cmd/unikraft/testdata/TestHelp/instances @@ -62,6 +62,7 @@ Fields: volumes, volumes.*, volumes.*.name, volumes.*.uuid, volumes.*.at, volumes.*.readonly, volumes.*.size roms, roms.*, roms.*.name, roms.*.image, roms.*.dir, roms.*.at + plugins, plugins.*, plugins.*.name, plugins.*.rom, plugins.*.config networks, networks.*, networks.*.uuid, networks.*.private-ip, networks.*.mac gpus, gpus.*, gpus.*.uuid, gpus.*.model timestamps, timestamps.created, timestamps.started, timestamps.stopped @@ -134,6 +135,7 @@ Fields: volumes, volumes.*, volumes.*.name, volumes.*.uuid, volumes.*.at, volumes.*.readonly, volumes.*.size roms, roms.*, roms.*.name, roms.*.image, roms.*.dir, roms.*.at + plugins, plugins.*, plugins.*.name, plugins.*.rom, plugins.*.config networks, networks.*, networks.*.uuid, networks.*.private-ip, networks.*.mac gpus, gpus.*, gpus.*.uuid, gpus.*.model timestamps, timestamps.created, timestamps.started, timestamps.stopped @@ -214,6 +216,7 @@ Fields: volumes, volumes.*, volumes.*.name, volumes.*.uuid, volumes.*.at, volumes.*.readonly, volumes.*.size roms, roms.*, roms.*.name, roms.*.image, roms.*.dir, roms.*.at + plugins, plugins.*, plugins.*.name, plugins.*.rom, plugins.*.config networks, networks.*, networks.*.uuid, networks.*.private-ip, networks.*.mac gpus, gpus.*, gpus.*.uuid, gpus.*.model timestamps, timestamps.created, timestamps.started, timestamps.stopped @@ -296,6 +299,7 @@ Fields: volumes, volumes.*, volumes.*.name, volumes.*.uuid, volumes.*.at, volumes.*.readonly, volumes.*.size roms, roms.*, roms.*.name, roms.*.image, roms.*.dir, roms.*.at + plugins, plugins.*, plugins.*.name, plugins.*.rom, plugins.*.config networks, networks.*, networks.*.uuid, networks.*.private-ip, networks.*.mac gpus, gpus.*, gpus.*.uuid, gpus.*.model timestamps, timestamps.created, timestamps.started, timestamps.stopped @@ -369,6 +373,20 @@ Examples: --metro fra \ --template my-template + # Create an instance with a plugin + unikraft instance create \ + --name demo-instance \ + --metro fra \ + --image nginx:latest \ + --plugin name=sandbox,rom=plugins/sandbox:latest + + # Create an instance with a configured plugin + unikraft instance create \ + --name demo-instance \ + --metro fra \ + --image nginx:latest \ + --plugin 'name=logger,rom=plugins/logger:latest,config={"level":"debug"}' + Fields: metro name @@ -388,6 +406,7 @@ Fields: volumes, volumes.*, volumes.*.name, volumes.*.uuid, volumes.*.at, volumes.*.readonly, volumes.*.size roms, roms.*, roms.*.name, roms.*.image, roms.*.dir, roms.*.at + plugins, plugins.*, plugins.*.name, plugins.*.rom, plugins.*.config networks, networks.*, networks.*.uuid, networks.*.private-ip, networks.*.mac gpus, gpus.*, gpus.*.uuid, gpus.*.model timestamps, timestamps.created, timestamps.started, timestamps.stopped @@ -480,6 +499,9 @@ Create flags: --rom=image=,at= ... Attach ROM. [examples: image=myuser/my-rom:latest,at=/rom0,name=my-rom, dir=./mydata,at=/rom] + --plugin=name=,rom=[,config=] ... + Load plugin into the instance. + [examples: name=sandbox,rom=plugins/sandbox:latest, name=logger,rom=plugins/logger:latest,config={"level":"debug"}] --service= Service group name or key. -p, --publish=:[/] ... @@ -543,6 +565,20 @@ Examples: --metro fra \ --template my-template + # Create an instance with a plugin + unikraft instance create \ + --name demo-instance \ + --metro fra \ + --image nginx:latest \ + --plugin name=sandbox,rom=plugins/sandbox:latest + + # Create an instance with a configured plugin + unikraft instance create \ + --name demo-instance \ + --metro fra \ + --image nginx:latest \ + --plugin 'name=logger,rom=plugins/logger:latest,config={"level":"debug"}' + Fields: metro name @@ -562,6 +598,7 @@ Fields: volumes, volumes.*, volumes.*.name, volumes.*.uuid, volumes.*.at, volumes.*.readonly, volumes.*.size roms, roms.*, roms.*.name, roms.*.image, roms.*.dir, roms.*.at + plugins, plugins.*, plugins.*.name, plugins.*.rom, plugins.*.config networks, networks.*, networks.*.uuid, networks.*.private-ip, networks.*.mac gpus, gpus.*, gpus.*.uuid, gpus.*.model timestamps, timestamps.created, timestamps.started, timestamps.stopped @@ -654,6 +691,9 @@ Create flags: --rom=image=,at= ... Attach ROM. [examples: image=myuser/my-rom:latest,at=/rom0,name=my-rom, dir=./mydata,at=/rom] + --plugin=name=,rom=[,config=] ... + Load plugin into the instance. + [examples: name=sandbox,rom=plugins/sandbox:latest, name=logger,rom=plugins/logger:latest,config={"level":"debug"}] --service= Service group name or key. -p, --publish=:[/] ... @@ -736,6 +776,10 @@ Examples: # Deploy a new instance with specific restart policy unikraft instance run --metro=dal --image=my-app:latest --restart=on-failure + # Deploy a new instance with a configured plugin + unikraft instance run --metro=fra --image=my-app:latest --plugin + 'name=logger,rom=plugins/logger:latest,config={"level":"debug"}' + Fields: metro name @@ -755,6 +799,7 @@ Fields: volumes, volumes.*, volumes.*.name, volumes.*.uuid, volumes.*.at, volumes.*.readonly, volumes.*.size roms, roms.*, roms.*.name, roms.*.image, roms.*.dir, roms.*.at + plugins, plugins.*, plugins.*.name, plugins.*.rom, plugins.*.config networks, networks.*, networks.*.uuid, networks.*.private-ip, networks.*.mac gpus, gpus.*, gpus.*.uuid, gpus.*.model timestamps, timestamps.created, timestamps.started, timestamps.stopped @@ -849,6 +894,9 @@ Create flags: --rom=image=,at= ... Attach ROM. [examples: image=myuser/my-rom:latest,at=/rom0,name=my-rom, dir=./mydata,at=/rom] + --plugin=name=,rom=[,config=] ... + Load plugin into the instance. + [examples: name=sandbox,rom=plugins/sandbox:latest, name=logger,rom=plugins/logger:latest,config={"level":"debug"}] --service= Service group name or key. -p, --publish=:[/] ... @@ -940,6 +988,7 @@ Fields: volumes, volumes.*, volumes.*.name, volumes.*.uuid, volumes.*.at, volumes.*.readonly, volumes.*.size roms, roms.*, roms.*.name, roms.*.image, roms.*.dir, roms.*.at + plugins, plugins.*, plugins.*.name, plugins.*.rom, plugins.*.config networks, networks.*, networks.*.uuid, networks.*.private-ip, networks.*.mac gpus, gpus.*, gpus.*.uuid, gpus.*.model timestamps, timestamps.created, timestamps.started, timestamps.stopped @@ -1019,6 +1068,9 @@ Edit flags: --rom=image=,at= ... Attach ROM. [examples: image=myuser/my-rom:latest,at=/rom0,name=my-rom, dir=./mydata,at=/rom] + --plugin=name=,rom=[,config=] ... + Load plugin into the instance. + [examples: name=sandbox,rom=plugins/sandbox:latest, name=logger,rom=plugins/logger:latest,config={"level":"debug"}] --tag= ... Instance tag. [example: env-prod] @@ -1069,6 +1121,7 @@ Fields: volumes, volumes.*, volumes.*.name, volumes.*.uuid, volumes.*.at, volumes.*.readonly, volumes.*.size roms, roms.*, roms.*.name, roms.*.image, roms.*.dir, roms.*.at + plugins, plugins.*, plugins.*.name, plugins.*.rom, plugins.*.config networks, networks.*, networks.*.uuid, networks.*.private-ip, networks.*.mac 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 3ab2bbca..bfe441a5 100644 --- a/cmd/unikraft/testdata/TestHelp/run +++ b/cmd/unikraft/testdata/TestHelp/run @@ -39,6 +39,10 @@ Examples: # Deploy a new instance with specific restart policy unikraft run --metro=dal --image=my-app:latest --restart=on-failure + # Deploy a new instance with a configured plugin + unikraft run --metro=fra --image=my-app:latest --plugin + 'name=logger,rom=plugins/logger:latest,config={"level":"debug"}' + Fields: metro name @@ -58,6 +62,7 @@ Fields: volumes, volumes.*, volumes.*.name, volumes.*.uuid, volumes.*.at, volumes.*.readonly, volumes.*.size roms, roms.*, roms.*.name, roms.*.image, roms.*.dir, roms.*.at + plugins, plugins.*, plugins.*.name, plugins.*.rom, plugins.*.config networks, networks.*, networks.*.uuid, networks.*.private-ip, networks.*.mac gpus, gpus.*, gpus.*.uuid, gpus.*.model timestamps, timestamps.created, timestamps.started, timestamps.stopped @@ -152,6 +157,9 @@ Create flags: --rom=image=,at= ... Attach ROM. [examples: image=myuser/my-rom:latest,at=/rom0,name=my-rom, dir=./mydata,at=/rom] + --plugin=name=,rom=[,config=] ... + Load plugin into the instance. + [examples: name=sandbox,rom=plugins/sandbox:latest, name=logger,rom=plugins/logger:latest,config={"level":"debug"}] --service= Service group name or key. -p, --publish=:[/] ... diff --git a/internal/cmd/instances.go b/internal/cmd/instances.go index 3033990b..c287b38a 100644 --- a/internal/cmd/instances.go +++ b/internal/cmd/instances.go @@ -26,6 +26,7 @@ import ( "github.com/distribution/reference" "github.com/go-json-experiment/json/jsontext" "mvdan.cc/sh/v3/shell" + "unikraft.com/cloud/sdk/platform" "unikraft.com/cloud/sdk/platform/group" "unikraft.com/cloud/sdk/platform/logs" @@ -93,6 +94,8 @@ type InstanceCreateCmd struct { Volume []InstanceVolume `group:"flag-create" shortcut:"volumes" short:"v" sep:"none" help:"Attach volume." placeholder:":[:]" example:"my-vol:/data,cache:/tmp:ro,data:/mnt:size=10GiB"` Rom []InstanceRom `group:"flag-create" shortcut:"roms" sep:"none" help:"Attach ROM." placeholder:"image=,at=" example:"image=myuser/my-rom:latest\\,at=/rom0\\,name=my-rom,dir=./mydata\\,at=/rom"` + Plugin []InstancePlugin `group:"flag-create" shortcut:"plugins" sep:"none" help:"Load plugin into the instance." placeholder:"name=,rom=[,config=]" example:"name=sandbox\\,rom=plugins/sandbox:latest,name=logger\\,rom=plugins/logger:latest\\,config={\"level\":\"debug\"}"` + Service InstanceService `group:"flag-create" shortcut:"service" help:"Service group name or key." placeholder:"name"` Publish []Service `group:"flag-create" shortcut:"service.services" short:"p" sep:"none" help:"Publish port." placeholder:":[/]" example:"443:8080/http+tls"` Domain []Domain `group:"flag-create" shortcut:"service.domains" sep:"none" help:"Service domain." placeholder:"fqdn" example:"example.com"` @@ -150,6 +153,8 @@ type InstanceEditCmd struct { Rom []InstanceRom `group:"flag-edit" shortcut:"roms" sep:"none" help:"Attach ROM." placeholder:"image=,at=" example:"image=myuser/my-rom:latest\\,at=/rom0\\,name=my-rom,dir=./mydata\\,at=/rom"` + Plugin []InstancePlugin `group:"flag-edit" shortcut:"plugins" sep:"none" help:"Load plugin into the instance." placeholder:"name=,rom=[,config=]" example:"name=sandbox\\,rom=plugins/sandbox:latest,name=logger\\,rom=plugins/logger:latest\\,config={\"level\":\"debug\"}"` + Tag []string `group:"flag-edit" shortcut:"tags" sep:"none" help:"Instance tag." placeholder:"tag" example:"env-prod"` Annotation []string `group:"flag-edit" shortcut:"annotations" sep:"none" help:"Instance annotation." placeholder:"=" example:"env=production,example.com/team=platform"` @@ -193,6 +198,7 @@ type Instance struct { Service *InstanceService `mirror:"instance.service_group" field:",embed" create:"set"` Volumes []*InstanceVolume `mirror:"instance.volumes" field:",embed" create:"set" edit:"add,del=strings"` Roms []*InstanceRom `mirror:"instance.roms" field:",embed" create:"set" edit:"set,add,del=strings"` + Plugins []*InstancePlugin `mirror:"instance.plugins" field:",embed" create:"set" edit:"set,add,del=strings"` Networks []InstanceNetwork `mirror:"instance.network_interfaces" field:",embed"` Gpus []InstanceGpu `mirror:"instance.gpus" field:"gpus,embed"` @@ -452,6 +458,90 @@ func (r *InstanceRom) UnmarshalJSON(data []byte) error { return json.Unmarshal(data, (*romJSON)(r)) } +// PluginConfig is a plugin's arbitrary JSON configuration, carried as raw JSON +type PluginConfig string + +func (c PluginConfig) Validate() error { + if c == "" || json.Valid([]byte(c)) { + return nil + } + return fmt.Errorf("config is not valid JSON: %s", string(c)) +} + +func (c *PluginConfig) UnmarshalJSON(data []byte) error { + v := PluginConfig(data) + if len(data) != 0 && data[0] == '"' { + var text string + if err := json.Unmarshal(data, &text); err != nil { + return err + } + v = PluginConfig(text) + } + if err := v.Validate(); err != nil { + return err + } + *c = v + return nil +} + +func (c PluginConfig) MarshalJSON() ([]byte, error) { + if c == "" { + return []byte("null"), nil + } + if err := c.Validate(); err != nil { + return nil, err + } + return []byte(c), nil +} + +// InstancePlugin represents a plugin loaded into an instance. +// Parsed via value.Parse as comma-separated key=value pairs: +// +// name=,rom=[,config=] +type InstancePlugin struct { + Name string `name:"name" mirror:"name" json:"name" field:",long"` + Rom string `name:"rom" mirror:"image" json:"rom" field:",long"` + Config PluginConfig `name:"config" mirror:"config" json:"config,omitempty" field:",long"` +} + +func (p InstancePlugin) Validate() error { + if p.Name == "" { + return fmt.Errorf("must specify name= for a plugin") + } + if p.Rom == "" { + return fmt.Errorf("must specify rom= for plugin %q", p.Name) + } + if err := p.Config.Validate(); err != nil { + return fmt.Errorf("plugin %q: %w", p.Name, err) + } + return nil +} + +func (p *InstancePlugin) UnmarshalText(data []byte) error { + type alias InstancePlugin + parsed, err := value.Parse[alias]([]string{string(data)}) + if err != nil { + return err + } + *p = InstancePlugin(parsed) + return p.Validate() +} + +func (p *InstancePlugin) UnmarshalJSON(data []byte) error { + if len(data) != 0 && data[0] == '"' { + var text string + if err := json.Unmarshal(data, &text); err != nil { + return err + } + return p.UnmarshalText([]byte(text)) + } + type pluginJSON InstancePlugin + if err := json.Unmarshal(data, (*pluginJSON)(p)); err != nil { + return err + } + return p.Validate() +} + // inlineFilesFromDir walks a local directory and returns its contents as // base64-encoded InlineFile entries suitable for the platform API. func inlineFilesFromDir(dir string) ([]platform.InlineFile, error) { @@ -1034,6 +1124,27 @@ func instancePatchSpec(path string, op patchOp, value any) (platform.MutableInst reqRoms = append(reqRoms, reqRom) } return platform.MutableInstancePropertyRoms, reqRoms, nil + case "plugins": + if op == patchOpDel { + return platform.MutableInstancePropertyPlugins, value.([]string), nil + } + plugins := value.([]*InstancePlugin) + var reqPlugins []map[string]any + for _, plugin := range plugins { + if err := plugin.Validate(); err != nil { + return zero, nil, err + } + + reqPlugin := map[string]any{ + "name": plugin.Name, + "rom": plugin.Rom, + } + if plugin.Config != "" { + reqPlugin["config"] = jsontext.Value(plugin.Config) + } + reqPlugins = append(reqPlugins, reqPlugin) + } + return platform.MutableInstancePropertyPlugins, reqPlugins, nil case "delete-lock": return platform.MutableInstancePropertyDeleteLock, value.(bool), nil default: @@ -1162,6 +1273,21 @@ func (Instance) Create(ctx context.Context, fields []resource.Field) ([]resource } req.Roms = append(req.Roms, reqRom) } + case "plugins": + for _, plugin := range field.Create.Set.([]*InstancePlugin) { + if err := plugin.Validate(); err != nil { + return nil, err + } + reqPlugin := platform.CreateInstanceRequestPlugin{ + Name: plugin.Name, + Rom: platform.ImageReference(plugin.Rom), + } + if plugin.Config != "" { + var config any = jsontext.Value(plugin.Config) + reqPlugin.Config = &config + } + req.Plugins = append(req.Plugins, reqPlugin) + } case "service": svc := field.Create.Set.(*InstanceService) if req.ServiceGroup == nil { @@ -1381,6 +1507,26 @@ func (Instance) Examples() map[cmd.CmdType][]kingkong.Example { --template my-template`, }, }, + { + Description: "Create an instance with a plugin", + Commands: []string{ + `unikraft instance create \ + --name demo-instance \ + --metro fra \ + --image nginx:latest \ + --plugin name=sandbox,rom=plugins/sandbox:latest`, + }, + }, + { + Description: "Create an instance with a configured plugin", + Commands: []string{ + `unikraft instance create \ + --name demo-instance \ + --metro fra \ + --image nginx:latest \ + --plugin 'name=logger,rom=plugins/logger:latest,config={"level":"debug"}'`, + }, + }, }, cmd.CmdTypeEdit: { { diff --git a/internal/cmd/instances_test.go b/internal/cmd/instances_test.go new file mode 100644 index 00000000..56816705 --- /dev/null +++ b/internal/cmd/instances_test.go @@ -0,0 +1,146 @@ +// 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_test + +import ( + "encoding/json" + "testing" + + "github.com/go-json-experiment/json/jsontext" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "unikraft.com/cloud/sdk/platform" + + "unikraft.com/cli/internal/cmd" + "unikraft.com/cli/internal/mirror" +) + +func TestInstancePluginUnmarshalText(t *testing.T) { + tests := []struct { + name string + in string + want cmd.InstancePlugin + wantErr string + }{ + { + name: "name and rom", + in: "name=sandbox,rom=plugins/sandbox:latest", + want: cmd.InstancePlugin{Name: "sandbox", Rom: "plugins/sandbox:latest"}, + }, + { + name: "object config", + in: `name=logger,rom=plugins/logger:latest,config={"level":"debug"}`, + want: cmd.InstancePlugin{Name: "logger", Rom: "plugins/logger:latest", Config: `{"level":"debug"}`}, + }, + { + name: "config keeps its own commas", + in: `name=logger,rom=r:1,config={"a":1,"tags":["x","y"],"msg":"p,q"}`, + want: cmd.InstancePlugin{Name: "logger", Rom: "r:1", Config: `{"a":1,"tags":["x","y"],"msg":"p,q"}`}, + }, + { + name: "scalar config", + in: "name=logger,rom=r:1,config=5", + want: cmd.InstancePlugin{Name: "logger", Rom: "r:1", Config: "5"}, + }, + { + name: "missing name is rejected", + in: "rom=plugins/logger:latest", + wantErr: "must specify name= for a plugin", + }, + { + name: "missing rom is rejected", + in: "name=logger", + wantErr: `must specify rom= for plugin "logger"`, + }, + { + name: "config alone is rejected", + in: `config={"level":"debug"}`, + wantErr: "must specify name= for a plugin", + }, + { + name: "empty is rejected", + in: "", + wantErr: "must specify name= for a plugin", + }, + { + name: "truncated config is reported by the splitter", + in: "name=logger,rom=r:1,config={oops", + wantErr: `missing "}"`, + }, + { + name: "malformed config is rejected at parse time", + in: "name=logger,rom=r:1,config={oops}", + wantErr: "not valid JSON", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got cmd.InstancePlugin + 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) + }) + } +} + +func TestInstancePluginJSONRequiresFields(t *testing.T) { + for _, in := range []string{ + `{"config":{"level":"debug"}}`, + `{"name":"logger"}`, + `{"rom":"plugins/logger:latest"}`, + `"config={\"level\":\"debug\"}"`, + } { + var p cmd.InstancePlugin + assert.ErrorContains(t, json.Unmarshal([]byte(in), &p), "must specify", "input %s", in) + } +} + +func TestInstancePluginConfigMirror(t *testing.T) { + tests := []struct { + name string + config any + wantConfig cmd.PluginConfig + }{ + {"object serialized by the platform", `{"level":"debug"}`, `{"level":"debug"}`}, + {"array serialized by the platform", "[1,2]", "[1,2]"}, + {"number serialized by the platform", "30", "30"}, + {"string serialized by the platform", `"debug"`, `"debug"`}, + {"structured object", map[string]any{"level": "debug"}, `{"level":"debug"}`}, + {"absent", nil, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + plugin := platform.InstancePlugin{ + Name: "logger", + AdditionalProperties: map[string]jsontext.Value{ + "image": jsontext.Value(`"plugins/logger:latest"`), + }, + } + if tt.config != nil { + cfg := tt.config + plugin.Config = &cfg + } + result := cmd.Instance{Instance: platform.Instance{ + Name: "demo-instance", + Uuid: "instance-uuid-1234", + State: platform.InstanceStateRunning, + Image: "nginx:latest", + Plugins: []platform.InstancePlugin{plugin}, + }} + + require.NoError(t, mirror.Mirror(result, &result)) + require.Len(t, result.Plugins, 1) + assert.Equal(t, "logger", result.Plugins[0].Name) + assert.Equal(t, "plugins/logger:latest", result.Plugins[0].Rom) + assert.Equal(t, tt.wantConfig, result.Plugins[0].Config) + }) + } +} diff --git a/internal/cmd/marshal_test.go b/internal/cmd/marshal_test.go index 7e7d239b..fa35b6f6 100644 --- a/internal/cmd/marshal_test.go +++ b/internal/cmd/marshal_test.go @@ -91,6 +91,14 @@ func TestJSONRoundTrip(t *testing.T) { wantObject: &cmd.InstanceRom{Name: "my-rom", Image: "myuser/my-rom:latest", At: "/rom"}, wantText: &cmd.InstanceRom{Name: "my-rom", Image: "myuser/my-rom:latest", At: "/rom"}, }, + { + name: "InstancePlugin", + object: `{"name":"logger","rom":"plugins/logger:latest","config":{"level":"debug"}}`, + text: `"name=logger,rom=plugins/logger:latest,config={\"level\":\"debug\"}"`, + into: func() any { return &cmd.InstancePlugin{} }, + wantObject: &cmd.InstancePlugin{Name: "logger", Rom: "plugins/logger:latest", Config: `{"level":"debug"}`}, + wantText: &cmd.InstancePlugin{Name: "logger", Rom: "plugins/logger:latest", Config: `{"level":"debug"}`}, + }, { name: "InstanceScaleToZero", object: `{"policy":"on","stateful":true,"cooldown-time":500,"notify-time":100}`, @@ -296,6 +304,30 @@ func TestEditPatches(t *testing.T) { spec: patch.PatchSpec{Del: map[string][]string{"roms": {"r1", "r2"}}}, want: map[string]string{"roms.del": `["r1", "r2"]`}, }, + { + name: "plugins set with config", + res: cmd.Instance{}, + spec: patch.PatchSpec{Set: map[string][]string{"plugins": {`name=logger,rom=plugins/logger:latest,config={"level":"debug"}`}}}, + want: map[string]string{"plugins.set": `[name=logger, rom=plugins/logger:latest, config={"level":"debug"}]`}, + }, + { + name: "plugins add", + res: cmd.Instance{}, + spec: patch.PatchSpec{Add: map[string][]string{"plugins": {"name=sandbox,rom=plugins/sandbox:latest"}}}, + want: map[string]string{"plugins.add": "[name=sandbox, rom=plugins/sandbox:latest]"}, + }, + { + name: "plugins set without rom rejected", + res: cmd.Instance{}, + spec: patch.PatchSpec{Set: map[string][]string{"plugins": {`config={"level":"debug"}`}}}, + wantErr: "must specify name= for a plugin", + }, + { + name: "plugins del by name", + res: cmd.Instance{}, + spec: patch.PatchSpec{Del: map[string][]string{"plugins": {"logger", "sandbox"}}}, + want: map[string]string{"plugins.del": `["logger", "sandbox"]`}, + }, { name: "env keeps commas verbatim", res: cmd.Instance{}, diff --git a/internal/cmd/output_test.go b/internal/cmd/output_test.go index e67a4277..b8be6c19 100644 --- a/internal/cmd/output_test.go +++ b/internal/cmd/output_test.go @@ -125,6 +125,9 @@ func instancesOutputTests(t *testing.T) { Roms: []*cmd.InstanceRom{ {Name: "my-rom", Image: "myuser/my-rom:latest", At: "/rom"}, }, + Plugins: []*cmd.InstancePlugin{ + {Name: "sandbox", Rom: "plugins/sandbox:latest", Config: `{"timeout":30}`}, + }, ScaleToZero: cmd.InstanceScaleToZero{ Policy: "on", Stateful: true, diff --git a/internal/cmd/run.go b/internal/cmd/run.go index 8ff3b0eb..592f535a 100644 --- a/internal/cmd/run.go +++ b/internal/cmd/run.go @@ -96,6 +96,12 @@ func (InstanceRunCmd) Examples() []kingkong.Example { "unikraft instance run --metro=dal --image=my-app:latest --restart=on-failure", }, }, + { + Description: "Deploy a new instance with a configured plugin", + Commands: []string{ + `unikraft instance run --metro=fra --image=my-app:latest --plugin 'name=logger,rom=plugins/logger:latest,config={"level":"debug"}'`, + }, + }, } } diff --git a/internal/cmd/testdata/TestOutput/instances b/internal/cmd/testdata/TestOutput/instances index c4e810df..4c647f13 100644 --- a/internal/cmd/testdata/TestOutput/instances +++ b/internal/cmd/testdata/TestOutput/instances @@ -31,6 +31,10 @@ roms: - name: my-rom image: myuser/my-rom:latest at: /rom +plugins: +- name: sandbox + rom: plugins/sandbox:latest + config: {"timeout":30} networks: - uuid: net-uuid-1234 private-ip: 192.168.1.10 @@ -87,6 +91,10 @@ roms: - name: my-rom image: myuser/my-rom:latest at: /rom +plugins: +- name: sandbox + rom: plugins/sandbox:latest + config: {"timeout":30} networks: - uuid: net-uuid-1234 private-ip: 192.168.1.10 @@ -631,6 +639,80 @@ fra my-instance running nginx ["arg1", "arg2"] 256MiB 2 example.uni "del": null } }, + { + "name": "plugins", + "subfields": [ + { + "name": "0", + "subfields": [ + { + "name": "name", + "value": "sandbox", + "verbosity": "long" + }, + { + "name": "rom", + "value": "plugins/sandbox:latest", + "verbosity": "long" + }, + { + "name": "config", + "value": { + "timeout": 30 + }, + "verbosity": "long" + } + ], + "verbosity": "long" + } + ], + "elem": { + "name": "", + "subfields": [ + { + "name": "name", + "value": "", + "verbosity": "long" + }, + { + "name": "rom", + "value": "", + "verbosity": "long" + }, + { + "name": "config", + "value": null, + "verbosity": "long" + } + ], + "verbosity": "long" + }, + "verbosity": "long", + "create": { + "set": [ + { + "name": "sandbox", + "rom": "plugins/sandbox:latest", + "config": { + "timeout": 30 + } + } + ] + }, + "edit": { + "set": [ + { + "name": "sandbox", + "rom": "plugins/sandbox:latest", + "config": { + "timeout": 30 + } + } + ], + "add": null, + "del": null + } + }, { "name": "networks", "subfields": [ diff --git a/internal/resource/value/parse.go b/internal/resource/value/parse.go index 1b4fef54..abf5b114 100644 --- a/internal/resource/value/parse.go +++ b/internal/resource/value/parse.go @@ -18,6 +18,76 @@ import ( xmaps "unikraft.com/cli/internal/x/maps" ) +// HACK: splitTopLevel splits s on commas, skipping those inside a JSON value. Only a +// value that opens with {, [ or " immediately after its key's "=" is treated as +// JSON, so a brace or quote appearing mid-value stays literal and the item +// separators around it still split. +func splitTopLevel(s string) ([]string, error) { + var parts []string + var open []byte + start := 0 + valuePos := -1 + inStr := false + esc := false + for i := 0; i < len(s); i++ { + c := s[i] + + if inStr { + switch { + case esc: + esc = false + case c == '\\': + esc = true + case c == '"': + inStr = false + } + continue + } + + if len(open) > 0 { + switch c { + case '"': + inStr = true + case '{': + open = append(open, '}') + case '[': + open = append(open, ']') + case '}', ']': + if open[len(open)-1] == c { + open = open[:len(open)-1] + } + } + continue + } + + switch { + case c == ',': + parts = append(parts, s[start:i]) + start = i + 1 + valuePos = -1 + case c == '=' && valuePos < 0: + valuePos = i + 1 + case i != valuePos: + case c == '{': + open = append(open, '}') + case c == '[': + open = append(open, ']') + case c == '"': + inStr = true + } + } + if inStr { + return nil, fmt.Errorf("unterminated quote in %q", s) + } + if len(open) > 0 { + return nil, fmt.Errorf("missing %q in %q", string(open[len(open)-1]), s) + } + if start < len(s) { + parts = append(parts, s[start:]) + } + return parts, nil +} + func Parse[T any](input []string) (T, error) { var t T output, err := ParseNew(input, t) @@ -187,8 +257,12 @@ func parseReflect(input []string, value reflect.Value) error { notFound := make(map[string]struct{}) for _, input := range input { + items, err := splitTopLevel(input) + if err != nil { + return err + } process: - for item := range strings.SplitSeq(input, ",") { + for _, item := range items { item = strings.TrimSpace(item) if item == "" { continue diff --git a/internal/resource/value/parse_test.go b/internal/resource/value/parse_test.go index 4c14092e..98e7a2a5 100644 --- a/internal/resource/value/parse_test.go +++ b/internal/resource/value/parse_test.go @@ -25,6 +25,96 @@ type testStructCollections struct { Env map[string]string `name:"env"` } +type testStructJSON struct { + Name string `name:"name"` + Config string `name:"config"` +} + +func TestSplitTopLevel(t *testing.T) { + tests := []struct { + name string + in string + want []string + wantErr string + }{ + {name: "plain", in: "a=1,b=2", want: []string{"a=1", "b=2"}}, + {name: "object", in: `a=1,b={"x":2}`, want: []string{"a=1", `b={"x":2}`}}, + {name: "array", in: "a=1,b=[1,2,3]", want: []string{"a=1", "b=[1,2,3]"}}, + {name: "nested", in: `a={"x":{"y":[1,2]}},b=2`, want: []string{`a={"x":{"y":[1,2]}}`, "b=2"}}, + {name: "comma inside string", in: `a={"x":"y,z"},b=2`, want: []string{`a={"x":"y,z"}`, "b=2"}}, + {name: "brace inside string", in: `a={"x":"}"},b=2`, want: []string{`a={"x":"}"}`, "b=2"}}, + {name: "escaped quote inside string", in: `a={"x":"y\",z"},b=2`, want: []string{`a={"x":"y\",z"}`, "b=2"}}, + {name: "trailing comma drops empty tail", in: "a=1,", want: []string{"a=1"}}, + {name: "unmatched closer still splits", in: "a=1,b=x],c=3", want: []string{"a=1", "b=x]", "c=3"}}, + {name: "invalid utf-8 passes through", in: "a=\xff\xfe,b=2", want: []string{"a=\xff\xfe", "b=2"}}, + {name: "quote mid-value stays literal", in: `image=my"img,at=/rom0,name=r`, want: []string{`image=my"img`, "at=/rom0", "name=r"}}, + {name: "brace mid-value stays literal", in: "name=a{b,rom=c}d", want: []string{"name=a{b", "rom=c}d"}}, + {name: "bracket mid-value stays literal", in: "image=a[b,at=/x]c", want: []string{"image=a[b", "at=/x]c"}}, + {name: "spaces are not trimmed around the separator", in: "a= 1 ,b=2", want: []string{"a= 1 ", "b=2"}}, + {name: "empty", in: "", want: nil}, + + {name: "unmatched opener", in: "a=1,b={,c=3", wantErr: `missing "}"`}, + {name: "unterminated quote at value start", in: `config="oops,name=x`, wantErr: "unterminated quote"}, + {name: "mismatched pair", in: `a={"x":1],b=2`, wantErr: `missing "}"`}, + {name: "mismatched pair the other way", in: "a=[1},b=2", wantErr: `missing "]"`}, + {name: "wrong closer", in: `a={"x":[1}},b=2`, wantErr: `missing "]"`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := splitTopLevel(tt.in) + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestParseStructJSONValue(t *testing.T) { + t.Run("object config", func(t *testing.T) { + got, err := Parse[testStructJSON]([]string{`name=logger,config={"level":"debug"}`}) + require.NoError(t, err) + assert.Equal(t, testStructJSON{Name: "logger", Config: `{"level":"debug"}`}, got) + }) + + t.Run("config keeps its own commas", func(t *testing.T) { + got, err := Parse[testStructJSON]([]string{`name=logger,config={"a":1,"b":[2,3]}`}) + require.NoError(t, err) + assert.Equal(t, testStructJSON{Name: "logger", Config: `{"a":1,"b":[2,3]}`}, got) + }) + + t.Run("config keeps commas inside strings", func(t *testing.T) { + got, err := Parse[testStructJSON]([]string{`config={"msg":"a,b"},name=logger`}) + require.NoError(t, err) + assert.Equal(t, testStructJSON{Name: "logger", Config: `{"msg":"a,b"}`}, got) + }) + + t.Run("array config", func(t *testing.T) { + got, err := Parse[testStructJSON]([]string{`name=logger,config=[1,2]`}) + require.NoError(t, err) + assert.Equal(t, testStructJSON{Name: "logger", Config: "[1,2]"}, got) + }) + + t.Run("scalar config", func(t *testing.T) { + got, err := Parse[testStructJSON]([]string{`name=logger,config=5`}) + require.NoError(t, err) + assert.Equal(t, testStructJSON{Name: "logger", Config: "5"}, got) + }) + + t.Run("a stray closer does not glue later fields together", func(t *testing.T) { + got, err := Parse[testStructJSON]([]string{"config=x],name=logger"}) + require.NoError(t, err) + assert.Equal(t, testStructJSON{Name: "logger", Config: "x]"}, got) + }) + + t.Run("a truncated config is reported, not split mid-value", func(t *testing.T) { + _, err := Parse[testStructJSON]([]string{`name=logger,config={"level":"debug"`}) + require.ErrorContains(t, err, `missing "}"`) + }) +} + func TestParseStandalone(t *testing.T) { t.Run("string", func(t *testing.T) { got, err := Parse[string]([]string{"hello"})