One-command sandbox: `banger vm run` on a fresh host now Just Works. No prior `banger image pull` or `banger kernel pull` needed. Changes: - Default `default_image_name` flips from "default" to "debian-bookworm" so the golden image is the implicit target when `--image` is omitted. - `CreateVM` resolves the image via a new `findOrAutoPullImage`: try the local store first, and on miss fall back to the embedded imagecat catalog + auto-pull. Emits a vm-create progress stage so the user sees "pulling from image catalog" in the create output. - `resolveKernelInputs` gains context + the same pattern via `readOrAutoPullKernel`: try the local kernelcat, and on miss look up the embedded kernelcat and auto-pull. Fires whenever a bundle's manifest references a kernel the user hasn't pulled yet, not just during image pull — any CreateVM with an image that needs a kernel not yet local will resolve it. - `--image` help text updated on both `vm run` and `vm create`. Six tests cover local-hit-no-pull, auto-pull-on-miss, not-in-catalog error propagation, and a non-ENOENT kernel read error does NOT trigger a misleading "not in catalog" claim. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
178 lines
4.9 KiB
Go
178 lines
4.9 KiB
Go
package config
|
|
|
|
import (
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"crypto/x509"
|
|
"encoding/pem"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
toml "github.com/pelletier/go-toml"
|
|
"golang.org/x/crypto/ssh"
|
|
|
|
"banger/internal/model"
|
|
"banger/internal/paths"
|
|
"banger/internal/system"
|
|
)
|
|
|
|
type fileConfig struct {
|
|
LogLevel string `toml:"log_level"`
|
|
WebListenAddr *string `toml:"web_listen_addr"`
|
|
FirecrackerBin string `toml:"firecracker_bin"`
|
|
SSHKeyPath string `toml:"ssh_key_path"`
|
|
DefaultImageName string `toml:"default_image_name"`
|
|
AutoStopStaleAfter string `toml:"auto_stop_stale_after"`
|
|
StatsPollInterval string `toml:"stats_poll_interval"`
|
|
MetricsPoll string `toml:"metrics_poll_interval"`
|
|
BridgeName string `toml:"bridge_name"`
|
|
BridgeIP string `toml:"bridge_ip"`
|
|
CIDR string `toml:"cidr"`
|
|
TapPoolSize int `toml:"tap_pool_size"`
|
|
DefaultDNS string `toml:"default_dns"`
|
|
}
|
|
|
|
func Load(layout paths.Layout) (model.DaemonConfig, error) {
|
|
cfg := model.DaemonConfig{
|
|
LogLevel: "info",
|
|
WebListenAddr: "127.0.0.1:7777",
|
|
AutoStopStaleAfter: 0,
|
|
StatsPollInterval: model.DefaultStatsPollInterval,
|
|
MetricsPollInterval: model.DefaultMetricsPollInterval,
|
|
BridgeName: model.DefaultBridgeName,
|
|
BridgeIP: model.DefaultBridgeIP,
|
|
CIDR: model.DefaultCIDR,
|
|
TapPoolSize: 4,
|
|
DefaultDNS: model.DefaultDNS,
|
|
DefaultImageName: "debian-bookworm",
|
|
}
|
|
|
|
var file fileConfig
|
|
configPath := filepath.Join(layout.ConfigDir, "config.toml")
|
|
if info, err := os.Stat(configPath); err == nil && !info.IsDir() {
|
|
data, err := os.ReadFile(configPath)
|
|
if err != nil {
|
|
return cfg, err
|
|
}
|
|
if err := toml.Unmarshal(data, &file); err != nil {
|
|
return cfg, err
|
|
}
|
|
} else if err != nil && !os.IsNotExist(err) {
|
|
return cfg, err
|
|
}
|
|
|
|
if value := strings.TrimSpace(file.LogLevel); value != "" {
|
|
cfg.LogLevel = value
|
|
}
|
|
if file.WebListenAddr != nil {
|
|
cfg.WebListenAddr = strings.TrimSpace(*file.WebListenAddr)
|
|
}
|
|
if value := strings.TrimSpace(file.FirecrackerBin); value != "" {
|
|
cfg.FirecrackerBin = value
|
|
} else if path, err := system.LookupExecutable("firecracker"); err == nil {
|
|
cfg.FirecrackerBin = path
|
|
}
|
|
if value := strings.TrimSpace(file.DefaultImageName); value != "" {
|
|
cfg.DefaultImageName = value
|
|
}
|
|
if value := strings.TrimSpace(file.BridgeName); value != "" {
|
|
cfg.BridgeName = value
|
|
}
|
|
if value := strings.TrimSpace(file.BridgeIP); value != "" {
|
|
cfg.BridgeIP = value
|
|
}
|
|
if value := strings.TrimSpace(file.CIDR); value != "" {
|
|
cfg.CIDR = value
|
|
}
|
|
if file.TapPoolSize > 0 {
|
|
cfg.TapPoolSize = file.TapPoolSize
|
|
}
|
|
if value := strings.TrimSpace(file.DefaultDNS); value != "" {
|
|
cfg.DefaultDNS = value
|
|
}
|
|
if value := strings.TrimSpace(file.AutoStopStaleAfter); value != "" {
|
|
duration, err := time.ParseDuration(value)
|
|
if err != nil {
|
|
return cfg, err
|
|
}
|
|
cfg.AutoStopStaleAfter = duration
|
|
}
|
|
if value := strings.TrimSpace(file.StatsPollInterval); value != "" {
|
|
duration, err := time.ParseDuration(value)
|
|
if err != nil {
|
|
return cfg, err
|
|
}
|
|
cfg.StatsPollInterval = duration
|
|
}
|
|
if value := strings.TrimSpace(file.MetricsPoll); value != "" {
|
|
duration, err := time.ParseDuration(value)
|
|
if err != nil {
|
|
return cfg, err
|
|
}
|
|
cfg.MetricsPollInterval = duration
|
|
}
|
|
if value := strings.TrimSpace(os.Getenv("BANGER_LOG_LEVEL")); value != "" {
|
|
cfg.LogLevel = value
|
|
}
|
|
|
|
sshKeyPath, err := resolveSSHKeyPath(layout, file.SSHKeyPath)
|
|
if err != nil {
|
|
return cfg, err
|
|
}
|
|
cfg.SSHKeyPath = sshKeyPath
|
|
return cfg, nil
|
|
}
|
|
|
|
func resolveSSHKeyPath(layout paths.Layout, configured string) (string, error) {
|
|
configured = strings.TrimSpace(configured)
|
|
if configured != "" {
|
|
return configured, nil
|
|
}
|
|
return ensureDefaultSSHKey(filepath.Join(layout.ConfigDir, "ssh", "id_ed25519"))
|
|
}
|
|
|
|
func ensureDefaultSSHKey(path string) (string, error) {
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
|
return "", err
|
|
}
|
|
if _, err := os.Stat(path); err == nil {
|
|
if err := ensurePublicKeyFile(path); err != nil {
|
|
return "", err
|
|
}
|
|
return path, nil
|
|
} else if !os.IsNotExist(err) {
|
|
return "", err
|
|
}
|
|
|
|
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
pkcs8, err := x509.MarshalPKCS8PrivateKey(privateKey)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
privatePEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: pkcs8})
|
|
if err := os.WriteFile(path, privatePEM, 0o600); err != nil {
|
|
return "", err
|
|
}
|
|
if err := ensurePublicKeyFile(path); err != nil {
|
|
return "", err
|
|
}
|
|
return path, nil
|
|
}
|
|
|
|
func ensurePublicKeyFile(privateKeyPath string) error {
|
|
data, err := os.ReadFile(privateKeyPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
signer, err := ssh.ParsePrivateKey(data)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
publicKey := ssh.MarshalAuthorizedKey(signer.PublicKey())
|
|
return os.WriteFile(privateKeyPath+".pub", publicKey, 0o644)
|
|
}
|