- machines.building/room/rack/rack_unit/asset_tag, racks table (height, comment)
- API: POST /machines/{id}/location, POST /locations/import (CSV or JSON, matched by
id/serial/mac/node_name, ?dry=1), GET/POST/DELETE /racks, ?building/?room/?rack filters
- CLI: rollcall location ..., location import, location racks/rack, list -rack/-room/-building
- UI: location column and building > room > rack chips, bulk Set location…, Racks page with
elevation views and CSV import dialog, location in machine detail
- .Node.Building/Room/Rack/Unit/AssetTag in overlay templates and tag match rules
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
803 lines
23 KiB
Go
803 lines
23 KiB
Go
package main
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type client struct {
|
|
base, token string
|
|
http *http.Client
|
|
}
|
|
|
|
func newClient() (*client, error) {
|
|
base, token := os.Getenv("ROLLCALL_URL"), os.Getenv("ROLLCALL_TOKEN")
|
|
if base == "" || token == "" {
|
|
if cfg, err := loadConfig(envOr("ROLLCALL_CONFIG", "/etc/rollcall/config.json")); err == nil {
|
|
if base == "" {
|
|
base = "http://" + strings.Replace(cfg.Listen, "0.0.0.0", "127.0.0.1", 1)
|
|
}
|
|
if token == "" {
|
|
token = cfg.AdminToken
|
|
}
|
|
}
|
|
}
|
|
if base == "" || token == "" {
|
|
return nil, fmt.Errorf("set ROLLCALL_URL and ROLLCALL_TOKEN (or make the config file readable)")
|
|
}
|
|
return &client{base: strings.TrimRight(base, "/"), token: token, http: &http.Client{Timeout: 90 * time.Second}}, nil
|
|
}
|
|
|
|
func (c *client) do(method, path string, body any, out any) error {
|
|
var rd io.Reader
|
|
if body != nil {
|
|
b, _ := json.Marshal(body)
|
|
rd = bytes.NewReader(b)
|
|
}
|
|
req, err := http.NewRequest(method, c.base+path, rd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
data, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode >= 400 {
|
|
var e apiError
|
|
_ = json.Unmarshal(data, &e)
|
|
if e.Error == "" {
|
|
e.Error = string(data)
|
|
}
|
|
return fmt.Errorf("%s: %s", resp.Status, e.Error)
|
|
}
|
|
if out != nil {
|
|
return json.Unmarshal(data, out)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func human(n float64) string {
|
|
units := []string{"B", "K", "M", "G", "T", "P"}
|
|
i := 0
|
|
for n >= 1024 && i < len(units)-1 {
|
|
n /= 1024
|
|
i++
|
|
}
|
|
if i <= 2 {
|
|
return fmt.Sprintf("%.0f%s", n, units[i])
|
|
}
|
|
return fmt.Sprintf("%.1f%s", n, units[i])
|
|
}
|
|
|
|
func runClient(cmd string, args []string) error {
|
|
c, err := newClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
need := func(n int) error {
|
|
if len(args) < n {
|
|
usage()
|
|
}
|
|
return nil
|
|
}
|
|
switch cmd {
|
|
case "list":
|
|
fs := newFlags("list")
|
|
tag := fs.String("tag", "", "only machines with this tag")
|
|
rack := fs.String("rack", "", "only machines in this rack")
|
|
room := fs.String("room", "", "only machines in this room")
|
|
bld := fs.String("building", "", "only machines in this building")
|
|
_ = fs.Parse(args)
|
|
ms, err := c.machines(*tag)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
f := "%-16s %-14s %-10s %-16s %-14s %-22s %-18s %4s %7s %3s %7s %3s %2s %3s %-18s %s\n"
|
|
fmt.Printf(f, "ID", "STATE", "NODE", "PROFILE/OS", "MGMT-IP", "LOCATION", "MODEL", "CPU", "RAM", "DSK", "STORAGE", "NIC", "IB", "GPU", "BMC", "TAGS")
|
|
for _, m := range ms {
|
|
if (*rack != "" && !strings.EqualFold(m.Location.Rack, *rack)) || (*room != "" && !strings.EqualFold(m.Location.Room, *room)) || (*bld != "" && !strings.EqualFold(m.Location.Building, *bld)) {
|
|
continue
|
|
}
|
|
state := m.State
|
|
if m.Job != nil {
|
|
var p obj
|
|
_ = json.Unmarshal(m.Job.Progress, &p)
|
|
state = fmt.Sprintf("%s %.0f%%", m.Job.Kind, num(p["pct"]))
|
|
}
|
|
what := deref(m.Profile)
|
|
if what == "" && m.OS != nil {
|
|
what = *m.OS
|
|
}
|
|
bmc := "-"
|
|
if m.BMC != nil {
|
|
b := *m.BMC
|
|
ok := "FAIL"
|
|
if v, _ := b["configured"].(bool); v {
|
|
ok = "ok"
|
|
}
|
|
bmc = fmt.Sprintf("%s (%s,%s)", str(b["address"]), str(b["source"]), ok)
|
|
}
|
|
model := strings.TrimSpace(deref(m.Manufacturer) + " " + deref(m.Product))
|
|
fmt.Printf(f, m.ID, state, deref(m.NodeName), trunc(what, 16), deref(m.MgmtIP), trunc(m.Location.String(), 22), trunc(model, 18), m.CPUs, human(m.MemoryBytes),
|
|
fmt.Sprint(int(m.Disks)), human(m.StorageBytes), fmt.Sprint(m.NICs), fmt.Sprint(m.IBHCAs), fmt.Sprint(m.GPUs), trunc(bmc, 18), strings.Join(m.Tags, ","))
|
|
}
|
|
case "show":
|
|
_ = need(1)
|
|
fs := flag.NewFlagSet("show", flag.ExitOnError)
|
|
asJSON := fs.Bool("json", false, "raw json")
|
|
_ = fs.Parse(args[1:])
|
|
var d obj
|
|
if err := c.do("GET", "/api/v1/machines/"+args[0], nil, &d); err != nil {
|
|
return err
|
|
}
|
|
if *asJSON {
|
|
b, _ := json.MarshalIndent(d, "", " ")
|
|
fmt.Println(string(b))
|
|
return nil
|
|
}
|
|
printMachine(d)
|
|
case "events":
|
|
_ = need(1)
|
|
var d obj
|
|
if err := c.do("GET", "/api/v1/machines/"+args[0], nil, &d); err != nil {
|
|
return err
|
|
}
|
|
for _, e := range toList(d["events"]) {
|
|
fmt.Printf("%s %-24s %s\n", str(dig(e, "ts")), str(dig(e, "event")), str(dig(e, "detail")))
|
|
}
|
|
case "power":
|
|
_ = need(2)
|
|
ids, err := c.targets(args[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
failed := 0
|
|
for _, id := range ids {
|
|
var d obj
|
|
if err := c.do("POST", "/api/v1/machines/"+id+"/power", obj{"action": args[1]}, &d); err != nil {
|
|
return err
|
|
}
|
|
if len(ids) > 1 {
|
|
fmt.Printf("%s: ", id)
|
|
}
|
|
fmt.Println(str(d["output"]))
|
|
if ok, _ := d["ok"].(bool); !ok {
|
|
failed++
|
|
}
|
|
}
|
|
if failed > 0 {
|
|
return fmt.Errorf("ipmitool failed on %d machine(s)", failed)
|
|
}
|
|
case "bootdev":
|
|
_ = need(2)
|
|
var d obj
|
|
if err := c.do("POST", "/api/v1/machines/"+args[0]+"/bootdev", obj{"device": args[1]}, &d); err != nil {
|
|
return err
|
|
}
|
|
fmt.Println(str(d["output"]))
|
|
case "recommission":
|
|
_ = need(1)
|
|
q := ""
|
|
if len(args) > 1 && args[1] == "-now" {
|
|
q = "?now=1"
|
|
}
|
|
var d obj
|
|
if err := c.do("POST", "/api/v1/machines/"+args[0]+"/recommission"+q, nil, &d); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("%s -> %s\n", str(d["id"]), str(d["state"]))
|
|
if d["ipmi"] != nil {
|
|
fmt.Println(str(d["ipmi"]))
|
|
}
|
|
case "delete":
|
|
_ = need(1)
|
|
var d obj
|
|
if err := c.do("DELETE", "/api/v1/machines/"+args[0], nil, &d); err != nil {
|
|
return err
|
|
}
|
|
fmt.Println("deleted", str(d["deleted"]))
|
|
case "deploy", "release", "jobs", "job":
|
|
return c.deployCmd(cmd, args)
|
|
case "tag", "tags":
|
|
return c.tagCmd(cmd, args)
|
|
case "location":
|
|
return c.locationCmd(args)
|
|
default:
|
|
return c.statelessCmd(cmd, args)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *client) machines(tag string) ([]Summary, error) {
|
|
q := ""
|
|
if tag != "" {
|
|
q = "?tag=" + tag
|
|
}
|
|
var ms []Summary
|
|
err := c.do("GET", "/api/v1/machines"+q, nil, &ms)
|
|
return ms, err
|
|
}
|
|
|
|
// targets expands "-tag NAME" (passed as a single "tag:NAME" or "-tag=NAME" token) or returns the id.
|
|
func (c *client) targets(arg string) ([]string, error) {
|
|
tag := ""
|
|
switch {
|
|
case strings.HasPrefix(arg, "tag:"):
|
|
tag = strings.TrimPrefix(arg, "tag:")
|
|
case strings.HasPrefix(arg, "-tag="):
|
|
tag = strings.TrimPrefix(arg, "-tag=")
|
|
default:
|
|
return []string{arg}, nil
|
|
}
|
|
ms, err := c.machines(tag)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(ms) == 0 {
|
|
return nil, fmt.Errorf("no machine has tag %s", tag)
|
|
}
|
|
var ids []string
|
|
for _, m := range ms {
|
|
ids = append(ids, m.ID)
|
|
}
|
|
return ids, nil
|
|
}
|
|
|
|
func (c *client) tagCmd(cmd string, args []string) error {
|
|
if cmd == "tag" {
|
|
if len(args) < 2 {
|
|
usage()
|
|
}
|
|
req := obj{"add": []string{}, "remove": []string{}}
|
|
var add, rm []string
|
|
for _, t := range args[1:] {
|
|
switch {
|
|
case strings.HasPrefix(t, "+"):
|
|
add = append(add, t[1:])
|
|
case strings.HasPrefix(t, "-"):
|
|
rm = append(rm, t[1:])
|
|
default:
|
|
add = append(add, t)
|
|
}
|
|
}
|
|
req["add"], req["remove"] = add, rm
|
|
var d obj
|
|
if err := c.do("POST", "/api/v1/machines/"+args[0]+"/tags", req, &d); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("%s: manual=%v auto=%v\n", str(d["id"]), d["tags"], d["auto_tags"])
|
|
return nil
|
|
}
|
|
if len(args) < 1 {
|
|
usage()
|
|
}
|
|
switch args[0] {
|
|
case "list":
|
|
var defs []TagDef
|
|
if err := c.do("GET", "/api/v1/tags", nil, &defs); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("%-16s %5s %-32s %-16s %-8s %s\n", "TAG", "MACH", "MATCH", "OVERLAYS", "CLOUDCFG", "COMMENT")
|
|
for _, t := range defs {
|
|
cc := "-"
|
|
if t.CloudConfig != "" {
|
|
cc = fmt.Sprintf("%d B", len(t.CloudConfig))
|
|
}
|
|
fmt.Printf("%-16s %5d %-32s %-16s %-8s %s\n", t.Name, t.Machines, trunc(strings.ReplaceAll(t.Match, "\n", " "), 32), strings.Join(t.Overlays, ","), cc, t.Comment)
|
|
}
|
|
case "show":
|
|
if len(args) < 2 {
|
|
usage()
|
|
}
|
|
var defs []TagDef
|
|
if err := c.do("GET", "/api/v1/tags", nil, &defs); err != nil {
|
|
return err
|
|
}
|
|
for _, t := range defs {
|
|
if t.Name == args[1] {
|
|
fmt.Printf("tag %s (%d machines)\ncomment: %s\nmatch: %s\noverlays: %v\n--- cloud_config ---\n%s\n", t.Name, t.Machines, t.Comment, t.Match, t.Overlays, t.CloudConfig)
|
|
return nil
|
|
}
|
|
}
|
|
return errNotFound
|
|
case "add":
|
|
if len(args) < 2 {
|
|
usage()
|
|
}
|
|
fs := newFlags("tags add")
|
|
match := fs.String("match", "", "Go template over .Node that renders true, e.g. '{{ gt .Node.GPUs 0 }}'")
|
|
ov := fs.String("overlays", "", "comma-separated overlays for stateless nodes with this tag")
|
|
cc := fs.String("cloud-config", "", "file with a #cloud-config or #!/bin/sh fragment applied at deployment")
|
|
comment := fs.String("comment", "", "comment")
|
|
_ = fs.Parse(args[2:])
|
|
t := TagDef{Name: args[1], Match: *match, Comment: *comment}
|
|
if *ov != "" {
|
|
t.Overlays = strings.Split(*ov, ",")
|
|
}
|
|
if *cc != "" {
|
|
b, err := os.ReadFile(*cc)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
t.CloudConfig = string(b)
|
|
}
|
|
var out TagDef
|
|
if err := c.do("POST", "/api/v1/tags", t, &out); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("tag %s saved\n", out.Name)
|
|
case "rm":
|
|
if len(args) < 2 {
|
|
usage()
|
|
}
|
|
var d obj
|
|
if err := c.do("DELETE", "/api/v1/tags/"+args[1], nil, &d); err != nil {
|
|
return err
|
|
}
|
|
fmt.Println("deleted", str(d["deleted"]))
|
|
default:
|
|
usage()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *client) deployCmd(cmd string, args []string) error {
|
|
switch cmd {
|
|
case "deploy":
|
|
if len(args) < 2 {
|
|
usage()
|
|
}
|
|
fs := newFlags("deploy")
|
|
ver := fs.String("version", "", "image version (default: current)")
|
|
disk := fs.String("disk", "", "target disk, e.g. /dev/sda (default: smallest disk that fits)")
|
|
host := fs.String("hostname", "", "hostname (default: existing node name or next free)")
|
|
upgrade := fs.Bool("upgrade", false, "cloud-init package_upgrade on first boot")
|
|
pkgs := fs.String("packages", "", "comma-separated packages to install on first boot")
|
|
ud := fs.String("user-data", "", "file with a complete #cloud-config to use instead of the generated one")
|
|
boot := fs.Bool("boot", false, "set bootdev pxe and power-cycle via BMC")
|
|
watch := fs.Bool("watch", false, "follow job progress")
|
|
_ = fs.Parse(args[2:])
|
|
req := obj{"os": args[1], "version": *ver, "disk": *disk, "hostname": *host, "package_upgrade": *upgrade, "boot": *boot}
|
|
if *pkgs != "" {
|
|
req["packages"] = strings.Split(*pkgs, ",")
|
|
}
|
|
if *ud != "" {
|
|
b, err := os.ReadFile(*ud)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req["user_data"] = string(b)
|
|
}
|
|
ids, err := c.targets(args[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(ids) > 1 && (*host != "" || *disk != "") {
|
|
return fmt.Errorf("-hostname/-disk cannot be used with a tag target")
|
|
}
|
|
for _, id := range ids {
|
|
var d obj
|
|
if err := c.do("POST", "/api/v1/machines/"+id+"/deploy", req, &d); err != nil {
|
|
if len(ids) == 1 {
|
|
return err
|
|
}
|
|
fmt.Printf("%s: error: %v\n", id, err)
|
|
continue
|
|
}
|
|
fmt.Printf("%s: job %s %s/%s -> %s hostname=%s mgmt=%s\n", str(d["id"]), str(d["job"]), str(d["os"]), str(d["version"]), str(d["disk"]), str(d["hostname"]), str(d["mgmt_ip"]))
|
|
if d["ipmi"] != nil {
|
|
fmt.Println(str(d["ipmi"]))
|
|
}
|
|
}
|
|
if *watch && len(ids) == 1 {
|
|
return c.watchJob(ids[0])
|
|
}
|
|
case "release":
|
|
if len(args) < 1 {
|
|
usage()
|
|
}
|
|
fs := newFlags("release")
|
|
boot := fs.Bool("boot", false, "set bootdev pxe and power-cycle via BMC")
|
|
wipe := fs.String("wipe", "quick", "quick (signatures + first/last 64 MiB) or full")
|
|
watch := fs.Bool("watch", false, "follow job progress")
|
|
_ = fs.Parse(args[1:])
|
|
var d obj
|
|
if err := c.do("POST", "/api/v1/machines/"+args[0]+"/release", obj{"boot": *boot, "wipe": *wipe}, &d); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("%s: job %s wipe %s\n", str(d["id"]), str(d["job"]), str(d["disk"]))
|
|
if d["ipmi"] != nil {
|
|
fmt.Println(str(d["ipmi"]))
|
|
}
|
|
if *watch {
|
|
return c.watchJob(args[0])
|
|
}
|
|
case "jobs":
|
|
if len(args) < 1 {
|
|
usage()
|
|
}
|
|
if len(args) > 1 && args[1] == "-watch" {
|
|
return c.watchJob(args[0])
|
|
}
|
|
var jobs []Job
|
|
if err := c.do("GET", "/api/v1/machines/"+args[0]+"/jobs", nil, &jobs); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("%-10s %-8s %-10s %-20s %-20s %s\n", "JOB", "KIND", "STATE", "CREATED", "FINISHED", "OS / PROGRESS / ERROR")
|
|
for _, j := range jobs {
|
|
sp := j.spec()
|
|
var p obj
|
|
_ = json.Unmarshal(j.Progress, &p)
|
|
fin := ""
|
|
if j.Finished != nil {
|
|
fin = j.Finished.Local().Format("2006-01-02 15:04:05")
|
|
}
|
|
extra := sp.OS + "/" + sp.Version + " " + sp.Disk
|
|
if j.State == "running" {
|
|
extra += fmt.Sprintf(" %s %.0f%% %s", str(p["phase"]), num(p["pct"]), str(p["msg"]))
|
|
}
|
|
if j.Error != nil {
|
|
extra += " " + *j.Error
|
|
}
|
|
fmt.Printf("%-10s %-8s %-10s %-20s %-20s %s\n", j.ID, j.Kind, j.State, j.Created.Local().Format("2006-01-02 15:04:05"), fin, extra)
|
|
}
|
|
case "job":
|
|
if len(args) < 2 || args[0] != "cancel" {
|
|
usage()
|
|
}
|
|
var d obj
|
|
if err := c.do("POST", "/api/v1/machines/"+args[1]+"/jobs/cancel", nil, &d); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("job %s cancelled\n", str(d["job"]))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *client) watchJob(id string) error {
|
|
last := ""
|
|
for {
|
|
var jobs []Job
|
|
if err := c.do("GET", "/api/v1/machines/"+id+"/jobs", nil, &jobs); err != nil {
|
|
return err
|
|
}
|
|
if len(jobs) == 0 {
|
|
return fmt.Errorf("no jobs")
|
|
}
|
|
j := jobs[0]
|
|
var p obj
|
|
_ = json.Unmarshal(j.Progress, &p)
|
|
line := fmt.Sprintf("%s %s %-9s %-12s %3.0f%% %s", time.Now().Format("15:04:05"), j.ID, j.State, str(p["phase"]), num(p["pct"]), str(p["msg"]))
|
|
if line[9:] != last {
|
|
fmt.Println(line)
|
|
last = line[9:]
|
|
}
|
|
if j.State != "pending" && j.State != "running" {
|
|
if j.Error != nil {
|
|
return fmt.Errorf("%s", *j.Error)
|
|
}
|
|
return nil
|
|
}
|
|
time.Sleep(3 * time.Second)
|
|
}
|
|
}
|
|
|
|
func (c *client) statelessCmd(cmd string, args []string) error {
|
|
switch cmd {
|
|
case "images":
|
|
var imgs []obj
|
|
if err := c.do("GET", "/api/v1/images", nil, &imgs); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("%-24s %-9s %10s %10s %10s %s\n", "IMAGE", "COMPLETE", "KERNEL", "INITRD", "SQUASHFS", "BUILT")
|
|
for _, i := range imgs {
|
|
fmt.Printf("%-24s %-9v %10s %10s %10s %s\n", str(i["name"]), i["complete"], human(num(i["vmlinuz"])),
|
|
human(num(i["initrd.img"])), human(num(i["filesystem.squashfs"])), str(i["built"]))
|
|
}
|
|
case "profile":
|
|
if len(args) < 1 {
|
|
usage()
|
|
}
|
|
switch args[0] {
|
|
case "list":
|
|
var ps []Profile
|
|
if err := c.do("GET", "/api/v1/profiles", nil, &ps); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("%-14s %-22s %-24s %-12s %s\n", "PROFILE", "IMAGE", "OVERLAYS", "NAMES", "APPEND")
|
|
for _, p := range ps {
|
|
fmt.Printf("%-14s %-22s %-24s %-12s %s\n", p.Name, p.Image, strings.Join(p.Overlays, ","), p.NameFormat, p.Append)
|
|
}
|
|
case "add":
|
|
if len(args) < 2 {
|
|
usage()
|
|
}
|
|
fs := flag.NewFlagSet("profile add", flag.ExitOnError)
|
|
img := fs.String("image", "", "image directory name (required)")
|
|
app := fs.String("append", "", "extra kernel parameters")
|
|
ov := fs.String("overlays", "", "comma-separated overlay names")
|
|
nf := fs.String("name-format", "node%03d", "node name format")
|
|
vars := fs.String("vars", "{}", "profile variables as JSON")
|
|
_ = fs.Parse(args[2:])
|
|
p := Profile{Name: args[1], Image: *img, Append: *app, NameFormat: *nf}
|
|
if *ov != "" {
|
|
p.Overlays = strings.Split(*ov, ",")
|
|
}
|
|
if err := json.Unmarshal([]byte(*vars), &p.Vars); err != nil {
|
|
return fmt.Errorf("-vars: %w", err)
|
|
}
|
|
var out Profile
|
|
if err := c.do("POST", "/api/v1/profiles", p, &out); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("profile %s: image=%s overlays=%v\n", out.Name, out.Image, out.Overlays)
|
|
case "rm":
|
|
if len(args) < 2 {
|
|
usage()
|
|
}
|
|
var d obj
|
|
if err := c.do("DELETE", "/api/v1/profiles/"+args[1], nil, &d); err != nil {
|
|
return err
|
|
}
|
|
fmt.Println("deleted", str(d["deleted"]))
|
|
default:
|
|
usage()
|
|
}
|
|
case "assign":
|
|
if len(args) < 2 {
|
|
usage()
|
|
}
|
|
fs := flag.NewFlagSet("assign", flag.ExitOnError)
|
|
name := fs.String("name", "", "node name (default: next free from the profile's name format)")
|
|
boot := fs.Bool("boot", false, "set bootdev pxe and power-cycle via BMC")
|
|
_ = fs.Parse(args[2:])
|
|
var d obj
|
|
if err := c.do("POST", "/api/v1/machines/"+args[0]+"/assign", obj{"profile": args[1], "name": *name, "boot": *boot}, &d); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("%s -> %s as %s mgmt=%s ib=%s\n", str(d["id"]), str(d["profile"]), str(d["name"]), str(d["mgmt_ip"]), str(d["ib_ip"]))
|
|
if d["ipmi"] != nil {
|
|
fmt.Println(str(d["ipmi"]))
|
|
}
|
|
case "unassign":
|
|
if len(args) < 1 {
|
|
usage()
|
|
}
|
|
var d obj
|
|
if err := c.do("POST", "/api/v1/machines/"+args[0]+"/unassign", nil, &d); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("%s -> %s\n", str(d["id"]), str(d["state"]))
|
|
case "overlay":
|
|
if len(args) < 1 {
|
|
usage()
|
|
}
|
|
fs := flag.NewFlagSet("overlay", flag.ExitOnError)
|
|
stage := fs.String("stage", "system", "system or runtime")
|
|
extract := fs.String("extract", "", "extract into this directory instead of listing")
|
|
_ = fs.Parse(args[1:])
|
|
data, err := c.raw("GET", "/api/v1/machines/"+args[0]+"/overlay?stage="+*stage)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tr := tar.NewReader(bytes.NewReader(data))
|
|
for {
|
|
h, err := tr.Next()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if *extract == "" {
|
|
fmt.Printf("%s %8d %s\n", os.FileMode(h.Mode).String(), h.Size, h.Name)
|
|
continue
|
|
}
|
|
dst := filepath.Join(*extract, h.Name)
|
|
if h.Typeflag == tar.TypeDir {
|
|
_ = os.MkdirAll(dst, 0o755)
|
|
continue
|
|
}
|
|
_ = os.MkdirAll(filepath.Dir(dst), 0o755)
|
|
f, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(h.Mode))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, _ = io.Copy(f, tr)
|
|
f.Close()
|
|
}
|
|
case "render":
|
|
if len(args) < 2 {
|
|
usage()
|
|
}
|
|
fs := flag.NewFlagSet("render", flag.ExitOnError)
|
|
stage := fs.String("stage", "system", "system or runtime")
|
|
_ = fs.Parse(args[2:])
|
|
data, err := c.raw("GET", "/api/v1/render/"+args[0]+"/"+strings.TrimPrefix(args[1], "/")+"?stage="+*stage)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
os.Stdout.Write(data)
|
|
default:
|
|
usage()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func readFileArg(p string) ([]byte, error) {
|
|
if p == "-" {
|
|
return io.ReadAll(os.Stdin)
|
|
}
|
|
return os.ReadFile(p)
|
|
}
|
|
|
|
func (c *client) rawBody(method, path, ctype string, body []byte) ([]byte, error) {
|
|
req, err := http.NewRequest(method, c.base+path, bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
|
req.Header.Set("Content-Type", ctype)
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
data, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode >= 400 {
|
|
return nil, fmt.Errorf("%s: %s", resp.Status, strings.TrimSpace(string(data)))
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
func (c *client) raw(method, path string) ([]byte, error) {
|
|
req, err := http.NewRequest(method, c.base+path, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
data, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode >= 400 {
|
|
return nil, fmt.Errorf("%s: %s", resp.Status, strings.TrimSpace(string(data)))
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
func trunc(s string, n int) string {
|
|
if len(s) > n {
|
|
return s[:n]
|
|
}
|
|
return s
|
|
}
|
|
|
|
func toList(v any) []any {
|
|
l, _ := v.([]any)
|
|
return l
|
|
}
|
|
|
|
func printMachine(d obj) {
|
|
s, _ := d["summary"].(obj)
|
|
inv, _ := d["inventory"].(obj)
|
|
sys, _ := inv["system"].(obj)
|
|
cpu, _ := inv["cpu"].(obj)
|
|
mem, _ := inv["memory"].(obj)
|
|
st, _ := inv["storage"].(obj)
|
|
fmt.Printf("%s state=%s hostname=%s\n", str(s["id"]), str(s["state"]), str(s["hostname"]))
|
|
fmt.Printf(" %s %s S/N %s UUID %s\n", str(sys["system-manufacturer"]), str(sys["system-product-name"]),
|
|
str(sys["system-serial-number"]), str(sys["system-uuid"]))
|
|
fmt.Printf(" BIOS %s %s (%s) board %s\n", str(sys["bios-vendor"]), str(sys["bios-version"]),
|
|
str(sys["bios-release-date"]), str(sys["baseboard-product-name"]))
|
|
fmt.Printf(" CPU: %s sockets=%s cores/socket=%s threads=%s numa=%s\n", str(cpu["Model name"]), str(cpu["Socket(s)"]),
|
|
str(cpu["Core(s) per socket"]), str(cpu["CPU(s)"]), str(cpu["NUMA node(s)"]))
|
|
fmt.Printf(" RAM: %s DIMMs %s/%s\n", human(num(mem["total_bytes"])), str(mem["dimms_populated"]), str(mem["dimm_slots"]))
|
|
for _, dv := range toList(mem["dimms"]) {
|
|
if d, _ := dv.(obj); d["populated"] == true {
|
|
speed := str(d["configured_speed"])
|
|
if speed == "" {
|
|
speed = str(d["speed"])
|
|
}
|
|
fmt.Printf(" %-14s %-8s %s %s %s %s\n", str(d["locator"]), str(d["size"]), str(d["type"]), speed,
|
|
str(d["manufacturer"]), str(d["part_number"]))
|
|
}
|
|
}
|
|
fmt.Printf(" Storage: %s disks, %s\n", str(st["disk_count"]), human(num(st["total_bytes"])))
|
|
for _, dv := range toList(st["disks"]) {
|
|
d, _ := dv.(obj)
|
|
kind := "hdd"
|
|
if d["rota"] == false {
|
|
kind = "ssd"
|
|
}
|
|
fmt.Printf(" %-10s %8s %-5s %-4s %s S/N %s\n", str(d["name"]), human(num(d["size"])), str(d["tran"]), kind,
|
|
str(d["model"]), str(d["serial"]))
|
|
}
|
|
for _, cv := range toList(st["controllers"]) {
|
|
c, _ := cv.(obj)
|
|
fmt.Printf(" ctrl %s %s %s [%s]\n", str(c["slot"]), str(c["vendor"]), str(c["device"]), str(c["driver"]))
|
|
}
|
|
fmt.Println(" NICs:")
|
|
for _, nv := range toList(inv["network"]) {
|
|
n, _ := nv.(obj)
|
|
var ips []string
|
|
for _, ip := range toList(n["ipv4"]) {
|
|
ips = append(ips, str(ip))
|
|
}
|
|
fmt.Printf(" %-10s %s %-10s %-10s link=%v fw=%s pci=%s %s\n", str(n["name"]), str(n["mac"]), str(n["driver"]),
|
|
str(n["speed"]), n["link"], str(n["firmware_version"]), str(n["pci"]), strings.Join(ips, ","))
|
|
}
|
|
if hcas := toList(dig(inv, "infiniband", "hcas")); len(hcas) > 0 {
|
|
fmt.Println(" InfiniBand:")
|
|
for _, hv := range hcas {
|
|
h, _ := hv.(obj)
|
|
for _, pv := range toList(h["ports"]) {
|
|
p, _ := pv.(obj)
|
|
fmt.Printf(" %s port%s %s %s %s guid=%s fw=%s %s\n", str(h["name"]), str(p["port"]), str(p["link_layer"]),
|
|
str(p["state"]), str(p["rate"]), str(h["node_guid"]), str(h["fw_ver"]), str(h["board_id"]))
|
|
}
|
|
}
|
|
}
|
|
if g := toList(inv["gpus"]); len(g) > 0 {
|
|
fmt.Println(" GPUs/accelerators:")
|
|
for _, gv := range g {
|
|
x, _ := gv.(obj)
|
|
fmt.Printf(" %s %s %s [%s]\n", str(x["slot"]), str(x["vendor"]), str(x["device"]), str(x["driver"]))
|
|
}
|
|
}
|
|
if lldp, ok := inv["lldp"].(obj); ok && len(lldp) > 0 {
|
|
fmt.Println(" LLDP neighbors:")
|
|
for ifn, ents := range lldp {
|
|
for _, e := range asList(ents) {
|
|
eo, _ := e.(obj)
|
|
ch, _ := eo["chassis"].(obj)
|
|
name := "?"
|
|
for k := range ch {
|
|
name = k
|
|
}
|
|
fmt.Printf(" %s: %s port %s\n", ifn, name, str(dig(eo, "port", "id", "value")))
|
|
}
|
|
}
|
|
}
|
|
b, _ := d["bmc"].(obj)
|
|
fmt.Printf(" BMC: source=%s address=%s user=%s configured=%v verified_oob=%v secret_stored=%v\n", str(b["source"]),
|
|
str(b["address"]), str(b["user"]), b["configured"], b["verified_oob"], d["has_bmc_secret"])
|
|
if b["lan"] != nil {
|
|
lb, _ := json.Marshal(b["lan"])
|
|
fmt.Printf(" lan: %s\n", lb)
|
|
}
|
|
if errs := toList(b["errors"]); len(errs) > 0 {
|
|
fmt.Printf(" errors: %v\n", errs)
|
|
}
|
|
if errs := toList(inv["errors"]); len(errs) > 0 {
|
|
fmt.Printf(" agent errors: %v\n", errs)
|
|
}
|
|
var evs []string
|
|
for _, e := range toList(d["events"]) {
|
|
evs = append(evs, str(dig(e, "event")))
|
|
}
|
|
fmt.Printf(" events: %s\n", strings.Join(evs, "; "))
|
|
}
|
|
|
|
func asList(v any) []any {
|
|
if l, ok := v.([]any); ok {
|
|
return l
|
|
}
|
|
return []any{v}
|
|
}
|