banger/internal/store/store.go
Thales Maciel ea72ea26fe
Add Go daemon-driven VM control plane
Replace the shell-only user workflow with `banger` and `bangerd`: Cobra commands, XDG/SQLite-backed state, managed VM and image lifecycle, and a Bubble Tea TUI for browsing and operating VMs.\n\nKeep Firecracker orchestration behind the daemon so VM specs become persistent objects, and add repo entrypoints for building, installing, and documenting the new flow while still delegating rootfs customization to the existing shell tooling.\n\nHarden the control plane around real usage by reclaiming Firecracker API sockets for the user, restarting stale daemons after rebuilds, and returning the correct `vm.create` payload so the CLI and TUI creation flow work reliably.\n\nValidation: `go test ./...`, `make build`, and a host-side smoke test with `./banger vm create --name codex-smoke`.
2026-03-16 12:52:54 -03:00

386 lines
9.6 KiB
Go

package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
_ "modernc.org/sqlite"
"banger/internal/model"
)
type Store struct {
db *sql.DB
}
func Open(path string) (*Store, error) {
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, err
}
store := &Store{db: db}
if err := store.migrate(); err != nil {
_ = db.Close()
return nil, err
}
return store, nil
}
func (s *Store) Close() error {
return s.db.Close()
}
func (s *Store) migrate() error {
stmts := []string{
`PRAGMA journal_mode=WAL;`,
`CREATE TABLE IF NOT EXISTS images (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
managed INTEGER NOT NULL DEFAULT 0,
artifact_dir TEXT,
rootfs_path TEXT NOT NULL,
kernel_path TEXT NOT NULL,
initrd_path TEXT,
modules_dir TEXT,
packages_path TEXT,
build_size TEXT,
docker INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);`,
`CREATE TABLE IF NOT EXISTS vms (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
image_id TEXT NOT NULL,
guest_ip TEXT NOT NULL UNIQUE,
state TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_touched_at TEXT NOT NULL,
spec_json TEXT NOT NULL,
runtime_json TEXT NOT NULL,
stats_json TEXT NOT NULL DEFAULT '{}',
FOREIGN KEY(image_id) REFERENCES images(id) ON DELETE RESTRICT
);`,
}
for _, stmt := range stmts {
if _, err := s.db.Exec(stmt); err != nil {
return err
}
}
return nil
}
func (s *Store) UpsertImage(ctx context.Context, image model.Image) error {
const query = `
INSERT INTO images (
id, name, managed, artifact_dir, rootfs_path, kernel_path, initrd_path,
modules_dir, packages_path, build_size, docker, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name=excluded.name,
managed=excluded.managed,
artifact_dir=excluded.artifact_dir,
rootfs_path=excluded.rootfs_path,
kernel_path=excluded.kernel_path,
initrd_path=excluded.initrd_path,
modules_dir=excluded.modules_dir,
packages_path=excluded.packages_path,
build_size=excluded.build_size,
docker=excluded.docker,
updated_at=excluded.updated_at`
_, err := s.db.ExecContext(ctx, query,
image.ID,
image.Name,
boolToInt(image.Managed),
image.ArtifactDir,
image.RootfsPath,
image.KernelPath,
image.InitrdPath,
image.ModulesDir,
image.PackagesPath,
image.BuildSize,
boolToInt(image.Docker),
image.CreatedAt.Format(time.RFC3339),
image.UpdatedAt.Format(time.RFC3339),
)
return err
}
func (s *Store) GetImageByName(ctx context.Context, name string) (model.Image, error) {
return s.getImage(ctx, "SELECT id, name, managed, artifact_dir, rootfs_path, kernel_path, initrd_path, modules_dir, packages_path, build_size, docker, created_at, updated_at FROM images WHERE name = ?", name)
}
func (s *Store) GetImageByID(ctx context.Context, id string) (model.Image, error) {
return s.getImage(ctx, "SELECT id, name, managed, artifact_dir, rootfs_path, kernel_path, initrd_path, modules_dir, packages_path, build_size, docker, created_at, updated_at FROM images WHERE id = ?", id)
}
func (s *Store) ListImages(ctx context.Context) ([]model.Image, error) {
rows, err := s.db.QueryContext(ctx, "SELECT id, name, managed, artifact_dir, rootfs_path, kernel_path, initrd_path, modules_dir, packages_path, build_size, docker, created_at, updated_at FROM images ORDER BY created_at ASC")
if err != nil {
return nil, err
}
defer rows.Close()
var images []model.Image
for rows.Next() {
image, err := scanImage(rows)
if err != nil {
return nil, err
}
images = append(images, image)
}
return images, rows.Err()
}
func (s *Store) DeleteImage(ctx context.Context, id string) error {
_, err := s.db.ExecContext(ctx, "DELETE FROM images WHERE id = ?", id)
return err
}
func (s *Store) UpsertVM(ctx context.Context, vm model.VMRecord) error {
specJSON, err := json.Marshal(vm.Spec)
if err != nil {
return err
}
runtimeJSON, err := json.Marshal(vm.Runtime)
if err != nil {
return err
}
statsJSON, err := json.Marshal(vm.Stats)
if err != nil {
return err
}
const query = `
INSERT INTO vms (
id, name, image_id, guest_ip, state, created_at, updated_at, last_touched_at,
spec_json, runtime_json, stats_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name=excluded.name,
image_id=excluded.image_id,
guest_ip=excluded.guest_ip,
state=excluded.state,
updated_at=excluded.updated_at,
last_touched_at=excluded.last_touched_at,
spec_json=excluded.spec_json,
runtime_json=excluded.runtime_json,
stats_json=excluded.stats_json`
_, err = s.db.ExecContext(ctx, query,
vm.ID,
vm.Name,
vm.ImageID,
vm.Runtime.GuestIP,
string(vm.State),
vm.CreatedAt.Format(time.RFC3339),
vm.UpdatedAt.Format(time.RFC3339),
vm.LastTouchedAt.Format(time.RFC3339),
string(specJSON),
string(runtimeJSON),
string(statsJSON),
)
return err
}
func (s *Store) GetVM(ctx context.Context, idOrName string) (model.VMRecord, error) {
const query = `
SELECT id, name, image_id, guest_ip, state, created_at, updated_at, last_touched_at,
spec_json, runtime_json, stats_json
FROM vms
WHERE id = ? OR name = ?
`
row := s.db.QueryRowContext(ctx, query, idOrName, idOrName)
return scanVMRow(row)
}
func (s *Store) GetVMByID(ctx context.Context, id string) (model.VMRecord, error) {
row := s.db.QueryRowContext(ctx, `
SELECT id, name, image_id, guest_ip, state, created_at, updated_at, last_touched_at,
spec_json, runtime_json, stats_json
FROM vms WHERE id = ?`, id)
return scanVMRow(row)
}
func (s *Store) ListVMs(ctx context.Context) ([]model.VMRecord, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, name, image_id, guest_ip, state, created_at, updated_at, last_touched_at,
spec_json, runtime_json, stats_json
FROM vms ORDER BY created_at ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
var vms []model.VMRecord
for rows.Next() {
vm, err := scanVMRows(rows)
if err != nil {
return nil, err
}
vms = append(vms, vm)
}
return vms, rows.Err()
}
func (s *Store) DeleteVM(ctx context.Context, id string) error {
_, err := s.db.ExecContext(ctx, "DELETE FROM vms WHERE id = ?", id)
return err
}
func (s *Store) FindVMsUsingImage(ctx context.Context, imageID string) ([]model.VMRecord, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, name, image_id, guest_ip, state, created_at, updated_at, last_touched_at,
spec_json, runtime_json, stats_json
FROM vms WHERE image_id = ?`, imageID)
if err != nil {
return nil, err
}
defer rows.Close()
var vms []model.VMRecord
for rows.Next() {
vm, err := scanVMRows(rows)
if err != nil {
return nil, err
}
vms = append(vms, vm)
}
return vms, rows.Err()
}
func (s *Store) NextGuestIP(ctx context.Context, bridgeIPPrefix string) (string, error) {
used := map[string]struct{}{}
rows, err := s.db.QueryContext(ctx, "SELECT guest_ip FROM vms")
if err != nil {
return "", err
}
defer rows.Close()
for rows.Next() {
var ip string
if err := rows.Scan(&ip); err != nil {
return "", err
}
used[ip] = struct{}{}
}
if err := rows.Err(); err != nil {
return "", err
}
for i := 2; i < 255; i++ {
candidate := fmt.Sprintf("%s.%d", bridgeIPPrefix, i)
if _, exists := used[candidate]; !exists {
return candidate, nil
}
}
return "", errors.New("no guest IPs available")
}
func (s *Store) getImage(ctx context.Context, query string, arg string) (model.Image, error) {
row := s.db.QueryRowContext(ctx, query, arg)
return scanImageRow(row)
}
func scanImage(rows scanner) (model.Image, error) {
return scanImageRow(rows)
}
type scanner interface {
Scan(dest ...any) error
}
func scanImageRow(row scanner) (model.Image, error) {
var image model.Image
var managed, docker int
var createdAt, updatedAt string
err := row.Scan(
&image.ID,
&image.Name,
&managed,
&image.ArtifactDir,
&image.RootfsPath,
&image.KernelPath,
&image.InitrdPath,
&image.ModulesDir,
&image.PackagesPath,
&image.BuildSize,
&docker,
&createdAt,
&updatedAt,
)
if err != nil {
return image, err
}
image.Managed = managed == 1
image.Docker = docker == 1
image.CreatedAt, err = time.Parse(time.RFC3339, createdAt)
if err != nil {
return image, err
}
image.UpdatedAt, err = time.Parse(time.RFC3339, updatedAt)
if err != nil {
return image, err
}
return image, nil
}
func scanVMRow(row scanner) (model.VMRecord, error) {
return scanVMInto(row)
}
func scanVMRows(rows scanner) (model.VMRecord, error) {
return scanVMInto(rows)
}
func scanVMInto(row scanner) (model.VMRecord, error) {
var vm model.VMRecord
var state, createdAt, updatedAt, touchedAt, specJSON, runtimeJSON, statsJSON string
err := row.Scan(
&vm.ID,
&vm.Name,
&vm.ImageID,
&vm.Runtime.GuestIP,
&state,
&createdAt,
&updatedAt,
&touchedAt,
&specJSON,
&runtimeJSON,
&statsJSON,
)
if err != nil {
return vm, err
}
vm.State = model.VMState(state)
if err := json.Unmarshal([]byte(specJSON), &vm.Spec); err != nil {
return vm, err
}
if err := json.Unmarshal([]byte(runtimeJSON), &vm.Runtime); err != nil {
return vm, err
}
if statsJSON != "" {
if err := json.Unmarshal([]byte(statsJSON), &vm.Stats); err != nil {
return vm, err
}
}
var parseErr error
vm.CreatedAt, parseErr = time.Parse(time.RFC3339, createdAt)
if parseErr != nil {
return vm, parseErr
}
vm.UpdatedAt, parseErr = time.Parse(time.RFC3339, updatedAt)
if parseErr != nil {
return vm, parseErr
}
vm.LastTouchedAt, parseErr = time.Parse(time.RFC3339, touchedAt)
if parseErr != nil {
return vm, parseErr
}
return vm, nil
}
func boolToInt(value bool) int {
if value {
return 1
}
return 0
}