Skip to main content

architecture

Full project docs: README.md, SPEC.md, API reference, testing plan, setup guide

Package map​

cmd/              Cobra CLI (root, subcommands, TUI)
β”œβ”€β”€ root.go entrypoint, plugin dispatch, post-quit actions
β”œβ”€β”€ commands.go list, start, stop, ssh, viewer, logs, info, restart, pause, migrate, scale, adddisk, rmdisk, snapshot
β”œβ”€β”€ create.go corral create (all flags, both backends, CLI catalog/import/bootc)
β”œβ”€β”€ clone.go corral clone (kubevirt VM disk + config clone)
β”œβ”€β”€ ct.go corral ct (create/list/start/stop/delete/console β€” Containers, pkg/ct)
β”œβ”€β”€ images.go corral images (list catalog + imported datavolumes)
β”œβ”€β”€ config.go corral config (show, set auth key)
β”œβ”€β”€ web.go corral web (--addr, serve embedded SPA)
β”œβ”€β”€ tui.go Bubble Tea TUI (VMs + Containers side by side; VM and CT action menus, clone input state)
β”œβ”€β”€ plugin.go corral plugin (search, install, list, remove)
β”œβ”€β”€ doctor.go corral doctor (cluster diagnostics)
β”œβ”€β”€ helpers.go shared CLI helpers (flags, namespace resolution)
β”œβ”€β”€ corral-backup/ plugin: on-demand + scheduled (in-cluster CronJob) S3/R2 backups (built with -tags bootc-free, separate binary)
β”œβ”€β”€ corral-windows/ plugin: UEFI/TPM/virtio-tuned Windows VMs
β”œβ”€β”€ corral-vdi/ plugin: desktop pools (RFC-0001 phase 1) β€” pkg/vdi
└── corral-bootc/ plugin binary (built with -tags bootc, separate from core)

