-
Notifications
You must be signed in to change notification settings - Fork 6
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
5 changed files
with
379 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
instance-id: h1 | ||
local-hostname: h1 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
ssh_authorized_keys: | ||
- ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBERI62l9pAbMxi6QYd3xnEMJhOY9NxcUOvgzNrJsDqSSRs5UgRjHCTDbw+7+yqr+ibcwDAcQgnzJEdRqsdhdTdc= | ||
ssh_import_id: | ||
- gh:stv0g |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,96 @@ | ||
// SPDX-FileCopyrightText: 2023 Steffen Vogel <[email protected]> | ||
// 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...) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,195 @@ | ||
// SPDX-FileCopyrightText: 2023 Steffen Vogel <[email protected]> | ||
// 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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,82 @@ | ||
// SPDX-FileCopyrightText: 2023 Steffen Vogel <[email protected]> | ||
// 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) | ||
} |