feat: add metis cli skeleton and inventory schema
This commit is contained in:
parent
26c0cced39
commit
b500241b8c
88
cmd/metis/main.go
Normal file
88
cmd/metis/main.go
Normal file
@ -0,0 +1,88 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"metis/pkg/inventory"
|
||||
"metis/pkg/plan"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
switch os.Args[1] {
|
||||
case "plan":
|
||||
planCmd(os.Args[2:])
|
||||
case "burn":
|
||||
burnCmd(os.Args[2:])
|
||||
default:
|
||||
usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func usage() {
|
||||
fmt.Fprintf(os.Stderr, "Usage: metis <plan|burn> [options]\n")
|
||||
}
|
||||
|
||||
func loadInventory(path string) *inventory.Inventory {
|
||||
inv, err := inventory.Load(path)
|
||||
if err != nil {
|
||||
log.Fatalf("load inventory: %v", err)
|
||||
}
|
||||
return inv
|
||||
}
|
||||
|
||||
func planCmd(args []string) {
|
||||
fs := flag.NewFlagSet("plan", flag.ExitOnError)
|
||||
invPath := fs.String("inventory", "inventory.yaml", "inventory file")
|
||||
node := fs.String("node", "", "target node")
|
||||
device := fs.String("device", "/dev/sdX", "target block device")
|
||||
cache := fs.String("cache", filepath.Join(os.TempDir(), "metis-cache"), "image cache dir")
|
||||
fs.Parse(args)
|
||||
if *node == "" {
|
||||
log.Fatalf("--node is required")
|
||||
}
|
||||
inv := loadInventory(*invPath)
|
||||
p, err := plan.Build(inv, *node, *device, *cache)
|
||||
if err != nil {
|
||||
log.Fatalf("build plan: %v", err)
|
||||
}
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
_ = enc.Encode(p)
|
||||
}
|
||||
|
||||
func burnCmd(args []string) {
|
||||
fs := flag.NewFlagSet("burn", flag.ExitOnError)
|
||||
invPath := fs.String("inventory", "inventory.yaml", "inventory file")
|
||||
node := fs.String("node", "", "target node")
|
||||
device := fs.String("device", "", "target block device (e.g. /dev/sdX)")
|
||||
cache := fs.String("cache", filepath.Join(os.TempDir(), "metis-cache"), "image cache dir")
|
||||
confirm := fs.Bool("yes", false, "actually write to device")
|
||||
fs.Parse(args)
|
||||
if *node == "" || *device == "" {
|
||||
log.Fatalf("--node and --device are required")
|
||||
}
|
||||
inv := loadInventory(*invPath)
|
||||
p, err := plan.Build(inv, *node, *device, *cache)
|
||||
if err != nil {
|
||||
log.Fatalf("build plan: %v", err)
|
||||
}
|
||||
fmt.Printf("Plan for %s to %s:\n", p.Node, p.Device)
|
||||
for _, a := range p.Actions {
|
||||
fmt.Printf("- [%s] %s\n", a.Type, a.Detail)
|
||||
}
|
||||
if !*confirm {
|
||||
fmt.Printf("\nDry run. Re-run with --yes to execute (not yet implemented).\n")
|
||||
return
|
||||
}
|
||||
log.Fatalf("burn execution not yet implemented; follow plan commands manually")
|
||||
}
|
||||
5
go.mod
Normal file
5
go.mod
Normal file
@ -0,0 +1,5 @@
|
||||
module metis
|
||||
|
||||
go 1.22.0
|
||||
|
||||
require gopkg.in/yaml.v3 v3.0.1
|
||||
4
go.sum
Normal file
4
go.sum
Normal file
@ -0,0 +1,4 @@
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
56
inventory.example.yaml
Normal file
56
inventory.example.yaml
Normal file
@ -0,0 +1,56 @@
|
||||
# Example inventory for Metis
|
||||
classes:
|
||||
- name: rpi5-ubuntu-worker
|
||||
arch: arm64
|
||||
os: ubuntu-24.04
|
||||
image: https://harbor.bstein.dev/library/rpi5-ubuntu-worker.img
|
||||
checksum: sha256:REPLACE_ME
|
||||
default_labels:
|
||||
hardware: rpi5
|
||||
node-role.kubernetes.io/worker: "true"
|
||||
default_taints: []
|
||||
- name: rpi4-armbian-longhorn
|
||||
arch: arm64
|
||||
os: armbian-6.6
|
||||
image: https://harbor.bstein.dev/library/rpi4-armbian-longhorn.img
|
||||
checksum: sha256:REPLACE_ME
|
||||
default_labels:
|
||||
hardware: rpi4
|
||||
longhorn: "true"
|
||||
node-role.kubernetes.io/worker: "true"
|
||||
default_taints: []
|
||||
- name: control-plane
|
||||
arch: arm64
|
||||
os: ubuntu-24.04
|
||||
image: https://harbor.bstein.dev/library/rpi5-ubuntu-control.img
|
||||
checksum: sha256:REPLACE_ME
|
||||
default_labels:
|
||||
node-role.kubernetes.io/control-plane: "true"
|
||||
default_taints:
|
||||
- node-role.kubernetes.io/control-plane:NoSchedule
|
||||
|
||||
nodes:
|
||||
- name: titan-04
|
||||
class: rpi5-ubuntu-worker
|
||||
hostname: titan-04
|
||||
ip: 192.168.22.30
|
||||
k3s_role: agent
|
||||
labels:
|
||||
hardware: rpi5
|
||||
ssh_user: ubuntu
|
||||
- name: titan-13
|
||||
class: rpi4-armbian-longhorn
|
||||
hostname: titan-13
|
||||
ip: 192.168.22.41
|
||||
k3s_role: agent
|
||||
labels:
|
||||
hardware: rpi4
|
||||
longhorn: "true"
|
||||
longhorn_disks:
|
||||
- mountpoint: /mnt/astreae
|
||||
uuid: 6031fa8b-f28c-45c3-b7bc-6133300e07c6
|
||||
fs: ext4
|
||||
- mountpoint: /mnt/asteria
|
||||
uuid: cbd4989d-62b5-4741-8b2a-28fdae259cae
|
||||
fs: ext4
|
||||
ssh_user: root
|
||||
85
pkg/inventory/types.go
Normal file
85
pkg/inventory/types.go
Normal file
@ -0,0 +1,85 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Inventory is the root document defining node classes and per-node specs.
|
||||
type Inventory struct {
|
||||
Classes []NodeClass `yaml:"classes"`
|
||||
Nodes []NodeSpec `yaml:"nodes"`
|
||||
}
|
||||
|
||||
// NodeClass defines a reusable image/config for a group of nodes.
|
||||
type NodeClass struct {
|
||||
Name string `yaml:"name"`
|
||||
Arch string `yaml:"arch"`
|
||||
OS string `yaml:"os"`
|
||||
Image string `yaml:"image"`
|
||||
Checksum string `yaml:"checksum,omitempty"`
|
||||
BootloaderNote string `yaml:"bootloader_note,omitempty"`
|
||||
DefaultLabels map[string]string `yaml:"default_labels,omitempty"`
|
||||
DefaultTaints []string `yaml:"default_taints,omitempty"`
|
||||
CloudInit string `yaml:"cloud_init,omitempty"`
|
||||
}
|
||||
|
||||
// NodeSpec captures per-node overrides and identity.
|
||||
type NodeSpec struct {
|
||||
Name string `yaml:"name"`
|
||||
Class string `yaml:"class"`
|
||||
Hostname string `yaml:"hostname"`
|
||||
IP string `yaml:"ip"`
|
||||
MAC string `yaml:"mac,omitempty"`
|
||||
K3sRole string `yaml:"k3s_role"`
|
||||
K3sToken string `yaml:"k3s_token,omitempty"`
|
||||
K3sURL string `yaml:"k3s_url,omitempty"`
|
||||
Labels map[string]string `yaml:"labels,omitempty"`
|
||||
Taints []string `yaml:"taints,omitempty"`
|
||||
LonghornDisks []LonghornDisk `yaml:"longhorn_disks,omitempty"`
|
||||
SSHUser string `yaml:"ssh_user,omitempty"`
|
||||
SSHAuthorized []string `yaml:"ssh_authorized_keys,omitempty"`
|
||||
Notes string `yaml:"notes,omitempty"`
|
||||
}
|
||||
|
||||
// LonghornDisk describes an attached disk to mount for Longhorn.
|
||||
type LonghornDisk struct {
|
||||
Mountpoint string `yaml:"mountpoint"`
|
||||
UUID string `yaml:"uuid"`
|
||||
FS string `yaml:"fs,omitempty"`
|
||||
}
|
||||
|
||||
// Load reads and parses an inventory file.
|
||||
func Load(path string) (*Inventory, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read inventory: %w", err)
|
||||
}
|
||||
var inv Inventory
|
||||
if err := yaml.Unmarshal(data, &inv); err != nil {
|
||||
return nil, fmt.Errorf("parse inventory: %w", err)
|
||||
}
|
||||
return &inv, nil
|
||||
}
|
||||
|
||||
// FindNode returns the node spec and class.
|
||||
func (i *Inventory) FindNode(name string) (*NodeSpec, *NodeClass, error) {
|
||||
var node *NodeSpec
|
||||
for idx := range i.Nodes {
|
||||
if i.Nodes[idx].Name == name {
|
||||
node = &i.Nodes[idx]
|
||||
break
|
||||
}
|
||||
}
|
||||
if node == nil {
|
||||
return nil, nil, fmt.Errorf("node %s not found", name)
|
||||
}
|
||||
for idx := range i.Classes {
|
||||
if i.Classes[idx].Name == node.Class {
|
||||
return node, &i.Classes[idx], nil
|
||||
}
|
||||
}
|
||||
return node, nil, fmt.Errorf("class %s not found for node %s", node.Class, node.Name)
|
||||
}
|
||||
59
pkg/plan/plan.go
Normal file
59
pkg/plan/plan.go
Normal file
@ -0,0 +1,59 @@
|
||||
package plan
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"metis/pkg/inventory"
|
||||
)
|
||||
|
||||
// Action describes a step in the burn process.
|
||||
type Action struct {
|
||||
Type string `json:"type"`
|
||||
Detail string `json:"detail"`
|
||||
Command string `json:"command,omitempty"`
|
||||
}
|
||||
|
||||
// Plan describes the overall burn for a node.
|
||||
type Plan struct {
|
||||
Node string `json:"node"`
|
||||
Device string `json:"device"`
|
||||
Image string `json:"image"`
|
||||
Class string `json:"class"`
|
||||
Actions []Action `json:"actions"`
|
||||
}
|
||||
|
||||
// Build constructs a plan without executing it.
|
||||
func Build(inv *inventory.Inventory, nodeName, device, cacheDir string) (*Plan, error) {
|
||||
node, class, err := inv.FindNode(nodeName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if device == "" {
|
||||
device = "/dev/sdX" // placeholder
|
||||
}
|
||||
cacheImage := filepath.Join(cacheDir, filepath.Base(class.Image))
|
||||
actions := []Action{
|
||||
{Type: "fetch", Detail: fmt.Sprintf("Download %s to %s", class.Image, cacheImage)},
|
||||
}
|
||||
if class.Checksum != "" {
|
||||
actions = append(actions, Action{Type: "verify", Detail: fmt.Sprintf("Verify checksum %s", class.Checksum)})
|
||||
}
|
||||
actions = append(actions, Action{Type: "write", Detail: fmt.Sprintf("Write image to %s", device), Command: fmt.Sprintf("dd if=%s of=%s bs=4M status=progress conv=fsync", cacheImage, device)})
|
||||
actions = append(actions, Action{Type: "inject", Detail: "Inject hostname/network/k3s config into boot or rootfs"})
|
||||
actions = append(actions, Action{Type: "finalize", Detail: fmt.Sprintf("Ready to insert SD for %s", node.Hostname)})
|
||||
|
||||
return &Plan{
|
||||
Node: nodeName,
|
||||
Device: device,
|
||||
Image: class.Image,
|
||||
Class: class.Name,
|
||||
Actions: actions,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NextRunStale returns true if the last success was older than the given duration.
|
||||
func NextRunStale(lastSuccess time.Time, maxAge time.Duration) bool {
|
||||
return time.Since(lastSuccess) > maxAge
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user