pkg/ Library code (importable)
β”œβ”€β”€ catalog/ curated OS image catalog (containerdisks)
β”œβ”€β”€ config/ ~/.config/tailvm/config.yaml reader (Tailscale auth key)
β”œβ”€β”€ cronops/ shared CronJob/RBAC manifest builders for scheduled-op plugins (backup, snapsched, schedule)
β”œβ”€β”€ ct/ Containers (CT) β€” pet pods, not KubeVirt VMs; see docs/adr/0005
β”‚ └── ct.go Create/Start/Stop/Delete/List/Exists, distrobox-style persistent-rootfs bootstrap for privileged CTs
β”œβ”€β”€ doctor/ cluster diagnostics + auto-fix (namespace, CDI, KubeVirt, GPU/PCI passthrough, StorageClass perf, etc.)
β”œβ”€β”€ kubevirt/ KubeVirt backend (the heavy lifter)
β”‚ β”œβ”€β”€ client.go VM CRUD, SSH via virtctl, cloud-init, registry
β”‚ β”œβ”€β”€ client_test.go integration tests (need kubectl context)
β”‚ β”œβ”€β”€ features.go scale, volumes, snapshots, clone, export, guest-info, metrics, capabilities, templates
β”‚ β”œβ”€β”€ options.go boot options (run strategy, firmware, machine type, boot order) β€” read-modify-apply
β”‚ β”œβ”€β”€ upload.go ISO/template upload via virtctl image-upload
β”‚ β”œβ”€β”€ bootc_core.go bootc interface seam (always compiled β€” nil when tag absent)
β”‚ └── bootc.go bootc implementation (//go:build bootc β€” Job + kernel-boot VM)
β”œβ”€β”€ plugin/ extension system (krew-style, marketplace fetch, install/remove)
β”œβ”€β”€ qemu/ QEMU backend (local VMs via systemd user service)
β”œβ”€β”€ registry/ ~/.local/share/tailvm/registry.json β€” VM name β†’ backend/namespace/password
β”œβ”€β”€ vdi/ desktop pools (RFC-0001 phase 1) β€” clone-based pool create, label-based assignment
β”œβ”€β”€ types/ shared types (VM, CreateOpts, RegistryEntry)
└── web/ Proxmox-style web UI
β”œβ”€β”€ server.go HTTP server, mux, VM list/create/action/delete/info/export, nodes, tasks, WS bridges (VM VNC/serial + CT exec)
β”œβ”€β”€ server_test.go
β”œβ”€β”€ ct.go CT list/create/start-stop/delete HTTP handlers
β”œβ”€β”€ features.go capabilities, scale, volumes, expand, snapshots, clone, guest-info, events, metrics, templates,
β”‚ doctor, plugins, NADs, NICs, images, instancetypes, datavolumes, boot options, ISO upload
└── static/
β”œβ”€β”€ index.html dark SPA shell, create dialog (6 source types), CT create dialog, build dialog
β”œβ”€β”€ app.js API client, tree (Server View/Folder View, VMs+CTs merged), VM/CT detail panels
β”‚ (Summary/Hardware/Options/Snapshots/Events/Console/Terminal), create wizards,
β”‚ image library + import, bootc build streaming, mobile drawer
β”œβ”€β”€ icons.js inline Heroicon SVGs
└── style.css dark theme, responsive, dialog/modals, cards, tables

marketplace/ Plugin registry (hosted at tuna-os/corral)
└── index.json plugin entries (name, description, version, platform download URLs)

deploy/ On-cluster manifests
└── corral-web.yaml Namespace, SA, ClusterRole, Deployment, Service, Tailscale Ingress

docs/ Documentation
β”œβ”€β”€ api.md REST API reference
β”œβ”€β”€ architecture.md This file
β”œβ”€β”€ adr/ Architecture Decision Records (0001-0005)
└── kubevirt-proxmox-setup.md From-scratch cluster setup guide

scripts/ CI and Docker helper scripts
Containerfile Alpine + corral binary + kubectl + virtctl (for on-cluster deployment)
main.go Entrypoint: cmd.Execute()

Design decisions​

No client-go β€” shell-out to kubectl/virtctl​

Corral never imports client-go. Every cluster interaction shells out to kubectl or virtctl. This keeps the binary under ~12 MB, avoids a ~60 MB SDK dependency, and respects whatever kubeconfig the operator already has. The trade-off: slower per-call latency (process spawn). For a single-operator tool this is fine; for a multi-tenant API server it would be unacceptable.

Single binary, three UIs​

corral is one Go binary that serves three interfaces:

  1. CLI β€” Cobra subcommands (corral create, corral ssh, etc.)
  2. TUI β€” Bubble Tea interactive terminal UI (bare corral)
  3. Web β€” Embedded SPA served at corral web --addr :8006

All three share the same pkg/kubevirt backend and ~/.local/share/tailvm/registry.json. A VM created in the browser is immediately corral ssh-able and vice versa.

Embedded SPA β€” no JS build step​

The web UI is vanilla JavaScript and CSS embedded via //go:embed. There is no Node, no bundler, no build step. xterm.js and noVNC are loaded from CDN at runtime (they're only needed on the console pages and are too large to embed). Heroicons are inline SVGs.

Backend transparency​

After corral create, the registry stores backend: "kubevirt" or backend: "qemu". Every subsequent command (start, ssh, viewer, etc.) reads the registry to determine the backend, falling back to live probing if the registry entry is missing. The user never specifies --kubevirt again.

Bootc as a plugin​

Bootc (building a container image into a VM disk on-cluster) is an optional plugin compiled behind a //go:build bootc tag. The core binary's bootc_core.go defines the interface seam with nil implementations; the tagged bootc.go replaces them via init(). This keeps the core binary lean for users who only need containerdisks and ISOs, while the full pipeline is available via corral plugin install bootc or by building with -tags bootc.

The web UI checks GET /api/capabilities β†’ bootc: true/false and shows/hides the bootc source option accordingly.

KubeVirt LiveUpdate strategy​

Corral creates VMs with cpu.maxSockets + memory.maxGuest headroom and masquerade networking so they can hotplug/migrate. It gates live operations on real viability:

  • Live migration / hotplug: needs vmRolloutStrategy: LiveUpdate, masquerade networking, migratable storage (RWX), and a target node with the same CPU vendor (you cannot live-migrate Intelβ†’AMD).
  • Disk hotplug: needs the HotplugVolumes feature gate.
  • Snapshots: need a VolumeSnapshotClass (Longhorn CSI).
  • Online expand: needs allowVolumeExpansion: true.

The UI queries GET /api/capabilities and greys out controls the cluster can't support, rather than failing at submit time.

Secrets stay local​

  • Cloud-init passwords are generated per-VM and stored in ~/.local/share/tailvm/registry.json (mode 0600).
  • The Tailscale auth key comes from ~/.config/tailvm/config.yaml or the TS_AUTHKEY environment variable (seeded from Bitwarden by the dotfiles Ansible role).
  • SSH public keys are read from ~/.ssh/id_ed25519.pub (fallbacks: id_rsa.pub, id_ecdsa.pub).
  • No secrets in git, no secrets in cluster state. The on-cluster deployment has no registry β€” it reads everything from cluster objects.

Namespace conventions​

  • Default namespace: tailvm (overridable with --namespace or -n).
  • VM names must match [a-z0-9][a-z0-9-]* (Kubernetes DNS label).
  • On-disk state uses tailvm prefix (~/.local/share/tailvm/, ~/.config/tailvm/) for backward compat with the legacy Python tailvm.

Data flow​

VM create (containerdisk)​

corral create web --kubevirt --image fedora
β”‚
β”œβ”€ catalog.Find("fedora") β†’ containerDisk URI
β”œβ”€ kubevirt.NewClient(ns).CreateVM(opts) β†’ kubectl apply VM manifest
β”‚ β”œβ”€ cloud-init: SSH key + Tailscale auth key + password
β”‚ └─ containerDisk registry URL
└─ registry.Set("web", {backend, namespace, password})

VM create (bootc)​

corral bootc create dev --image quay.io/centos-bootc/...
β”‚
β”œβ”€ kubevirt.BootcBuildDisk() β†’ block PVC + short-lived builder VM
β”‚ β”œβ”€ builder VM: bootc install to-disk (composefs/ostree auto-detected)
β”‚ └─ Progress streams to stderr / task log
β”œβ”€ kubevirt.GenerateBootcVM() β†’ VM manifest (UEFI-boots the disk)
β”œβ”€ kubevirt.Apply(vm) β†’ kubectl apply
└─ registry.Set("dev", {backend, namespace})

Same flow for the web UI, but the build runs as a background task polled at GET /api/tasks/{id} with a live log displayed in the browser.

Web UI SSH​

Browser: click SSH in VM detail panel
β”‚
β”œβ”€ GET /api/vms/{ns}/{name} β†’ get VM info
β”œβ”€ spawn: virtctl ssh {user}@{ns}/{name}
β”‚ └─ xterm.js + WebSocket terminal in browser
└─ (or copy the virtctl ssh command to clipboard)

The web SSH delegates to the browser's ability to open terminal URLs β€” it doesn't proxy SSH through the server.

Web UI console​

Browser: Console tab β†’ VNC or Serial
β”‚
β”œβ”€ VNC: WebSocket β†’ /api/vnc/{ns}/{name}
β”‚ β”œβ”€ server: virtctl vnc --proxy-only β†’ WS bridge
β”‚ └─ browser: noVNC canvas
└─ Serial: WebSocket β†’ /api/tty/{ns}/{name}
β”œβ”€ server: virtctl console β†’ WS bridge
└─ browser: xterm.js terminal

Build & CI​

go build -o corral .          # core binary (no bootc)
go build -tags bootc -o corral . # with bootc pipeline

# Container image (for on-cluster deployment)
docker build -t ghcr.io/tuna-os/corral .
# β†’ Alpine + corral + kubectl + virtctl

# CI (.github/workflows/ci.yml)
# β†’ go test ./..., go vet ./..., docker build, push to ghcr.io

On-cluster deployment​

The deploy/corral-web.yaml manifest:

  1. Creates the tailvm namespace (privileged pod-security)
  2. ServiceAccount + scoped ClusterRole (VM lifecycle, subresources, snapshots, CDI, PVCs, nodes, events, metrics, instancetypes, etc.)
  3. Deployment (ghcr.io/tuna-os/corral:latest, port 8006)
  4. ClusterIP Service (port 80 β†’ 8006)
  5. Tailscale Ingress (ingressClassName: tailscale) β†’ TLS at corral.<tailnet>.ts.net

Tailnet membership is the authentication β€” never bind a public interface. Authorization is an adminGate middleware: CORRAL_ADMINS (tailnet logins) gates mutating requests, with everyone else read-only (see ADR-0003). Unset = single-user/open.

Plugin system​

Plugins are standalone executables named corral-<name>, discovered in ~/.local/share/corral/plugins (and $PATH). When you run corral <name>, the root command checks if <name> is a known subcommand; if not, it dispatches to the plugin. CORRAL_PLUGIN=<name> is exported so plugins know how they were invoked.

The marketplace is the marketplace/index.json file in the repo, fetched from GitHub raw at runtime. Each entry has a name, description, version, and platform-specific download URLs. Installation downloads the binary, sets the executable bit, and places it in the plugin dir.