From 37eb45e3c0f7886bc65687cf4e5031cdf6436fe2 Mon Sep 17 00:00:00 2001 From: Steffen Vogel Date: Tue, 27 Aug 2024 08:52:27 +0200 Subject: [PATCH] wip --- pkg/cloud-init-metadata.yaml | 2 + pkg/cloud-init-userdata.yaml | 4 + pkg/options/vm/vm.go | 96 +++++++++++++++++ pkg/vm.go | 195 +++++++++++++++++++++++++++++++++++ pkg/vm_test.go | 82 +++++++++++++++ 5 files changed, 379 insertions(+) create mode 100644 pkg/cloud-init-metadata.yaml create mode 100644 pkg/cloud-init-userdata.yaml create mode 100644 pkg/options/vm/vm.go create mode 100644 pkg/vm.go create mode 100644 pkg/vm_test.go diff --git a/pkg/cloud-init-metadata.yaml b/pkg/cloud-init-metadata.yaml new file mode 100644 index 00000000..5a38ba10 --- /dev/null +++ b/pkg/cloud-init-metadata.yaml @@ -0,0 +1,2 @@ +instance-id: h1 +local-hostname: h1 diff --git a/pkg/cloud-init-userdata.yaml b/pkg/cloud-init-userdata.yaml new file mode 100644 index 00000000..fdffea1c --- /dev/null +++ b/pkg/cloud-init-userdata.yaml @@ -0,0 +1,4 @@ +ssh_authorized_keys: + - ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBERI62l9pAbMxi6QYd3xnEMJhOY9NxcUOvgzNrJsDqSSRs5UgRjHCTDbw+7+yqr+ibcwDAcQgnzJEdRqsdhdTdc= +ssh_import_id: + - gh:stv0g diff --git a/pkg/options/vm/vm.go b/pkg/options/vm/vm.go new file mode 100644 index 00000000..5dee497e --- /dev/null +++ b/pkg/options/vm/vm.go @@ -0,0 +1,96 @@ +// SPDX-FileCopyrightText: 2023 Steffen Vogel +// SPDX-License-Identifier: Apache-2.0 + +package vm + +import ( + "fmt" + "strings" + + g "cunicu.li/gont/v2/pkg" + co "cunicu.li/gont/v2/pkg/options/cmd" +) + +type Architecture string + +func (a Architecture) ApplyQEmuVM(vm *g.QEmuVM) { + vm.Arch = string(a) +} + +func Option(name string, opts ...any) co.Arguments { + kvs := []string{} + + for _, opt := range opts { + switch opt := opt.(type) { + case map[string]any: + for key, value := range opt { + kvs = append(kvs, fmt.Sprintf("%s=%v", key, value)) + } + case map[string]string: + for key, value := range opt { + kvs = append(kvs, fmt.Sprintf("%s=%s", key, value)) + } + default: + kvs = append(kvs, fmt.Sprint(opt)) + } + } + + args := co.Arguments{"-" + name} + + if len(kvs) > 0 { + args = append(args, strings.Join(kvs, ",")) + } + + return args +} + +type CloudInitUserData map[string]any + +func (c CloudInitUserData) ApplyQEmuVM(vm *g.QEmuVM) { + vm.CloudInit.UserData = c +} + +type CloudInitMetaData map[string]any + +func (c CloudInitMetaData) ApplyQEmuVM(vm *g.QEmuVM) { + vm.CloudInit.MetaData = c +} + +// Shortcuts + +func Memory(megs int) co.Arguments { + return Option("m", map[string]any{"size": megs}) +} + +//nolint:gochecknoglobals +var NoGraphic = Option("nographic") + +func Machine(typ string, opts ...any) co.Arguments { + args := []any{typ} + args = append(args, opts...) + + return Option("machine", args...) +} + +func CPU(model string) co.Arguments { + return Option("cpu", model) +} + +func Device(driver string, props map[string]any) co.Arguments { + return Option("device", driver, props) +} + +func NetDev(typ string, props map[string]any) co.Arguments { + return Option("netdev", typ, props) +} + +func Drive(props map[string]any) co.Arguments { + return Option("drive", props) +} + +func VNC(display int, opts ...any) co.Arguments { + args := []any{fmt.Sprintf(":%d", display)} + args = append(args, opts...) + + return Option("vnc", args...) +} diff --git a/pkg/vm.go b/pkg/vm.go new file mode 100644 index 00000000..6fb704a5 --- /dev/null +++ b/pkg/vm.go @@ -0,0 +1,195 @@ +// SPDX-FileCopyrightText: 2023 Steffen Vogel +// SPDX-License-Identifier: Apache-2.0 + +package gont + +import ( + "errors" + "fmt" + "maps" + "os" + "path/filepath" + "slices" + + "gopkg.in/yaml.v3" +) + +var ErrMissingCloudInitData = errors.New("missing cloud-init user data") + +type QEmuVMOption interface { + ApplyQEmuVM(vm *QEmuVM) +} + +var _ Node = (*QEmuVM)(nil) + +type CloudInit struct { + MetaData map[string]any + UserData map[string]any + NetworkConfig map[string]any +} + +type QEmuVM struct { + *BaseNode + + options []any + command *Cmd + + // Options + Arch string + CloudInit CloudInit +} + +func (n *Network) AddQEmuVM(name string, opts ...Option) (*QEmuVM, error) { + baseNode, err := n.addBaseNode(name, opts) + if err != nil { + return nil, err + } + + vm := &QEmuVM{ + BaseNode: baseNode, + } + + n.Register(vm) + + // Apply VM options + for _, opt := range opts { + switch opt := opt.(type) { + case QEmuVMOption: + opt.ApplyQEmuVM(vm) + case ExecCmdOption, CmdOption: + vm.options = append(vm.options, opt) + } + } + + // Add links + if err := vm.configureLinks(); err != nil { + return nil, fmt.Errorf("failed to configure links: %w", err) + } + + return vm, nil +} + +func (vm *QEmuVM) Close() error { + if vm.command.Process != nil { + if err := vm.Stop(); err != nil { + return fmt.Errorf("failed to stop VM: %w", err) + } + } + + return nil +} + +func (vm *QEmuVM) configureLinks() error { + for range vm.ConfiguredInterfaces { + return errors.ErrUnsupported + } + + return nil +} + +func (vm *QEmuVM) ConfigureInterface(_ *Interface) error { + return errors.ErrUnsupported +} + +func (vm *QEmuVM) Teardown() error { + return errors.ErrUnsupported +} + +func (vm *QEmuVM) Start() (*Cmd, error) { + name := fmt.Sprintf("/nix/store/v3jjx86y2cs6x9182f3d1i3z1kbap876-qemu-8.2.3/bin/qemu-system-%s", vm.Arch) + + args := slices.Clone(vm.options) + + if vm.CloudInit.UserData != nil { + fn, err := vm.createCloudInitImage() + if err != nil { + return nil, fmt.Errorf("failed to create cloud-init seed image: %w", err) + } + + args = append(args, "-drive", fmt.Sprintf("if=virtio,format=raw,file=%s", fn)) + } + + vm.command = vm.network.HostNode.Command(name, args...) + vm.command.Stdin = os.Stdin + vm.command.Stderr = os.Stderr + vm.command.Stdout = os.Stdout + + if err := vm.command.Start(); err != nil { + return nil, err + } + + return vm.command, nil +} + +func (vm *QEmuVM) Stop() error { + // TODO: Perform orderly shutdown of VM + return vm.command.Process.Kill() +} + +func (vm *QEmuVM) createCloudInitImage() (string, error) { + if vm.CloudInit.UserData == nil { + return "", ErrMissingCloudInitData + } + + fnOut := filepath.Join(vm.BasePath, "cloud-init.img") + + args := []any{ + "--disk-format", "raw", + "--filesystem", "iso9660", + fnOut, + } + + out := []byte("#cloud-config\n") + outYAML, err := yaml.Marshal(vm.CloudInit.UserData) + if err != nil { + return "", fmt.Errorf("failed to marshal cloud-init metadata: %w", err) + } + + out = append(out, outYAML...) + + fnUser := filepath.Join(vm.BasePath, "cloud-init-userdata.yaml") + if err := os.WriteFile(fnUser, out, 0o600); err != nil { + return "", fmt.Errorf("failed to write file: %s: %w", fnUser, err) + } + + args = append(args, fnUser) + + if vm.CloudInit.MetaData != nil { + meta := maps.Clone(vm.CloudInit.MetaData) + meta["dsmode"] = "local" + + out, err := yaml.Marshal(meta) + if err != nil { + return "", fmt.Errorf("failed to marshal cloud-init metadata: %w", err) + } + + fnMeta := filepath.Join(vm.BasePath, "cloud-init-metadata.yaml") + if err := os.WriteFile(fnMeta, out, 0o600); err != nil { + return "", fmt.Errorf("failed to write file: %s: %w", fnMeta, err) + } + + args = append(args, fnMeta) + } + + if vm.CloudInit.NetworkConfig != nil { + out, err := yaml.Marshal(vm.CloudInit.NetworkConfig) + if err != nil { + return "", fmt.Errorf("failed to marshal cloud-init network config: %w", err) + } + + fnNetCfg := filepath.Join(vm.BasePath, "cloud-init-network.yaml") + if err := os.WriteFile(fnNetCfg, out, 0o600); err != nil { + return "", fmt.Errorf("failed to write file: %s: %w", fnNetCfg, err) + } + + // args = append(args, fnNetCfg) + } + + cmd := vm.network.HostNode.Command("/nix/store/05dsd12j15sg8qbf7jz6dg5kv7q32z1c-cloud-utils-0.32/bin/cloud-localds", args...) + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("failed to run cloud-localds: %w", err) + } + + return fnOut, nil +} diff --git a/pkg/vm_test.go b/pkg/vm_test.go new file mode 100644 index 00000000..dbb30cb5 --- /dev/null +++ b/pkg/vm_test.go @@ -0,0 +1,82 @@ +// SPDX-FileCopyrightText: 2023 Steffen Vogel +// SPDX-License-Identifier: Apache-2.0 + +package gont_test + +import ( + "testing" + + g "cunicu.li/gont/v2/pkg" + vmo "cunicu.li/gont/v2/pkg/options/vm" + "github.com/stretchr/testify/require" +) + +const imageURL = "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-generic-arm64.qcow2" //nolint:unused + +// TestPing performs and end-to-end ping test +// between two hosts on a switched topology +// +// h1 <-> sw1 <-> h2 +func TestQEmuVM(t *testing.T) { + t.Skip() + + n, err := g.NewNetwork(*nname, globalNetworkOptions...) + require.NoError(t, err, "Failed to create network") + defer n.Close() + + sw1, err := n.AddSwitch("sw1") + require.NoError(t, err, "Failed to add switch") + + vmOpts := []g.Option{ + vmo.Architecture("x86_64"), + vmo.Machine("q35"), + // vmo.CPU("host"), + vmo.Memory(2 * 1024), + + vmo.NoGraphic, + vmo.VNC(0), + + vmo.Device("virtio-blk-pci", map[string]any{"drive": "disk"}), + + vmo.Device("virtio-net-pci", map[string]any{"netdev": "net0"}), + vmo.NetDev("tap", map[string]any{"id": "net0", "script": "no", "downscript": "no"}), + vmo.Drive(map[string]any{"if": "none", "id": "disk", "format": "qcow2", "file": "/home/stv0g/workspace/cunicu/gont/image.qcow2"}), + + g.NewInterface("veth0", sw1), + + vmo.CloudInitMetaData{ + "instance-id": "h1", + "local-hostname": "h1", + }, + + vmo.CloudInitUserData{ + "ssh_pwauth": true, + "users": []any{ + "default", + map[string]any{ + "name": "stv0g", + "ssh_authorized_keys": []string{ + "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBERI62l9pAbMxi6QYd3xnEMJhOY9NxcUOvgzNrJsDqSSRs5UgRjHCTDbw+7+yqr+ibcwDAcQgnzJEdRqsdhdTdc=", + }, + "ssh_import_id": []string{ + "gh:stv0g", + }, + "groups": []string{"users", "admin", "wheel"}, + "plain_text_passwd": "testtest", + }, + }, + }, + } + + h1, err := n.AddQEmuVM("h1", vmOpts...) + require.NoError(t, err, "Failed to add VM") + + // h2, err := n.AddQEmuVM("h2", vmOpts...) + // require.NoError(t, err, "Failed to add VM") + + cmd, err := h1.Start() + require.NoError(t, err) + + err = cmd.Wait() + require.NoError(t, err) +}