From f9454068d03c5cc662ee23a2f5ba61a46d1a5365 Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Sat, 10 Oct 2026 00:31:45 -0400 Subject: [PATCH 1/2] fix(confyg): pass a rune to the %#q verb in error messages go vet rejects %#q with an int argument, and that failure stopped the commit hook for all other changes. Signed-off-by: Xe Iaso --- internal/confyg/read.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/confyg/read.go b/internal/confyg/read.go index c581b514..cb88d153 100644 --- a/internal/confyg/read.go +++ b/internal/confyg/read.go @@ -312,7 +312,7 @@ func (in *input) lex(sym *symType) int { in.readRune() c = in.peekRune() if c != '#' { - in.Error(fmt.Sprintf("unexpected input character %#q", c)) + in.Error(fmt.Sprintf("unexpected input character %#q", rune(c))) } // Consume comment. @@ -397,7 +397,7 @@ func (in *input) lex(sym *symType) int { // Checked all punctuation. Must be identifier token. if c := in.peekRune(); !isIdent(c) { - in.Error(fmt.Sprintf("unexpected input character %#q", c)) + in.Error(fmt.Sprintf("unexpected input character %#q", rune(c))) } // Scan over identifier. From 8c37260c076378351ff32cca8b5dd2a1efeee426 Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Sat, 10 Oct 2026 00:32:12 -0400 Subject: [PATCH 2/2] feat(vmbully): add script, clone, template and passwd commands vmbully builds Proxmox VM templates from cloud images, clones them, and runs JavaScript against them. The script command uses a pool of VMs tagged vmbully. Each run taints one VM, runs the script over SSH, deletes that VM, and clones the next one. The script can call $ for commands in the VM, host$ for commands on this machine, and scp to copy files into the VM. Failed commands and script errors exit nonzero. The top-level yeetfile builds vmbully packages for all four architectures. Signed-off-by: Xe Iaso --- cmd/vmbully/README.md | 192 +++++++++++++++ cmd/vmbully/clone.go | 239 +++++++++++++++++++ cmd/vmbully/main.go | 68 ++++++ cmd/vmbully/passwd.go | 88 +++++++ cmd/vmbully/qemu_guest_agent.yaml | 11 + cmd/vmbully/script.go | 380 ++++++++++++++++++++++++++++++ cmd/vmbully/script_test.go | 146 ++++++++++++ cmd/vmbully/ssh.go | 161 +++++++++++++ cmd/vmbully/template.go | 192 +++++++++++++++ cmd/vmbully/test_httpdebug.js | 4 + go.mod | 38 +-- go.sum | 70 +++++- yeetfile.js | 19 ++ 13 files changed, 1589 insertions(+), 19 deletions(-) create mode 100644 cmd/vmbully/README.md create mode 100644 cmd/vmbully/clone.go create mode 100644 cmd/vmbully/main.go create mode 100644 cmd/vmbully/passwd.go create mode 100644 cmd/vmbully/qemu_guest_agent.yaml create mode 100644 cmd/vmbully/script.go create mode 100644 cmd/vmbully/script_test.go create mode 100644 cmd/vmbully/ssh.go create mode 100644 cmd/vmbully/template.go create mode 100644 cmd/vmbully/test_httpdebug.js diff --git a/cmd/vmbully/README.md b/cmd/vmbully/README.md new file mode 100644 index 00000000..ba9183ef --- /dev/null +++ b/cmd/vmbully/README.md @@ -0,0 +1,192 @@ +# vmbully + +`vmbully` runs JavaScript files against Proxmox VMs that are used one time, then deleted. + +Each `vmbully script` run uses one VM from a pool. The pool is all VMs with the tag `vmbully`. After the script ends, `vmbully` deletes the VM and clones a new one for the next run. + +## Requirements + +- A Proxmox API token that can create, change, and delete VMs. +- A network route from your machine to the VM network. `vmbully` connects to port 22 of the VM. +- A VM template that has the QEMU guest agent and permits SSH password logins. `qemu_guest_agent.yaml` in this directory is a cloud-init vendor snippet that does both. + +## Configuration + +Set the connection flags before the subcommand name, or set them as environment variables. + +| Flag | Environment variable | Default | +| -------------------- | -------------------- | -------- | +| `--proxmox-host` | `PROXMOX_HOST` | none | +| `--proxmox-token-id` | `PROXMOX_TOKEN_ID` | none | +| `--proxmox-secret` | `PROXMOX_SECRET` | none | +| `--proxmox-node` | `PROXMOX_NODE` | `shachi` | + +`PROXMOX_HOST` is the API URL, for example `https://shachi:8006/api2/json`. + +Each subcommand flag also has an environment variable. Change the flag name to upper case and replace each `-` with `_`. + +## Set up the pool + +Do these steps one time. + +1. Copy the vendor snippet to the Proxmox node. + + ```sh + scp qemu_guest_agent.yaml root@shachi:/var/lib/vz/snippets/ + ``` + + The `local` storage must have the `snippets` and `import` content types. + +2. Create the template. + + ```sh + vmbully template --id=9010 --ssh-keys-file=$HOME/.ssh/id_ed25519.pub \ + --ci-vendor=local:snippets/qemu_guest_agent.yaml + ``` + +3. Optional: put the first VM in the pool. Without this step, the first `vmbully script` run clones its own VM. + + ```sh + vmbully clone --template-id=9010 + ``` + + The command prints the IP address and the password of the VM when the VM is ready. + +## Run a script + +```sh +vmbully script ./setup.js +``` + +`vmbully script` does these steps: + +1. It finds a VM that is in operation, has the tag `vmbully`, and does not have the tag `tainted`. If there is none, it clones a new VM from the template with the tags `vmbully` and `tainted`. +2. It waits for the guest agent, then sets a random password for the user. +3. It connects to the VM with SSH. +4. It adds the tag `tainted` to the VM. +5. It runs the script. +6. It stops the VM and deletes it with its disks. +7. It clones a new VM from the template, tags it `vmbully`, and starts it. + +Steps 6 and 7 also run when the script fails and when you press Ctrl-C. + +If a step before step 4 fails, a VM from the pool stays in the pool. A VM that step 1 cloned is deleted, and step 7 does not run. + +### Flags + +| Flag | Default | Function | +| ---------------- | --------- | -------------------------------------------- | +| `--tag` | `vmbully` | The tag of the pool. | +| `--user` | empty | The SSH user. Empty is the cloud-init user. | +| `--template-id` | `9010` | The template that the next VM is a clone of. | +| `--disk-storage` | `ssd` | The storage for the disks of the next VM. | +| `--disk-size` | `32G` | The size of the `scsi0` disk of the next VM. | + +Put the flags before the script path. + +### Exit status + +| Status | Cause | +| ------ | ---------------------------------------------------------------------- | +| `0` | The script ended without an exception, and the cleanup was successful. | +| `1` | The script threw an exception, or a Proxmox or SSH step failed. | +| `2` | The arguments are incorrect. | + +## Script functions + +The script is one JavaScript file. `vmbully` evaluates it with QuickJS as a global script, not as a module. All functions are synchronous. + +### `` $`command` `` + +Runs `command` in the VM with the login shell of the user. + +- The return value is the stdout of the command as a string. The string includes the last newline. Use `.trim()` to remove it. +- The stderr of the command goes to your terminal. +- A nonzero exit status throws an exception. +- Each `${value}` becomes one quoted shell word. An array becomes one word for each element. + +```js +const kernel = $`uname -r`.trim(); +const files = ["/etc/hostname", "/etc/os-release"]; +console.log($`cat ${files}`); +``` + +Because `${value}` is always quoted, write shell syntax such as `|` and `>` in the fixed text of the command. + +### `` host$`command` `` + +Runs `command` with `sh` on your machine, not in the VM. + +- The return value, the stderr, the exception, and the `${value}` quoting are the same as for `$`. +- The command starts in the directory where you started `vmbully`. + +```js +const commit = host$`git rev-parse --short HEAD`.trim(); +host$`make build`; +scp("./bin/app", "/tmp/"); +$`/tmp/app --version`; +console.log(commit); +``` + +### `scp(source, dest)` + +Copies the local file `source` to `dest` in the VM. + +- `source` is a path on your machine. It must be a regular file. +- `dest` is a file path or a directory in the VM. +- The copy keeps the permission bits of the file. +- An error throws an exception. + +```js +scp("./install.sh", "/tmp/"); +$`sh /tmp/install.sh`; +``` + +### `console.log(...)` and `print(...)` + +Write text to your terminal. + +### Errors + +An exception that the script does not catch stops the script. `vmbully` then logs the error and exits with status `1`. + +Use `try` and `catch` when a command failure is permitted. + +```js +try { + $`systemctl is-active nginx`; +} catch (e) { + console.log("nginx is not in operation"); +} +``` + +## Example + +```js +console.log($`uname -a`); + +$`sudo apt-get install -y nginx`; +scp("./index.html", "/tmp/"); +$`sudo mv /tmp/index.html /var/www/html/index.html`; + +const page = $`curl -fsS http://localhost/`; +if (!page.includes("hello")) { + throw new Error("the page does not have the expected text"); +} +``` + +## Other commands + +| Command | Function | +| ------------------------ | ---------------------------------------------------- | +| `vmbully template` | Creates a VM template from a cloud image. | +| `vmbully clone` | Clones a template into a new VM in the pool. | +| `vmbully passwd ` | Sets a new password in a VM and prints the password. | +| `vmbully help ` | Shows the flags of a command. | + +## Limits + +- Two `vmbully script` runs at the same time can use the same VM. There is no lock. +- `vmbully` does not check the SSH host key of the VM. Use it on a trusted network only. +- With no free VM in the pool, `vmbully script` clones one first. That run is slower, because the VM must complete its first boot. +- If the cleanup fails, a VM with the tag `tainted` can stay on the node. Delete it manually. diff --git a/cmd/vmbully/clone.go b/cmd/vmbully/clone.go new file mode 100644 index 00000000..485e9173 --- /dev/null +++ b/cmd/vmbully/clone.go @@ -0,0 +1,239 @@ +package main + +import ( + "context" + "crypto/rand" + "flag" + "fmt" + "log/slog" + "time" + + "github.com/cenkalti/backoff/v4" + "github.com/google/subcommands" + "github.com/luthermonson/go-proxmox" + "within.website/x/internal/flagenv" + "within.website/x/misc/namegen" +) + +type cloneCmd struct { + templateID int + diskStorage string + diskSize string + tag string + sshUser string +} + +func (*cloneCmd) Name() string { return "clone" } +func (*cloneCmd) Synopsis() string { return "Clone the template into a new VM and start it." } +func (*cloneCmd) Usage() string { + return `clone [--template-id] [--disk-storage] [--disk-size] [--tag] [--ssh-user]: +Clone the template into a new VM, resize its disk, start it, and print its IPv4 addresses. +Then log in with SSH and a password made for this VM, and print the output of "uname -av". +` +} +func (c *cloneCmd) SetFlags(f *flag.FlagSet) { + f.IntVar(&c.templateID, "template-id", 9000, "VMID of the template to clone") + f.StringVar(&c.diskStorage, "disk-storage", "ssd", "storage for the disks of the new VM") + f.StringVar(&c.diskSize, "disk-size", "32G", "size of the scsi0 disk of the new VM") + f.StringVar(&c.tag, "tag", "vmbully", "tag to add to the new VM, empty for no tag") + f.StringVar(&c.sshUser, "ssh-user", "", "user for the SSH login, empty for the cloud-init user of the template") +} + +func (c *cloneCmd) Execute(ctx context.Context, f *flag.FlagSet, _ ...any) subcommands.ExitStatus { + if err := flagenv.ParseSet(flagenv.Prefix, f); err != nil { + slog.Error("can't read flags from the environment", "err", err) + return subcommands.ExitUsageError + } + + if err := c.run(ctx); err != nil { + slog.Error("can't clone template", "err", err) + return subcommands.ExitFailure + } + + return subcommands.ExitSuccess +} + +func (c *cloneCmd) run(ctx context.Context) error { + _, n, err := node(ctx) + if err != nil { + return err + } + + var tags []string + if c.tag != "" { + tags = append(tags, c.tag) + } + + password := rand.Text() + newVM, err := cloneVM(ctx, n, cloneOptions{ + templateID: c.templateID, + diskStorage: c.diskStorage, + diskSize: c.diskSize, + tags: tags, + password: password, + }) + if err != nil { + return err + } + newID := int(newVM.VMID) + + sshUser := c.sshUser + if sshUser == "" && newVM.VirtualMachineConfig != nil { + sshUser = newVM.VirtualMachineConfig.CIUser + } + if sshUser == "" { + return fmt.Errorf("template %d has no cloud-init user, set --ssh-user", c.templateID) + } + + ifaces, err := waitForAgent(ctx, newVM) + if err != nil { + return err + } + + var sshIP string + for _, iface := range ifaces { + for _, addr := range iface.IPAddresses { + if !isUsableIPv4(addr.IPAddress) { + continue + } + + fmt.Printf("%s: %s\n", iface.Name, addr.IPAddress) + if sshIP == "" { + sshIP = addr.IPAddress + } + } + } + + if sshIP == "" { + return fmt.Errorf("VM %d has no usable IPv4 address", newID) + } + fmt.Printf("login: %s@%s password: %s\n", sshUser, sshIP, password) + + sshClient, err := sshDial(ctx, sshIP, sshUser, password) + if err != nil { + return fmt.Errorf("ssh to %s@%s: %w", sshUser, sshIP, err) + } + defer sshClient.Close() + + out, err := sshRun(sshClient, "uname -av") + if err != nil { + return err + } + fmt.Print(out) + + return nil +} + +type cloneOptions struct { + templateID int + diskStorage string + diskSize string + tags []string + password string // cloud-init password of the new VM +} + +// cloneVM clones the template into a new VM on node n, then tags, resizes and +// starts the new VM. It does not wait for the guest agent. +func cloneVM(ctx context.Context, n *proxmox.Node, opts cloneOptions) (*proxmox.VirtualMachine, error) { + template, err := n.VirtualMachine(ctx, opts.templateID) + if err != nil { + return nil, fmt.Errorf("get template %d: %w", opts.templateID, err) + } + + newID, task, err := template.Clone(ctx, &proxmox.VirtualMachineCloneOptions{ + Name: namegen.Next(), + Full: proxmox.IntOrBool(true), + Storage: opts.diskStorage, + }) + if err != nil { + return nil, fmt.Errorf("clone template %d: %w", opts.templateID, err) + } + slog.Info("new vm created", "id", newID) + + if err := waitTask(ctx, task, time.Second, 5*time.Minute); err != nil { + return nil, err + } + + newVM, err := n.VirtualMachine(ctx, newID) + if err != nil { + return nil, err + } + + for _, tag := range opts.tags { + if err := addTag(ctx, newVM, tag); err != nil { + return nil, err + } + } + + // Each VM gets its own password, set before the first boot so that + // cloud-init applies it. + if err := newVM.ConfigSync(ctx, proxmox.VirtualMachineOption{Name: "cipassword", Value: opts.password}); err != nil { + return nil, fmt.Errorf("set VM password: %w", err) + } + + task, err = newVM.ResizeDisk(ctx, "scsi0", opts.diskSize) + if err != nil { + return nil, fmt.Errorf("resize disk: %w", err) + } + if err := waitTask(ctx, task, time.Second, 5*time.Minute); err != nil { + return nil, err + } + slog.Info("VM disk resized", "id", newID) + + task, err = newVM.Start(ctx) + if err != nil { + return nil, fmt.Errorf("start VM: %w", err) + } + if err := waitTask(ctx, task, time.Second, 5*time.Minute); err != nil { + return nil, err + } + slog.Info("VM started", "id", newID) + + return newVM, nil +} + +// addTag adds tag to vm and keeps the tags that vm already has. +func addTag(ctx context.Context, vm *proxmox.VirtualMachine, tag string) error { + if vm.VirtualMachineConfig == nil { + vm.VirtualMachineConfig = &proxmox.VirtualMachineConfig{} + } + + tags := tag + if cfg := vm.VirtualMachineConfig; cfg.Tags != "" { + tags = cfg.Tags + if !vm.HasTag(tag) { + tags += proxmox.TagSeperator + tag + } + } + + if err := vm.ConfigSync(ctx, proxmox.VirtualMachineOption{Name: "tags", Value: tags}); err != nil { + return fmt.Errorf("tag VM %d as %s: %w", vm.VMID, tag, err) + } + vm.VirtualMachineConfig.Tags = tags + vm.VirtualMachineConfig.TagsSlice = nil + slog.Info("VM tagged", "id", vm.VMID, "tags", tags) + + return nil +} + +// waitForAgent waits with exponential backoff until the guest agent of vm +// answers, and returns the network interfaces of vm. +func waitForAgent(ctx context.Context, vm *proxmox.VirtualMachine) ([]*proxmox.AgentNetworkIface, error) { + // The guest agent is not ready until the VM boots and cloud-init + // installs it, so every error counts as "not ready yet". + bo := backoff.NewExponentialBackOff() + bo.InitialInterval = 2 * time.Second + bo.MaxInterval = 30 * time.Second + bo.MaxElapsedTime = 5 * time.Minute + + ifaces, err := backoff.RetryNotifyWithData(func() ([]*proxmox.AgentNetworkIface, error) { + return vm.AgentGetNetworkIFaces(ctx) + }, backoff.WithContext(bo, ctx), func(err error, next time.Duration) { + slog.Info("guest agent not ready", "id", vm.VMID, "err", err, "retry_in", next.String()) + }) + if err != nil { + return nil, fmt.Errorf("get VM network interfaces: %w", err) + } + + return ifaces, nil +} diff --git a/cmd/vmbully/main.go b/cmd/vmbully/main.go new file mode 100644 index 00000000..c4452417 --- /dev/null +++ b/cmd/vmbully/main.go @@ -0,0 +1,68 @@ +package main + +import ( + "context" + "flag" + "fmt" + "os" + "os/signal" + "time" + + "github.com/google/subcommands" + "github.com/luthermonson/go-proxmox" + "within.website/x/internal" +) + +var ( + proxmoxHost = flag.String("proxmox-host", "", "proxmox host") + proxmoxTokenID = flag.String("proxmox-token-id", "", "proxmox token ID") + proxmoxSecret = flag.String("proxmox-secret", "", "proxmox API secret") + proxmoxNode = flag.String("proxmox-node", "shachi", "proxmox node to create VMs on") +) + +func main() { + subcommands.Register(subcommands.HelpCommand(), "") + subcommands.Register(subcommands.FlagsCommand(), "") + subcommands.Register(subcommands.CommandsCommand(), "") + + subcommands.Register(&cloneCmd{}, "VM manipulation") + subcommands.Register(&passwdCmd{}, "VM manipulation") + subcommands.Register(&scriptCmd{}, "VM manipulation") + subcommands.Register(&templateCmd{}, "VM manipulation") + + internal.HandleStartup() + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + defer cancel() + + os.Exit(int(subcommands.Execute(ctx))) +} + +// node connects to the Proxmox API and returns the client and the target node. +func node(ctx context.Context) (*proxmox.Client, *proxmox.Node, error) { + client := proxmox.NewClient(*proxmoxHost, + proxmox.WithAPIToken(*proxmoxTokenID, *proxmoxSecret), + proxmox.WithInsecureSkipVerify(), // lab only + proxmox.WithTimeout(30*time.Second), // http.DefaultClient has no timeout + ) + + n, err := client.Node(ctx, *proxmoxNode) + if err != nil { + return nil, nil, fmt.Errorf("get node %s: %w", *proxmoxNode, err) + } + + return client, n, nil +} + +// waitTask waits for task and returns an error when the task fails. +// task.Wait alone returns nil for a task that ends with a failure. +func waitTask(ctx context.Context, task *proxmox.Task, interval, max time.Duration) error { + if err := task.Wait(ctx, interval, max); err != nil { + return err + } + + if task.IsFailed { + return fmt.Errorf("task %s failed: %s", task.UPID, task.ExitStatus) + } + + return nil +} diff --git a/cmd/vmbully/passwd.go b/cmd/vmbully/passwd.go new file mode 100644 index 00000000..77442c23 --- /dev/null +++ b/cmd/vmbully/passwd.go @@ -0,0 +1,88 @@ +package main + +import ( + "context" + "crypto/rand" + "flag" + "fmt" + "log/slog" + "strconv" + + "github.com/google/subcommands" + "within.website/x/internal/flagenv" +) + +type passwdCmd struct { + user string + password string +} + +func (*passwdCmd) Name() string { return "passwd" } +func (*passwdCmd) Synopsis() string { return "Set a new password for a user in a running VM." } +func (*passwdCmd) Usage() string { + return `passwd [--user] [--password] : +Set a new password for a user in a running VM through the QEMU guest agent, and print the password. +` +} +func (p *passwdCmd) SetFlags(f *flag.FlagSet) { + f.StringVar(&p.user, "user", "", "user to set the password for, empty for the cloud-init user of the VM") + f.StringVar(&p.password, "password", "", "new password, empty for a random password") +} + +func (p *passwdCmd) Execute(ctx context.Context, f *flag.FlagSet, _ ...any) subcommands.ExitStatus { + if err := flagenv.ParseSet(flagenv.Prefix, f); err != nil { + slog.Error("can't read flags from the environment", "err", err) + return subcommands.ExitUsageError + } + + if f.NArg() != 1 { + fmt.Print(p.Usage()) + return subcommands.ExitUsageError + } + + vmid, err := strconv.Atoi(f.Arg(0)) + if err != nil { + slog.Error("VMID is not a number", "vmid", f.Arg(0)) + return subcommands.ExitUsageError + } + + if err := p.run(ctx, vmid); err != nil { + slog.Error("can't set password", "err", err) + return subcommands.ExitFailure + } + + return subcommands.ExitSuccess +} + +func (p *passwdCmd) run(ctx context.Context, vmid int) error { + _, n, err := node(ctx) + if err != nil { + return err + } + + vm, err := n.VirtualMachine(ctx, vmid) + if err != nil { + return fmt.Errorf("get VM %d: %w", vmid, err) + } + + user := p.user + if user == "" && vm.VirtualMachineConfig != nil { + user = vm.VirtualMachineConfig.CIUser + } + if user == "" { + return fmt.Errorf("VM %d has no cloud-init user, set --user", vmid) + } + + password := p.password + if password == "" { + password = rand.Text() + } + + if err := vm.AgentSetUserPassword(ctx, password, user); err != nil { + return fmt.Errorf("set password for %s on VM %d: %w", user, vmid, err) + } + + fmt.Printf("login: %s password: %s\n", user, password) + + return nil +} diff --git a/cmd/vmbully/qemu_guest_agent.yaml b/cmd/vmbully/qemu_guest_agent.yaml new file mode 100644 index 00000000..3fd14362 --- /dev/null +++ b/cmd/vmbully/qemu_guest_agent.yaml @@ -0,0 +1,11 @@ +#cloud-config +# Cloud-init vendor snippet for Proxmox VMs. +# Copy to /var/lib/vz/snippets/ on the Proxmox node and refer to it with +# cicustom "vendor=local:snippets/qemu_guest_agent.yaml". +# Ubuntu cloud images turn off SSH password logins. vmbully clone needs them. +ssh_pwauth: true +package_update: true +packages: + - qemu-guest-agent +runcmd: + - systemctl enable --now qemu-guest-agent diff --git a/cmd/vmbully/script.go b/cmd/vmbully/script.go new file mode 100644 index 00000000..17ae4acf --- /dev/null +++ b/cmd/vmbully/script.go @@ -0,0 +1,380 @@ +package main + +import ( + "bytes" + "context" + "crypto/rand" + "errors" + "flag" + "fmt" + "io" + "log/slog" + "os" + "os/exec" + "slices" + "strings" + "time" + + "github.com/google/subcommands" + "github.com/luthermonson/go-proxmox" + "modernc.org/quickjs" + "within.website/x/internal/flagenv" +) + +// scriptPrelude defines $ and host$ on top of the __vmbully_run and +// __vmbully_host_run host functions. Each interpolated value becomes one shell +// word. An array becomes one word for each element. +const scriptPrelude = `{ + const quote = (v) => Array.isArray(v) + ? v.map(quote).join(" ") + : "'" + String(v).replace(/'/g, "'\\''") + "'"; + const tag = (run) => (strings, ...values) => { + let cmd = strings[0]; + values.forEach((v, i) => { cmd += quote(v) + strings[i + 1]; }); + return run(cmd); + }; + globalThis.$ = tag(__vmbully_run); + globalThis.host$ = tag(__vmbully_host_run); +} +` + +// taintedTag marks a VM that a script ran on. Such a VM is never used again. +const taintedTag = "tainted" + +type scriptCmd struct { + user string + tag string + templateID int + diskStorage string + diskSize string +} + +func (*scriptCmd) Name() string { return "script" } +func (*scriptCmd) Synopsis() string { return "Run a JavaScript file against a fresh VM." } +func (*scriptCmd) Usage() string { + return "script [--user] [--tag] [--template-id] [--disk-storage] [--disk-size] :\n" + + "Find a running VM that has the tag and is not tagged \"" + taintedTag + "\".\n" + + "If there is none, clone a new VM from the template.\n" + + "Set a random password for the user in the VM, log in with SSH, and run the script.\n" + + "Then delete the VM and clone a new VM from the template for the next run.\n" + + "The script has these functions:\n" + + " $`command` run command in the VM and return its stdout, throw on a nonzero exit status\n" + + " host$`command` the same as $, but run command on this machine\n" + + " scp(source, dest) copy the local file source to dest in the VM\n" +} +func (s *scriptCmd) SetFlags(f *flag.FlagSet) { + f.StringVar(&s.user, "user", "", "user for the SSH login, empty for the cloud-init user of the VM") + f.StringVar(&s.tag, "tag", "vmbully", "tag of the VMs that scripts can use") + f.IntVar(&s.templateID, "template-id", 9010, "VMID of the template to clone the next VM from") + f.StringVar(&s.diskStorage, "disk-storage", "ssd", "storage for the disks of the next VM") + f.StringVar(&s.diskSize, "disk-size", "32G", "size of the scsi0 disk of the next VM") +} + +func (s *scriptCmd) Execute(ctx context.Context, f *flag.FlagSet, _ ...any) subcommands.ExitStatus { + if err := flagenv.ParseSet(flagenv.Prefix, f); err != nil { + slog.Error("can't read flags from the environment", "err", err) + return subcommands.ExitUsageError + } + + if f.NArg() != 1 || s.tag == "" { + fmt.Print(s.Usage()) + return subcommands.ExitUsageError + } + + if err := s.run(ctx, f.Arg(0)); err != nil { + var jsErr *quickjs.Error + if errors.As(err, &jsErr) && jsErr.Stack != "" { + slog.Error("script failed", "err", err, "stack", jsErr.Stack) + } else { + slog.Error("script failed", "err", err) + } + return subcommands.ExitFailure + } + + return subcommands.ExitSuccess +} + +func (s *scriptCmd) run(ctx context.Context, scriptPath string) error { + src, err := os.ReadFile(scriptPath) + if err != nil { + return fmt.Errorf("read script: %w", err) + } + + client, n, err := node(ctx) + if err != nil { + return err + } + + // created is true when this run made the VM. Such a VM is tainted from + // the start, so that no other run takes it. + created := false + vm, err := findFreeVM(ctx, client, s.tag) + if errors.Is(err, errNoFreeVM) { + slog.Info("no free VM, cloning a new one", "template", s.templateID) + created = true + vm, err = cloneVM(ctx, n, cloneOptions{ + templateID: s.templateID, + diskStorage: s.diskStorage, + diskSize: s.diskSize, + tags: []string{s.tag, taintedTag}, + password: rand.Text(), // evalOn sets its own password + }) + } + if err != nil { + return err + } + slog.Info("using VM", "id", vm.VMID, "name", vm.Name) + + tainted, err := s.evalOn(ctx, vm, string(src)) + if !tainted && !created { + // The VM is not used, so it stays in the pool. + return err + } + + // Clean up even after an interrupt, so that no tainted VM stays behind. + cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 15*time.Minute) + defer cancel() + + if !tainted { + // The script did not start. Delete the VM that this run made, but + // do not put a VM in the pool for a setup that does not work. + return errors.Join(err, deleteVM(cleanupCtx, vm)) + } + + return errors.Join(err, deleteVM(cleanupCtx, vm), s.cloneNext(cleanupCtx, n)) +} + +// evalOn logs in to vm and evaluates the script src. tainted reports if vm got +// the tainted tag, which happens immediately before the script starts. +func (s *scriptCmd) evalOn(ctx context.Context, vm *proxmox.VirtualMachine, src string) (tainted bool, err error) { + user := s.user + if user == "" && vm.VirtualMachineConfig != nil { + user = vm.VirtualMachineConfig.CIUser + } + if user == "" { + return false, fmt.Errorf("VM %d has no cloud-init user, set --user", vm.VMID) + } + + // A new VM can still be in its first boot. + ifaces, err := waitForAgent(ctx, vm) + if err != nil { + return false, err + } + + var ip string + for _, iface := range ifaces { + for _, addr := range iface.IPAddresses { + if ip == "" && isUsableIPv4(addr.IPAddress) { + ip = addr.IPAddress + } + } + } + if ip == "" { + return false, fmt.Errorf("VM %d has no usable IPv4 address", vm.VMID) + } + + password := rand.Text() + if err := vm.AgentSetUserPassword(ctx, password, user); err != nil { + return false, fmt.Errorf("set password for %s on VM %d: %w", user, vm.VMID, err) + } + + sshClient, err := sshDial(ctx, ip, user, password) + if err != nil { + return false, fmt.Errorf("ssh to %s@%s: %w", user, ip, err) + } + defer sshClient.Close() + slog.Info("connected to VM", "id", vm.VMID, "user", user, "ip", ip) + + js, err := newScriptVM( + func(cmd string) (string, error) { return sshRun(sshClient, cmd) }, + func(cmd string) (string, error) { return hostRun(ctx, cmd) }, + func(source, dest string) error { return sshCopy(sshClient, source, dest) }, + ) + if err != nil { + return false, err + } + defer js.Close() + + // From here the VM is used. It gets no second script, even when this + // script fails. + if err := addTag(ctx, vm, taintedTag); err != nil { + return false, err + } + + _, err = js.Eval(src, quickjs.EvalGlobal) + return true, err +} + +// cloneNext clones and starts the VM for the next script run. +func (s *scriptCmd) cloneNext(ctx context.Context, n *proxmox.Node) error { + next, err := cloneVM(ctx, n, cloneOptions{ + templateID: s.templateID, + diskStorage: s.diskStorage, + diskSize: s.diskSize, + tags: []string{s.tag}, + password: rand.Text(), // the next run sets its own password + }) + if err != nil { + return fmt.Errorf("clone next VM: %w", err) + } + slog.Info("next VM ready", "id", next.VMID, "name", next.Name) + + return nil +} + +// errNoFreeVM means that the pool has no VM that a script can use. +var errNoFreeVM = errors.New("no running VM has the pool tag without the tainted tag") + +// findFreeVM returns the running VM with the lowest VMID that has the tag and +// is not tainted. It returns errNoFreeVM when there is none. +func findFreeVM(ctx context.Context, client *proxmox.Client, tag string) (*proxmox.VirtualMachine, error) { + cluster, err := client.Cluster(ctx) + if err != nil { + return nil, fmt.Errorf("get cluster: %w", err) + } + resources, err := cluster.Resources(ctx, "vm") + if err != nil { + return nil, fmt.Errorf("list cluster VMs: %w", err) + } + + var found *proxmox.ClusterResource + for _, r := range resources { + if r.Type != "qemu" || r.Template != 0 || r.Status != proxmox.StatusVirtualMachineRunning { + continue + } + tags := splitTags(r.Tags) + if !slices.Contains(tags, tag) || slices.Contains(tags, taintedTag) { + continue + } + if found == nil || r.VMID < found.VMID { + found = r + } + } + if found == nil { + return nil, errNoFreeVM + } + + n, err := client.Node(ctx, found.Node) + if err != nil { + return nil, fmt.Errorf("get node %s: %w", found.Node, err) + } + + vm, err := n.VirtualMachine(ctx, int(found.VMID)) + if err != nil { + return nil, fmt.Errorf("get VM %d: %w", found.VMID, err) + } + + return vm, nil +} + +// splitTags splits the tag list that the cluster resource API returns. +func splitTags(tags string) []string { + return strings.FieldsFunc(tags, func(r rune) bool { return r == ';' || r == ',' || r == ' ' }) +} + +// deleteVM stops vm and deletes it with its disks. It refuses a VM that is not +// tainted. +func deleteVM(ctx context.Context, vm *proxmox.VirtualMachine) error { + if !vm.HasTag(taintedTag) { + return fmt.Errorf("VM %d is not tagged %q, refusing to delete it", vm.VMID, taintedTag) + } + + task, err := vm.Stop(ctx) + if err != nil { + return fmt.Errorf("stop VM %d: %w", vm.VMID, err) + } + if err := waitTask(ctx, task, time.Second, 5*time.Minute); err != nil { + return fmt.Errorf("stop VM %d: %w", vm.VMID, err) + } + + task, err = vm.Delete(ctx, &proxmox.VirtualMachineDeleteOptions{ + Purge: proxmox.IntOrBool(true), + DestroyUnreferencedDisks: proxmox.IntOrBool(true), + }) + if err != nil { + return fmt.Errorf("delete VM %d: %w", vm.VMID, err) + } + if err := waitTask(ctx, task, time.Second, 5*time.Minute); err != nil { + return fmt.Errorf("delete VM %d: %w", vm.VMID, err) + } + slog.Info("VM deleted", "id", vm.VMID) + + return nil +} + +// hostRun runs cmd with sh on this machine and returns its stdout. The stderr +// of cmd goes to the local stderr. A nonzero exit status is an error. +func hostRun(ctx context.Context, cmd string) (string, error) { + c := exec.CommandContext(ctx, "sh", "-c", cmd) + var stdout, stderr bytes.Buffer + c.Stdout = &stdout + c.Stderr = io.MultiWriter(os.Stderr, &stderr) + + if err := c.Run(); err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return stdout.String(), fmt.Errorf("host command %q: exit status %d: %s", cmd, exitErr.ExitCode(), strings.TrimSpace(stderr.String())) + } + return stdout.String(), fmt.Errorf("host command %q: %w", cmd, err) + } + + return stdout.String(), nil +} + +// newScriptVM returns a JavaScript VM with $, host$, scp, print and console +// defined. run, hostRun and copy do the work of $, host$ and scp. An error from +// one of them becomes a JavaScript exception. +func newScriptVM(run, hostRun func(cmd string) (string, error), copy func(source, dest string) error) (*quickjs.VM, error) { + js, err := quickjs.NewVM() + if err != nil { + return nil, fmt.Errorf("create JavaScript VM: %w", err) + } + + if err := js.StdAddHelpers(); err != nil { + js.Close() + return nil, fmt.Errorf("add console to JavaScript VM: %w", err) + } + + for name, fn := range map[string]func(cmd string) (string, error){ + "__vmbully_run": run, + "__vmbully_host_run": hostRun, + } { + err := js.RegisterHostFunc(name, func(args []any) (any, error) { + if len(args) != 1 { + return nil, errors.New("the command tag needs one command") + } + cmd, ok := args[0].(string) + if !ok { + return nil, errors.New("the command tag needs a string command") + } + return fn(cmd) + }) + if err != nil { + js.Close() + return nil, fmt.Errorf("register %s: %w", name, err) + } + } + + err = js.RegisterHostFunc("scp", func(args []any) (any, error) { + if len(args) != 2 { + return nil, errors.New("scp needs a source and a destination") + } + source, ok1 := args[0].(string) + dest, ok2 := args[1].(string) + if !ok1 || !ok2 { + return nil, errors.New("scp needs a string source and a string destination") + } + return nil, copy(source, dest) + }) + if err != nil { + js.Close() + return nil, fmt.Errorf("register scp: %w", err) + } + + if _, err := js.Eval(scriptPrelude, quickjs.EvalGlobal); err != nil { + js.Close() + return nil, fmt.Errorf("define $ and host$: %w", err) + } + + return js, nil +} diff --git a/cmd/vmbully/script_test.go b/cmd/vmbully/script_test.go new file mode 100644 index 00000000..f584665a --- /dev/null +++ b/cmd/vmbully/script_test.go @@ -0,0 +1,146 @@ +package main + +import ( + "errors" + "slices" + "testing" + + "modernc.org/quickjs" +) + +func TestScriptVM(t *testing.T) { + for _, tt := range []struct { + name string + script string + runErr error + hostErr error + wantCmds []string + wantHost []string + wantCopy []string + wantRes any + wantErr bool + }{ + { + name: "plain command returns stdout", + script: "$`uname -a`", + wantCmds: []string{"uname -a"}, + wantRes: "out:uname -a", + }, + { + name: "value is one shell word", + script: "const f = \"a b; rm -rf /\"; $`cat ${f}`", + wantCmds: []string{"cat 'a b; rm -rf /'"}, + wantRes: "out:cat 'a b; rm -rf /'", + }, + { + name: "single quote in value", + script: "$`echo ${\"it's\"}`", + wantCmds: []string{`echo 'it'\''s'`}, + wantRes: `out:echo 'it'\''s'`, + }, + { + name: "array is one word for each element", + script: "$`ls ${[\"a\", \"b c\"]} ${1}`", + wantCmds: []string{"ls 'a' 'b c' '1'"}, + wantRes: "out:ls 'a' 'b c' '1'", + }, + { + name: "failed command throws", + script: "$`false`; $`true`", + runErr: errors.New("exit status 1"), + wantCmds: []string{"false"}, + wantErr: true, + }, + { + name: "script can catch a failed command", + script: "let r = \"ok\"; try { $`false` } catch (e) { r = \"caught\" }; r", + runErr: errors.New("exit status 1"), + wantCmds: []string{"false"}, + wantRes: "caught", + }, + { + name: "host$ runs on the host with the same quoting", + script: "const out = $`hostname`; host$`echo ${out} ${[\"a b\"]}`", + wantCmds: []string{"hostname"}, + wantHost: []string{"echo 'out:hostname' 'a b'"}, + wantRes: "host:echo 'out:hostname' 'a b'", + }, + { + name: "failed host command throws", + script: "host$`false`; $`true`", + hostErr: errors.New("exit status 1"), + wantHost: []string{"false"}, + wantErr: true, + }, + { + name: "scp passes source and destination", + script: "scp(\"./a.txt\", \"/tmp/a.txt\"); 1", + wantCopy: []string{"./a.txt", "/tmp/a.txt"}, + wantRes: 1, + }, + { + name: "scp with one argument throws", + script: "scp(\"./a.txt\")", + wantErr: true, + }, + { + name: "script throw is an error", + script: "throw new Error(\"nope\")", + wantErr: true, + }, + } { + t.Run(tt.name, func(t *testing.T) { + var cmds, hostCmds, copied []string + + js, err := newScriptVM( + func(cmd string) (string, error) { + cmds = append(cmds, cmd) + return "out:" + cmd, tt.runErr + }, + func(cmd string) (string, error) { + hostCmds = append(hostCmds, cmd) + return "host:" + cmd, tt.hostErr + }, + func(source, dest string) error { + copied = append(copied, source, dest) + return nil + }, + ) + if err != nil { + t.Fatal(err) + } + defer js.Close() + + res, err := js.Eval(tt.script, quickjs.EvalGlobal) + if (err != nil) != tt.wantErr { + t.Fatalf("Eval error = %v, wantErr %v", err, tt.wantErr) + } + if !slices.Equal(cmds, tt.wantCmds) { + t.Errorf("commands = %q, want %q", cmds, tt.wantCmds) + } + if !slices.Equal(hostCmds, tt.wantHost) { + t.Errorf("host commands = %q, want %q", hostCmds, tt.wantHost) + } + if !slices.Equal(copied, tt.wantCopy) { + t.Errorf("copied = %q, want %q", copied, tt.wantCopy) + } + if !tt.wantErr && res != tt.wantRes { + t.Errorf("result = %#v, want %#v", res, tt.wantRes) + } + }) + } +} + +func TestHostRun(t *testing.T) { + out, err := hostRun(t.Context(), "printf 'a b'") + if err != nil { + t.Fatal(err) + } + if out != "a b" { + t.Errorf("stdout = %q, want %q", out, "a b") + } + + if _, err := hostRun(t.Context(), "exit 3"); err == nil { + t.Error("a nonzero exit status must be an error") + } +} diff --git a/cmd/vmbully/ssh.go b/cmd/vmbully/ssh.go new file mode 100644 index 00000000..f5b7bbf0 --- /dev/null +++ b/cmd/vmbully/ssh.go @@ -0,0 +1,161 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "io" + "net" + "os" + "path/filepath" + "strings" + "time" + + "golang.org/x/crypto/ssh" +) + +// isUsableIPv4 reports if addr is an IPv4 address that a remote client can reach. +func isUsableIPv4(addr string) bool { + ip := net.ParseIP(addr) + return ip != nil && ip.To4() != nil && !ip.IsLoopback() && !ip.IsLinkLocalUnicast() +} + +// sshDial logs in to host with a password. The connection closes when ctx ends. +func sshDial(ctx context.Context, host, user, password string) (*ssh.Client, error) { + addr := net.JoinHostPort(host, "22") + + d := net.Dialer{Timeout: 10 * time.Second} + conn, err := d.DialContext(ctx, "tcp", addr) + if err != nil { + return nil, err + } + + sc, chans, reqs, err := ssh.NewClientConn(conn, addr, &ssh.ClientConfig{ + User: user, + Auth: []ssh.AuthMethod{ssh.Password(password)}, + // The VM is seconds old, so there is no known host key to check. Lab only. + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + Timeout: 10 * time.Second, + }) + if err != nil { + conn.Close() + return nil, err + } + + client := ssh.NewClient(sc, chans, reqs) + context.AfterFunc(ctx, func() { client.Close() }) + + return client, nil +} + +// sshRun runs cmd on the remote host and returns its stdout. The stderr of cmd +// goes to the local stderr. A nonzero exit status is an error. +func sshRun(client *ssh.Client, cmd string) (string, error) { + sess, err := client.NewSession() + if err != nil { + return "", err + } + defer sess.Close() + + var stdout, stderr bytes.Buffer + sess.Stdout = &stdout + sess.Stderr = io.MultiWriter(os.Stderr, &stderr) + + if err := sess.Run(cmd); err != nil { + var exitErr *ssh.ExitError + if errors.As(err, &exitErr) { + return stdout.String(), fmt.Errorf("command %q: exit status %d: %s", cmd, exitErr.ExitStatus(), strings.TrimSpace(stderr.String())) + } + return stdout.String(), fmt.Errorf("command %q: %w", cmd, err) + } + + return stdout.String(), nil +} + +// shellQuote quotes s as one word for a POSIX shell. +func shellQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} + +// sshCopy copies the local file source to dest on the remote host with the +// scp protocol. dest is a file path or a directory. +func sshCopy(client *ssh.Client, source, dest string) error { + fin, err := os.Open(source) + if err != nil { + return err + } + defer fin.Close() + + st, err := fin.Stat() + if err != nil { + return err + } + if !st.Mode().IsRegular() { + return fmt.Errorf("%s is not a regular file", source) + } + + sess, err := client.NewSession() + if err != nil { + return err + } + defer sess.Close() + + stdin, err := sess.StdinPipe() + if err != nil { + return err + } + stdout, err := sess.StdoutPipe() + if err != nil { + return err + } + var stderr bytes.Buffer + sess.Stderr = &stderr + + if err := sess.Start("scp -t -- " + shellQuote(dest)); err != nil { + return err + } + + // The remote scp sends one status reply at the start and one after each + // message: 0 for OK, 1 or 2 for an error with a text line. + replies := bufio.NewReader(stdout) + ack := func() error { + code, err := replies.ReadByte() + if err != nil { + return fmt.Errorf("read scp reply: %w: %s", err, strings.TrimSpace(stderr.String())) + } + if code == 0 { + return nil + } + msg, _ := replies.ReadString('\n') + return fmt.Errorf("scp: %s", strings.TrimSpace(msg)) + } + + if err := ack(); err != nil { + return err + } + + if _, err := fmt.Fprintf(stdin, "C%04o %d %s\n", st.Mode().Perm(), st.Size(), filepath.Base(source)); err != nil { + return err + } + if err := ack(); err != nil { + return err + } + + if _, err := io.CopyN(stdin, fin, st.Size()); err != nil { + return err + } + if _, err := stdin.Write([]byte{0}); err != nil { + return err + } + if err := ack(); err != nil { + return err + } + + if err := stdin.Close(); err != nil { + return err + } + + return sess.Wait() +} diff --git a/cmd/vmbully/template.go b/cmd/vmbully/template.go new file mode 100644 index 00000000..462cc921 --- /dev/null +++ b/cmd/vmbully/template.go @@ -0,0 +1,192 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log/slog" + "os" + "strings" + "time" + + "github.com/google/subcommands" + "github.com/luthermonson/go-proxmox" + "within.website/x/internal/flagenv" +) + +type templateCmd struct { + id int + name string + diskStorage string + importStorage string + imageURL string + imageName string + ciUser string + ciPassword string + sshKeysFile string + ciVendor string +} + +func (*templateCmd) Name() string { return "template" } +func (*templateCmd) Synopsis() string { return "Create a VM template from a cloud image." } +func (*templateCmd) Usage() string { + return `template [--id] [--name] [--disk-storage] [--ssh-keys-file] [...]: +Download a cloud image to the import storage, create a VM from it, and convert the VM to a template. +` +} +func (t *templateCmd) SetFlags(f *flag.FlagSet) { + f.IntVar(&t.id, "id", 9000, "VMID of the template to create") + f.StringVar(&t.name, "name", "ubuntu-26-04-resolute", "name of the template to create") + f.StringVar(&t.diskStorage, "disk-storage", "ssd", "storage for the disks of the template") + f.StringVar(&t.importStorage, "import-storage", "local", "storage for the downloaded cloud image, needs the import content type") + f.StringVar(&t.imageURL, "image-url", "https://cloud-images.ubuntu.com/resolute/current/resolute-server-cloudimg-amd64.img", "cloud image to build the template from") + f.StringVar(&t.imageName, "image-name", "resolute-server-cloudimg-amd64.qcow2", "file name of the cloud image on the import storage, must end in .qcow2") + f.StringVar(&t.ciUser, "ci-user", "xe", "cloud-init user") + f.StringVar(&t.ciPassword, "ci-password", "", "cloud-init password (optional)") + f.StringVar(&t.sshKeysFile, "ssh-keys-file", "", "file with SSH public keys for the cloud-init user (optional)") + f.StringVar(&t.ciVendor, "ci-vendor", "", "volume ID of a cloud-init vendor snippet, for example local:snippets/qemu_guest_agent.yaml (optional)") +} + +func (t *templateCmd) Execute(ctx context.Context, f *flag.FlagSet, _ ...any) subcommands.ExitStatus { + if err := flagenv.ParseSet(flagenv.Prefix, f); err != nil { + slog.Error("can't read flags from the environment", "err", err) + return subcommands.ExitUsageError + } + + if err := t.run(ctx); err != nil { + slog.Error("can't create template", "err", err) + return subcommands.ExitFailure + } + + return subcommands.ExitSuccess +} + +func (t *templateCmd) run(ctx context.Context) error { + if !strings.HasSuffix(t.imageName, ".qcow2") { + return fmt.Errorf("--image-name %q must end in .qcow2", t.imageName) + } + + var sshKeys string + if t.sshKeysFile != "" { + data, err := os.ReadFile(t.sshKeysFile) + if err != nil { + return fmt.Errorf("read SSH keys: %w", err) + } + sshKeys = strings.TrimSpace(string(data)) + } + + client, n, err := node(ctx) + if err != nil { + return err + } + + // Check the VMID before the download so a taken ID fails fast. + cluster, err := client.Cluster(ctx) + if err != nil { + return fmt.Errorf("get cluster: %w", err) + } + resources, err := cluster.Resources(ctx, "vm") + if err != nil { + return fmt.Errorf("list cluster VMs: %w", err) + } + for _, r := range resources { + if r.VMID == uint64(t.id) { + return fmt.Errorf("VMID %d is in use by %q on node %s", t.id, r.Name, r.Node) + } + } + + // PVE pulls the image onto the storage itself. import-from only takes a + // volume from a storage with the import content type when the caller is + // not root@pam. + storage, err := n.Storage(ctx, t.importStorage) + if err != nil { + return fmt.Errorf("get storage %s: %w", t.importStorage, err) + } + + imageVolID := fmt.Sprintf("%s:import/%s", t.importStorage, t.imageName) + content, err := storage.GetContent(ctx) + if err != nil { + return fmt.Errorf("list content of storage %s: %w", t.importStorage, err) + } + + if hasVolume(content, imageVolID) { + slog.Info("image already present, skipping download", "volid", imageVolID) + } else { + slog.Info("downloading image", "url", t.imageURL, "volid", imageVolID) + task, err := storage.DownloadURL(ctx, "import", t.imageName, t.imageURL) + if err != nil { + return fmt.Errorf("download image: %w", err) + } + if err := waitTask(ctx, task, 5*time.Second, 20*time.Minute); err != nil { + return fmt.Errorf("download image: %w", err) + } + } + + options := []proxmox.VirtualMachineOption{ + {Name: "name", Value: t.name}, + {Name: "ostype", Value: "l26"}, + {Name: "bios", Value: "ovmf"}, + {Name: "net0", Value: "virtio,bridge=vmbr0"}, + // Cloud images ship a serial console; point the display at it. + {Name: "serial0", Value: "socket"}, + {Name: "vga", Value: "serial0"}, + {Name: "memory", Value: 2048}, + {Name: "cores", Value: 2}, + {Name: "cpu", Value: "host"}, + {Name: "balloon", Value: 512}, + // "storage:0" takes the disk size from the import source. + {Name: "scsi0", Value: fmt.Sprintf("%s:0,import-from=%s,discard=on", t.diskStorage, imageVolID)}, + {Name: "boot", Value: "order=scsi0"}, + {Name: "scsihw", Value: "virtio-scsi-pci"}, + {Name: "agent", Value: "enabled=1,fstrim_cloned_disks=1"}, + {Name: "scsi2", Value: fmt.Sprintf("%s:cloudinit", t.diskStorage)}, + {Name: "ipconfig0", Value: "ip=dhcp"}, + {Name: "ciuser", Value: t.ciUser}, + {Name: "efidisk0", Value: fmt.Sprintf("%s:1,format=raw,efitype=4m,pre-enrolled-keys=1", t.diskStorage)}, + } + if t.ciPassword != "" { + options = append(options, proxmox.VirtualMachineOption{Name: "cipassword", Value: t.ciPassword}) + } + if sshKeys != "" { + // PVE rejects Go's default query escaping for this field. + options = append(options, proxmox.VirtualMachineOption{Name: "sshkeys", Value: proxmox.EncodeSSHKeys(sshKeys)}) + } + if t.ciVendor != "" { + options = append(options, proxmox.VirtualMachineOption{Name: "cicustom", Value: "vendor=" + t.ciVendor}) + } + + slog.Info("creating VM", "id", t.id, "name", t.name) + task, err := n.NewVirtualMachine(ctx, t.id, options...) + if err != nil { + return fmt.Errorf("create VM %d: %w", t.id, err) + } + if err := waitTask(ctx, task, time.Second, 5*time.Minute); err != nil { + return fmt.Errorf("create VM %d: %w", t.id, err) + } + + vm, err := n.VirtualMachine(ctx, t.id) + if err != nil { + return fmt.Errorf("get VM %d: %w", t.id, err) + } + + task, err = vm.ConvertToTemplate(ctx) + if err != nil { + return fmt.Errorf("convert VM %d to template: %w", t.id, err) + } + if err := waitTask(ctx, task, time.Second, 2*time.Minute); err != nil { + return fmt.Errorf("convert VM %d to template: %w", t.id, err) + } + + slog.Info("template ready", "id", t.id, "name", t.name) + + return nil +} + +func hasVolume(content []*proxmox.StorageContent, volid string) bool { + for _, c := range content { + if c.Volid == volid { + return true + } + } + return false +} diff --git a/cmd/vmbully/test_httpdebug.js b/cmd/vmbully/test_httpdebug.js new file mode 100644 index 00000000..535c80c3 --- /dev/null +++ b/cmd/vmbully/test_httpdebug.js @@ -0,0 +1,4 @@ +$`wget -O /home/ci/httpdebug.deb https://github.com/Xe/x/releases/download/v1.31.0/httpdebug_1.31.0_amd64.deb`; +$`sudo apt-get -f install /home/ci/httpdebug.deb`; +$`sudo systemctl enable --now httpdebug.service`; +console.log($`sudo systemctl status httpdebug.service`); diff --git a/go.mod b/go.mod index 7ae2c2a6..85994cff 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module within.website/x -go 1.25.7 +go 1.26.0 require ( al.essio.dev/pkg/shellescape v1.6.0 @@ -50,6 +50,7 @@ require ( github.com/kr/pretty v0.3.1 github.com/lib/pq v1.10.9 github.com/lum8rjack/go-ja4h v0.0.0-20250606032308-3a989c6635be + github.com/luthermonson/go-proxmox v0.8.2 github.com/mattn/go-sqlite3 v1.14.24 github.com/modelcontextprotocol/go-sdk v1.4.1 github.com/multiformats/go-multibase v0.3.0 @@ -82,9 +83,9 @@ require ( go.etcd.io/bbolt v1.4.0 go.jetpack.io/tyson v0.1.1 go4.org v0.0.0-20190313082347-94abd6928b1d - golang.org/x/crypto v0.55.0 + golang.org/x/crypto v0.57.0 golang.org/x/oauth2 v0.36.0 - golang.org/x/sync v0.22.0 + golang.org/x/sync v0.23.0 google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa google.golang.org/grpc v1.83.2 gopkg.in/natefinch/lumberjack.v2 v2.2.1 @@ -122,6 +123,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb // indirect + github.com/buger/goterm v1.0.4 // indirect github.com/buger/jsonparser v1.1.2 // indirect github.com/bytedance/sonic v1.12.6 // indirect github.com/bytedance/sonic/loader v0.2.0 // indirect @@ -147,6 +149,8 @@ require ( github.com/cyphar/filepath-securejoin v0.6.1 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/diskfs/go-diskfs v1.9.3 // indirect + github.com/djherbis/times v1.6.0 // indirect github.com/dop251/goja v0.0.0-20250309171923-bcd7cc6bf64c // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/ebitengine/purego v0.8.3 // indirect @@ -198,6 +202,7 @@ require ( github.com/ipld/go-ipld-prime v0.23.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/jbenet/goprocess v0.1.4 // indirect + github.com/jinzhu/copier v0.3.4 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect @@ -212,8 +217,9 @@ require ( github.com/lestrrat-go/option v1.0.1 // indirect github.com/lmittmann/tint v1.1.3 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/magefile/mage v1.14.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect github.com/mattn/go-tty v0.0.3 // indirect github.com/minio/sha256-simd v1.0.1 // indirect @@ -232,7 +238,7 @@ require ( github.com/natefinch/atomic v1.0.1 // indirect github.com/nats-io/nkeys v0.4.11 // indirect github.com/nats-io/nuid v1.0.1 // indirect - github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/julianday v1.0.0 // indirect github.com/opentracing/opentracing-go v1.2.0 // indirect github.com/otiai10/mint v1.6.3 // indirect @@ -285,8 +291,8 @@ require ( go.yaml.in/yaml/v4 v4.0.0-rc.4 // indirect golang.org/x/arch v0.6.0 // indirect golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 // indirect - golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959 // indirect - golang.org/x/term v0.45.0 // indirect + golang.org/x/telemetry v0.0.0-20260908163034-4bcc4b2ee518 // indirect + golang.org/x/term v0.46.0 // indirect golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.2 // indirect @@ -295,9 +301,11 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect honnef.co/go/tools v0.6.1 // indirect lukechampine.com/blake3 v1.2.1 // indirect - modernc.org/libc v1.62.1 // indirect + modernc.org/libc v1.77.1 // indirect + modernc.org/libquickjs v0.13.1 // indirect modernc.org/mathutil v1.7.1 // indirect - modernc.org/memory v1.9.1 // indirect + modernc.org/memory v1.12.1 // indirect + modernc.org/quickjs v0.25.0 // indirect mvdan.cc/sh/v3 v3.12.0 // indirect ) @@ -328,7 +336,7 @@ require ( github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect - github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/compress v1.18.5 // indirect github.com/kr/text v0.2.0 // indirect github.com/oklog/ulid/v2 v2.1.0 github.com/prometheus/client_golang v1.22.0 @@ -341,12 +349,12 @@ require ( github.com/sendgrid/rest v2.6.9+incompatible // indirect golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f golang.org/x/image v0.41.0 - golang.org/x/mod v0.38.0 // indirect - golang.org/x/net v0.58.0 - golang.org/x/sys v0.47.0 // indirect - golang.org/x/text v0.41.0 // indirect + golang.org/x/mod v0.41.0 // indirect + golang.org/x/net v0.59.0 + golang.org/x/sys v0.48.0 // indirect + golang.org/x/text v0.42.0 // indirect golang.org/x/time v0.11.0 - golang.org/x/tools v0.48.0 + golang.org/x/tools v0.50.0 google.golang.org/protobuf v1.36.11 ) diff --git a/go.sum b/go.sum index 6453a9db..d203baed 100644 --- a/go.sum +++ b/go.sum @@ -129,6 +129,8 @@ github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRF github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE= +github.com/anchore/go-lzo v0.1.0 h1:NgAacnzqPeGH49Ky19QKLBZEuFRqtTG9cdaucc3Vncs= +github.com/anchore/go-lzo v0.1.0/go.mod h1:3kLx0bve2oN1iDwgM1U5zGku1Tfbdb0No5qp1eL1fIk= github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= @@ -208,6 +210,8 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/buger/goterm v1.0.4 h1:Z9YvGmOih81P0FbVtEYTFF6YsSgxSUKEhf/f9bTMXbY= +github.com/buger/goterm v1.0.4/go.mod h1:HiFWV3xnkolgrBV3mY8m0X0Pumt4zg4QhbdOzQtB8tE= github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno= @@ -314,6 +318,10 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= +github.com/diskfs/go-diskfs v1.9.3 h1:cLciNCeZ4QAXVxyPJDr1ZJ9N9CCG3rQlQ/z/Cs/cNDM= +github.com/diskfs/go-diskfs v1.9.3/go.mod h1:TePJORO83Adh5pb2SqsxAwaP0fofFxKLkxctiS/9OQc= +github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= +github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dop251/goja v0.0.0-20250309171923-bcd7cc6bf64c h1:mxWGS0YyquJ/ikZOjSrRjjFIbUqIP9ojyYQ+QZTU3Rg= @@ -329,6 +337,8 @@ github.com/ebitengine/purego v0.8.3/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M= github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= +github.com/elliotwutingfeng/asciiset v0.0.0-20260129054604-cfde2086bc57 h1:x5yxNrq8XffV/OoNUeFPM6hxHVi5OTspSTBxr/9pemg= +github.com/elliotwutingfeng/asciiset v0.0.0-20260129054604-cfde2086bc57/go.mod h1:GLo/8fDswSAniFG+BFIaiSPcK610jyzgEhWYPQwuQdw= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= @@ -410,8 +420,8 @@ github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c/go.mod h1:oVDCh3qjJM github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU= github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= -github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= -github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= @@ -566,6 +576,10 @@ github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/ad github.com/grbit/go-json v0.11.0 h1:bAbyMdYrYl/OjYsSqLH99N2DyQ291mHy726Mx+sYrnc= github.com/grbit/go-json v0.11.0/go.mod h1:IYpHsdybQ386+6g3VE6AXQ3uTGa5mquBme5/ZWmtzek= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= +github.com/h2non/gock v1.2.0 h1:K6ol8rfrRkUOefooBC8elXoaNGYkpp7y2qcxGG6BzUE= +github.com/h2non/gock v1.2.0/go.mod h1:tNhoxHYW2W42cYkYb1WqzdbYIieALC99kpYr7rH/BQk= +github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= +github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q= github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -655,6 +669,8 @@ github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOl github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/jbenet/goprocess v0.1.4 h1:DRGOFReOMqqDNXwW70QkacFW0YN9QnwLV0Vqk+3oU0o= github.com/jbenet/goprocess v0.1.4/go.mod h1:5yspPrukOVuOLORacaBi858NqyClJPQxYZlqdZVfqY4= +github.com/jinzhu/copier v0.3.4 h1:mfU6jI9PtCeUjkjQ322dlff9ELjGDu975C2p/nrubVI= +github.com/jinzhu/copier v0.3.4/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= @@ -680,8 +696,8 @@ github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kjk/lzma v0.0.0-20161016003348-3fd93898850d h1:RnWZeH8N8KXfbwMTex/KKMYMj0FJRCF6tQubUuQ02GM= github.com/kjk/lzma v0.0.0-20161016003348-3fd93898850d/go.mod h1:phT/jsRPBAEqjAibu1BurrabCBNTYiVI+zbmyCZJY6Q= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= @@ -725,6 +741,10 @@ github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQ github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lum8rjack/go-ja4h v0.0.0-20250606032308-3a989c6635be h1:dVIND0nXGXPQnFZYrMXT6CxHhBYhTPMm0GFqcmfaIC4= github.com/lum8rjack/go-ja4h v0.0.0-20250606032308-3a989c6635be/go.mod h1:q68TUR45WDa2r3yU4aO6WgxfCc0Vj1qtRaKaRE3yMLM= +github.com/luthermonson/go-proxmox v0.8.2 h1:lh8FVu1cmF+zFfGM2sbf0QrUUJxAtcXbQsTQPNh0I00= +github.com/luthermonson/go-proxmox v0.8.2/go.mod h1:Q6ByFv9Zak9NvJwZJ36HMN5qwIZVj0isxf8u4YIk6lE= +github.com/magefile/mage v1.14.0 h1:6QDX3g6z1YvJ4olPhT1wksUcSa/V0a1B+pJb73fBjyo= +github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= @@ -741,6 +761,8 @@ github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27k github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mattn/go-runewidth v0.0.6/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= @@ -815,6 +837,8 @@ github.com/ncruces/go-sqlite3/gormlite v0.16.0 h1:pBN2h323adfTF3NThIuHa3e7GCesgq github.com/ncruces/go-sqlite3/gormlite v0.16.0/go.mod h1:TyfqjFmR31x1iOth4t6hPgtUf2UvM8zupKSj6TZkP4c= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M= github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g= github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJEU3ofeGjhHklVoIGuVj85JJwZ6kWPaJwCIxgnFmo= @@ -850,6 +874,8 @@ github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwp github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/petar/GoLLRB v0.0.0-20210522233825-ae3b015fd3e9 h1:1/WtZae0yGtPq+TI6+Tv1WTxkukpXeMlviSxvL7SRgk= github.com/petar/GoLLRB v0.0.0-20210522233825-ae3b015fd3e9/go.mod h1:x3N5drFsm2uilKKuuYo6LdyD8vZAW55sH/9w+pbo1sw= +github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY= +github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= github.com/pires/go-proxyproto v0.7.0 h1:IukmRewDQFWC7kfnb66CSomk2q/seBuilHBYFwyq0Hs= github.com/pires/go-proxyproto v0.7.0/go.mod h1:Vz/1JPY/OACxWGQNIRY2BeyDmpoaWmEP40O9LbuiFR4= github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU= @@ -862,6 +888,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI= github.com/pkg/term v1.2.0-beta.2 h1:L3y/h2jkuBVFdWiJvNfYfKmzcCnILw7mJWm2JQuMppw= github.com/pkg/term v1.2.0-beta.2/go.mod h1:E25nymQcrSllhX42Ok8MRm1+hyBdHY0dCeiKZ9jpNGw= +github.com/pkg/xattr v0.4.12 h1:rRTkSyFNTRElv6pkA3zpjHpQ90p/OdHQC1GmGh1aTjM= +github.com/pkg/xattr v0.4.12/go.mod h1:di8WF84zAKk8jzR1UBTEWh9AUlIZZ7M/JNt8e9B6ktU= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -966,6 +994,8 @@ github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6Mwd github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY= @@ -1165,6 +1195,8 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -1215,6 +1247,8 @@ golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +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.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -1276,6 +1310,8 @@ golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +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.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -1322,6 +1358,8 @@ golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +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-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -1376,6 +1414,7 @@ golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210331175145-43e1dd70ce54/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -1401,6 +1440,7 @@ golang.org/x/sys v0.0.0-20220502124256-b6088ccd6cba/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220610221304-9f5ed59c137d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -1417,9 +1457,13 @@ golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959 h1:RJhm5l6Fo4rmEIcndxDllNhhf/fAx8qIm4t6A7vpm2A= golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959/go.mod h1:LV7u5Oco+Z/g6XI7PqN+EUUUGGkEcmB1uj2ceI0fOVg= +golang.org/x/telemetry v0.0.0-20260908163034-4bcc4b2ee518 h1:F5BWKvW126NXR74uxkxuc1jQHhm/rwm/J3rSiFyuRs4= +golang.org/x/telemetry v0.0.0-20260908163034-4bcc4b2ee518/go.mod h1:i+ivNqjDnTF3WTElsdk5g9V5DTSBYgdNo7xTU9SDwYA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -1432,6 +1476,8 @@ golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= +golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -1451,6 +1497,8 @@ golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +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.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -1520,6 +1568,8 @@ golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58 golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU= +golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0= golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -1764,20 +1814,32 @@ lukechampine.com/blake3 v1.2.1 h1:YuqqRuaqsGV71BV/nm9xlI0MKUv4QC54jQnBChWbGnI= lukechampine.com/blake3 v1.2.1/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k= modernc.org/cc/v4 v4.25.2 h1:T2oH7sZdGvTaie0BRNFbIYsabzCxUQg8nLqCdQ2i0ic= modernc.org/cc/v4 v4.25.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/cc/v4 v4.29.7 h1:q+NXGJ0bK3b4TXFYQQVr9pYETGnmwFWkrUzJnMya/Tg= modernc.org/ccgo/v4 v4.25.1 h1:TFSzPrAGmDsdnhT9X2UrcPMI3N/mJ9/X9ykKXwLhDsU= modernc.org/ccgo/v4 v4.25.1/go.mod h1:njjuAYiPflywOOrm3B7kCB444ONP5pAVr8PIEoE0uDw= +modernc.org/ccgo/v4 v4.36.1 h1:ZNIUZAryN0UgnJwtyxrdEzcFc3yD4Cu4AzjfPXsLsIE= modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= modernc.org/libc v1.62.1 h1:s0+fv5E3FymN8eJVmnk0llBe6rOxCu/DEU+XygRbS8s= modernc.org/libc v1.62.1/go.mod h1:iXhATfJQLjG3NWy56a6WVU73lWOcdYVxsvwCgoPljuo= +modernc.org/libc v1.77.1 h1:Ct8j47QtiZ1Enj2DtFXQtUqrPCAjdCmPjtCuvrYQ0Hs= +modernc.org/libc v1.77.1/go.mod h1:87/pZ4L6nD1zqW4nItuS12YO7hN1igAah34xjnQo/W0= +modernc.org/libquickjs v0.13.1 h1:uCb9AYyEyL1JS2dnKtLZKWH/vDb36cx1Mr1SIdbzb8A= +modernc.org/libquickjs v0.13.1/go.mod h1:tCEsA1Zda1+C5JagFIJGqjYZOxDPFOUYjK3dGhEeq0g= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.9.1 h1:V/Z1solwAVmMW1yttq3nDdZPJqV1rM05Ccq6KMSZ34g= modernc.org/memory v1.9.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g= +modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/quickjs v0.25.0 h1:j0oeoGlDh7YMYiNgpqLxrnxXZ8DPawv3M4u4B+qqqP0= +modernc.org/quickjs v0.25.0/go.mod h1:ptleYFKKqDn8J+JvpBauJcXajcpj4eWDGwW7qHpl6ao= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI= diff --git a/yeetfile.js b/yeetfile.js index 2354d702..83bcc2a6 100644 --- a/yeetfile.js +++ b/yeetfile.js @@ -178,6 +178,25 @@ const pkgs = []; ); } + pkgs.push( + method.build({ + name: "vmbully", + description: "Run scripts against single-use Proxmox VMs", + homepage: "https://within.website", + license: "CC0", + goarch, + + documentation: { + LICENSE: "LICENSE", + "cmd/vmbully/README.md": "README.md", + }, + + build: ({ bin }) => { + $`go build -o ${bin}/vmbully -ldflags '-s -w -extldflags "-static" -X "within.website/x.Version=${git.tag()}"' ./cmd/vmbully`; + }, + }), + ); + pkgs.push( method.build({ name: "x",