From 2ccf544f67f3c67bbeb6466abb4f0d9745d41d6c Mon Sep 17 00:00:00 2001 From: Luca Stocchi Date: Tue, 22 Oct 2024 20:03:36 +0200 Subject: [PATCH 1/3] ignition: add support for ignition config file it adds support to pass an ignition config file to vfkit that handles the provisioning process. Vfkit start a small server, listening to a unix socket to feed the config file, and create a virtio-vsock device using port 1024. This will be used by Ignition that read its configuration using an HTTP GET over a vsock connection on port 1024. Server code taken from https://github.com/containers/podman/blob/6487940534c1065d6c7753e3b6dfbe253666537d/pkg/machine/applehv/ignition.go Signed-off-by: Luca Stocchi --- cmd/vfkit/main.go | 52 +++++++++++++++++++++++++++++++++++++++++ pkg/cmdline/cmdline.go | 4 ++++ pkg/config/config.go | 49 ++++++++++++++++++++++++++++++++++++++ pkg/config/json.go | 28 ++++++++++++++++++++++ pkg/config/json_test.go | 12 +++++++++- 5 files changed, 144 insertions(+), 1 deletion(-) diff --git a/cmd/vfkit/main.go b/cmd/vfkit/main.go index 645bba27..9fbee45b 100644 --- a/cmd/vfkit/main.go +++ b/cmd/vfkit/main.go @@ -21,6 +21,9 @@ package main import ( "fmt" + "io" + "net" + "net/http" "os" "os/signal" "runtime" @@ -86,6 +89,10 @@ func newVMConfiguration(opts *cmdline.Options) (*config.VirtualMachine, error) { return nil, err } + if err := vmConfig.AddIgnitionFileFromCmdLine(opts.IgnitionPath); err != nil { + return nil, fmt.Errorf("failed to add ignition file: %w", err) + } + return vmConfig, nil } @@ -137,6 +144,21 @@ func runVFKit(vmConfig *config.VirtualMachine, opts *cmdline.Options) error { } func runVirtualMachine(vmConfig *config.VirtualMachine, vm *vf.VirtualMachine) error { + if vm.Config().Ignition != nil { + go func() { + file, err := os.Open(vmConfig.Ignition.ConfigPath) + if err != nil { + log.Error(err) + } + defer file.Close() + reader := file + if err := startIgnitionProvisionerServer(reader, vmConfig.Ignition.SocketPath); err != nil { + log.Error(err) + } + log.Debug("ignition vsock server exited") + }() + } + if err := vm.Start(); err != nil { return err } @@ -198,3 +220,33 @@ func runVirtualMachine(vmConfig *config.VirtualMachine, vm *vf.VirtualMachine) e return <-errCh } + +func startIgnitionProvisionerServer(ignitionReader io.Reader, ignitionSocketPath string) error { + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { + _, err := io.Copy(w, ignitionReader) + if err != nil { + log.Errorf("failed to serve ignition file: %v", err) + } + }) + + listener, err := net.Listen("unix", ignitionSocketPath) + if err != nil { + return err + } + defer func() { + if err := listener.Close(); err != nil { + log.Error(err) + } + }() + + srv := &http.Server{ + Handler: mux, + Addr: ignitionSocketPath, + ReadHeaderTimeout: 10 * time.Second, + IdleTimeout: 60 * time.Second, + } + + log.Debugf("ignition socket: %s", ignitionSocketPath) + return srv.Serve(listener) +} diff --git a/pkg/cmdline/cmdline.go b/pkg/cmdline/cmdline.go index 20bcea16..9f303d92 100644 --- a/pkg/cmdline/cmdline.go +++ b/pkg/cmdline/cmdline.go @@ -23,6 +23,8 @@ type Options struct { LogLevel string UseGUI bool + + IgnitionPath string } const DefaultRestfulURI = "none://" @@ -50,4 +52,6 @@ func AddFlags(cmd *cobra.Command, opts *Options) { cmd.Flags().StringVar(&opts.LogLevel, "log-level", "", "set log level") cmd.Flags().StringVar(&opts.RestfulURI, "restful-uri", DefaultRestfulURI, "URI address for RESTful services") + cmd.Flags().StringVar(&opts.IgnitionPath, "ignition", "", "path to the ignition file") + } diff --git a/pkg/config/config.go b/pkg/config/config.go index 34230579..483a9fb8 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -16,6 +16,7 @@ import ( "math" "os" "os/exec" + "path/filepath" "strconv" "strings" @@ -30,6 +31,7 @@ type VirtualMachine struct { Bootloader Bootloader `json:"bootloader"` Devices []VirtioDevice `json:"devices,omitempty"` Timesync *TimeSync `json:"timesync,omitempty"` + Ignition *Ignition `json:"ignition,omitempty"` } // TimeSync enables synchronization of the host time to the linux guest after the host was suspended. @@ -38,6 +40,11 @@ type TimeSync struct { VsockPort uint32 `json:"vsockPort"` } +type Ignition struct { + ConfigPath string `json:"configPath"` + SocketPath string `json:"socketPath"` +} + // The VMComponent interface represents a VM element (device, bootloader, ...) // which can be converted from/to commandline parameters type VMComponent interface { @@ -45,6 +52,11 @@ type VMComponent interface { ToCmdLine() ([]string, error) } +const ( + ignitionVsockPort uint = 1024 + ignitionSocketName string = "ignition.sock" +) + // NewVirtualMachine creates a new VirtualMachine instance. The virtual machine // will use vcpus virtual CPUs and it will be allocated memoryMiB mibibytes // (1024*1024 bytes) of RAM. bootloader specifies how the virtual machine will @@ -96,6 +108,10 @@ func (vm *VirtualMachine) ToCmdLine() ([]string, error) { args = append(args, devArgs...) } + if vm.Ignition != nil { + args = append(args, "--ignition", vm.Ignition.ConfigPath) + } + return args, nil } @@ -191,6 +207,39 @@ func (vm *VirtualMachine) TimeSync() *TimeSync { return vm.Timesync } +func IgnitionNew(configPath string, socketPath string) (*Ignition, error) { + if configPath == "" || socketPath == "" { + return nil, fmt.Errorf("config path and socket path cannot be empty") + } + return &Ignition{ + ConfigPath: configPath, + SocketPath: socketPath, + }, nil +} + +func (vm *VirtualMachine) AddIgnitionFileFromCmdLine(cmdlineOpts string) error { + if cmdlineOpts == "" { + return nil + } + opts := strings.Split(cmdlineOpts, ",") + if len(opts) != 1 { + return fmt.Errorf("ignition only accept one option in command line argument") + } + + socketPath := filepath.Join(os.TempDir(), ignitionSocketName) + dev, err := VirtioVsockNew(ignitionVsockPort, socketPath, true) + if err != nil { + return err + } + vm.Devices = append(vm.Devices, dev) + ignition, err := IgnitionNew(opts[0], socketPath) + if err != nil { + return err + } + vm.Ignition = ignition + return nil +} + func TimeSyncNew(vsockPort uint) (VMComponent, error) { if vsockPort > math.MaxUint32 { diff --git a/pkg/config/json.go b/pkg/config/json.go index ba011cff..a46667f9 100644 --- a/pkg/config/json.go +++ b/pkg/config/json.go @@ -28,6 +28,7 @@ const ( usbMassStorage vmComponentKind = "usbmassstorage" nvme vmComponentKind = "nvme" rosetta vmComponentKind = "rosetta" + ignition vmComponentKind = "ignition" ) type jsonKind struct { @@ -89,6 +90,17 @@ func unmarshalDevices(rawMsg json.RawMessage) ([]VirtioDevice, error) { return devices, nil } +func unmarshalIgnition(rawMsg json.RawMessage) (Ignition, error) { + var ignition Ignition + + err := json.Unmarshal(rawMsg, &ignition) + if err != nil { + return Ignition{}, err + } + + return ignition, nil +} + // VirtioNet needs a custom unmarshaller as net.HardwareAddress is not // serialized/unserialized in its expected format, instead of // '00:11:22:33:44:55', it's serialized as base64-encoded raw bytes such as @@ -208,6 +220,11 @@ func (vm *VirtualMachine) UnmarshalJSON(b []byte) error { if err == nil { vm.Devices = devices } + case "ignition": + ignition, err := unmarshalIgnition(*rawMsg) + if err == nil { + vm.Ignition = &ignition + } } if err != nil { @@ -367,3 +384,14 @@ func (dev *USBMassStorage) MarshalJSON() ([]byte, error) { USBMassStorage: *dev, }) } + +func (ign *Ignition) MarshalJSON() ([]byte, error) { + type devWithKind struct { + jsonKind + Ignition + } + return json.Marshal(devWithKind{ + jsonKind: kind(ignition), + Ignition: *ign, + }) +} diff --git a/pkg/config/json_test.go b/pkg/config/json_test.go index 1c9e76ec..1e1d76a8 100644 --- a/pkg/config/json_test.go +++ b/pkg/config/json_test.go @@ -85,6 +85,16 @@ var jsonTests = map[string]jsonTest{ }, expectedJSON: `{"vcpus":3,"memoryBytes":4194304000,"bootloader":{"kind":"linuxBootloader","vmlinuzPath":"/vmlinuz","initrdPath":"/initrd","kernelCmdLine":"console=hvc0"},"timesync":{"vsockPort":1234}}`, }, + "TestIgnition": { + newVM: func(t *testing.T) *VirtualMachine { + vm := newLinuxVM(t) + ignition, err := IgnitionNew("config", "socket") + require.NoError(t, err) + vm.Ignition = ignition + return vm + }, + expectedJSON: `{"vcpus":3,"memoryBytes":4194304000,"bootloader":{"kind":"linuxBootloader","vmlinuzPath":"/vmlinuz","initrdPath":"/initrd","kernelCmdLine":"console=hvc0"}, "ignition":{"kind":"ignition","configPath":"config","socketPath":"socket"}}`, + }, "TestVirtioRNG": { newVM: func(t *testing.T) *VirtualMachine { vm := newLinuxVM(t) @@ -207,7 +217,7 @@ var jsonStabilityTests = map[string]jsonStabilityTest{ return vm }, - skipFields: []string{"Bootloader", "Devices", "Timesync"}, + skipFields: []string{"Bootloader", "Devices", "Timesync", "Ignition"}, expectedJSON: `{"vcpus":3,"memoryBytes":3,"bootloader":{"kind":"linuxBootloader","vmlinuzPath":"/vmlinuz","kernelCmdLine":"console=hvc0","initrdPath":"/initrd"},"devices":[{"kind":"virtiorng"}],"timesync":{"vsockPort":1234}}`, }, "RosettaShare": { From d1daaca0719ea55fd7d8a5e122972b00518f6cff Mon Sep 17 00:00:00 2001 From: Luca Stocchi Date: Tue, 22 Oct 2024 19:30:54 +0200 Subject: [PATCH 2/3] test: add tests for new func supporting ignition Signed-off-by: Luca Stocchi --- cmd/vfkit/main_test.go | 44 +++++++++++++++++++++++++++++++++++++++ pkg/config/config_test.go | 23 ++++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 cmd/vfkit/main_test.go create mode 100644 pkg/config/config_test.go diff --git a/cmd/vfkit/main_test.go b/cmd/vfkit/main_test.go new file mode 100644 index 00000000..5990f388 --- /dev/null +++ b/cmd/vfkit/main_test.go @@ -0,0 +1,44 @@ +package main + +import ( + "bytes" + "io" + "net" + "net/http" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestStartIgnitionProvisionerServer(t *testing.T) { + socketPath := "virtiovsock" + defer os.Remove(socketPath) + + ignitionData := []byte("ignition configuration") + ignitionReader := bytes.NewReader(ignitionData) + + // Start the server using the socket so that it can returns the ignition data + go func() { + err := startIgnitionProvisionerServer(ignitionReader, socketPath) + require.NoError(t, err) + }() + + // Make a request to the server + client := http.Client{ + Transport: &http.Transport{ + Dial: func(_, _ string) (net.Conn, error) { + return net.Dial("unix", socketPath) + }, + }, + } + resp, err := client.Get("http://unix://" + socketPath) + require.NoError(t, err) + defer resp.Body.Close() + + // Verify the response from the server is actually the ignition data + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Equal(t, ignitionData, body) +} diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go new file mode 100644 index 00000000..75c8d175 --- /dev/null +++ b/pkg/config/config_test.go @@ -0,0 +1,23 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAddIgnitionFile_MultipleOptions(t *testing.T) { + vm := &VirtualMachine{} + err := vm.AddIgnitionFileFromCmdLine("file1,file2") + assert.EqualError(t, err, "ignition only accept one option in command line argument") +} + +func TestAddIgnitionFile_OneOption(t *testing.T) { + vm := &VirtualMachine{} + err := vm.AddIgnitionFileFromCmdLine("file1") + require.NoError(t, err) + assert.Len(t, vm.Devices, 1) + assert.Equal(t, uint32(ignitionVsockPort), vm.Devices[0].(*VirtioVsock).Port) + assert.Equal(t, "file1", vm.Ignition.ConfigPath) +} From 21c947b3cf41fd9a0157ab0c816e972a3a62b3f2 Mon Sep 17 00:00:00 2001 From: Luca Stocchi Date: Tue, 22 Oct 2024 20:04:28 +0200 Subject: [PATCH 3/3] doc: update doc for ignition flag usage it updates the doc to explain how the ignition flag is used. It also adds a new section under contrib where to store an example of configuration and link to the ignition website for more details about specification, mime type and other examples. Signed-off-by: Luca Stocchi --- contrib/ignition/README.md | 9 +++++++++ contrib/ignition/myconfig.json | 31 +++++++++++++++++++++++++++++++ doc/usage.md | 15 +++++++++++++++ 3 files changed, 55 insertions(+) create mode 100644 contrib/ignition/README.md create mode 100644 contrib/ignition/myconfig.json diff --git a/contrib/ignition/README.md b/contrib/ignition/README.md new file mode 100644 index 00000000..237fa3e3 --- /dev/null +++ b/contrib/ignition/README.md @@ -0,0 +1,9 @@ +## Ignition + +Ignition uses a JSON configuration file to define the desired changes. The format of this config is specified in detail [here](https://coreos.github.io/ignition/specs/), and its [MIME type](http://www.iana.org/assignments/media-types/application/vnd.coreos.ignition+json) is registered with IANA. + +`myconfig.json` file provides an example of configuration that adds a new `testuser`, creates a new file to `/etc/myapp` with the content listed in the same `files` section and add a systemd unit drop-in to modify the existing service `systemd-journald` and sets its environment variable SYSTEMD_LOG_LEVEL to debug. + +### Examples + +More examples can be found at https://coreos.github.io/ignition/examples/ diff --git a/contrib/ignition/myconfig.json b/contrib/ignition/myconfig.json new file mode 100644 index 00000000..79be1b97 --- /dev/null +++ b/contrib/ignition/myconfig.json @@ -0,0 +1,31 @@ +{ + "ignition": { + "version": "3.0.0" + }, + "passwd": { + "users": [ + { + "name": "testuser", + "sshAuthorizedKeys": [ + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIO43Z9aRPvvj1zMib/Hh4oWX+MuOPXdFChFMaWfishLj" + ] + } + ] + }, + "storage": { + "files": [{ + "path": "/etc/someconfig", + "mode": 420, + "contents": { "source": "data:,example%20file%0A" } + }] + }, + "systemd": { + "units": [{ + "name": "systemd-journald.service", + "dropins": [{ + "name": "debug.conf", + "contents": "[Service]\nEnvironment=SYSTEMD_LOG_LEVEL=debug" + }] + }] + } +} \ No newline at end of file diff --git a/doc/usage.md b/doc/usage.md index 8871435e..cc87a88f 100644 --- a/doc/usage.md +++ b/doc/usage.md @@ -460,3 +460,18 @@ Proper use of this flag may look similar to the following section of a command: ```bash --device virtio-input,keyboard --device virtio-input,pointing --device virtio-gpu,width=1920,height=1080 --gui ``` + +### Ignition + +#### Description + +The `--ignition` option allows you to specify a configuration file for Ignition. Vfkit will open a vsock connection between the host and the guest and start a lightweight HTTP server to push the configuration file to Ignition. + +You can find example configurations and more details about Ignition at https://coreos.github.io/ignition/ + +#### Example + +This command provisions the configuration file to Ignition on the guest +``` +--ignition configuration-path +```