Files
rollcall/cmd/rollcall/client.go
T
Ilker ManapandClaude Opus 5.5 53c05bb44d
CI / agent (push) Successful in 3s
CI / site (push) Successful in 2s
CI / go (push) Successful in 1m30s
Admin client certificates as an alternative to the admin token
rollcall admin-cert request NAME makes a key locally and sends a CSR; the
region signs admin:NAME (client auth) and records the serial; a verified,
recorded, unrevoked certificate is an admin. CLI/console read it from
client.json, a .p12 is written for the browser; the web UI no longer insists
on a token.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 17:44:06 +01:00

983 lines
28 KiB
Go

package main
import (
"archive/tar"
"bytes"
"crypto/tls"
"crypto/x509"
"encoding/json"
"flag"
"fmt"
"io"
"net"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
type client struct {
base, token string
http *http.Client
ca string
}
// clientTLS: the region's CA from the server config (root on the region), the
// client config (trusted on first connect), or ROLLCALL_CA; else the system roots.
func clientTLS(caPEM string) *tls.Config {
pem := []byte(caPEM)
if f := os.Getenv("ROLLCALL_CA"); f != "" {
if b, err := os.ReadFile(f); err == nil {
pem = b
}
}
if len(pem) == 0 {
if cfg, err := loadConfig(envOr("ROLLCALL_CONFIG", "/etc/rollcall/config.json")); err == nil {
pem, _ = os.ReadFile(filepath.Join(cfg.CADir, "ca.pem"))
}
}
tc := &tls.Config{MinVersion: tls.VersionTLS12}
if cert := clientCertificate(); cert != nil {
tc.Certificates = []tls.Certificate{*cert}
}
if len(pem) > 0 {
pool, err := x509.SystemCertPool()
if err != nil || pool == nil {
pool = x509.NewCertPool()
}
pool.AppendCertsFromPEM(pem)
tc.RootCAs = pool
}
return tc
}
type httpTransport = http.Transport
func newHTTPClient(caPEM string) *http.Client {
return &http.Client{Timeout: 90 * time.Second, Transport: &http.Transport{TLSClientConfig: clientTLS(caPEM), Proxy: http.ProxyFromEnvironment}}
}
func newClient() (*client, error) {
base, token := os.Getenv("ROLLCALL_URL"), os.Getenv("ROLLCALL_TOKEN")
caPEM := ""
if base == "" || token == "" {
if cfg, err := loadConfig(envOr("ROLLCALL_CONFIG", "/etc/rollcall/config.json")); err == nil {
if base == "" {
base = "http://" + localListen(cfg.Listen)
}
if token == "" {
token = cfg.AdminToken
}
}
}
if base == "" || token == "" {
if cc, ok := loadClientConf(); ok {
if base == "" {
base = cc.URL
}
if token == "" {
token = cc.Token
}
caPEM = cc.CA
}
}
if base == "" || (token == "" && clientCertificate() == nil) {
return nil, fmt.Errorf("set ROLLCALL_URL and ROLLCALL_TOKEN (or an admin certificate: rollcall admin-cert request), make the server config readable, or run rollcall in a terminal to connect")
}
if caPEM == "" {
if cc, ok := loadClientConf(); ok && strings.TrimRight(cc.URL, "/") == strings.TrimRight(base, "/") {
caPEM = cc.CA
}
}
return &client{base: strings.TrimRight(base, "/"), token: token, http: newHTTPClient(caPEM), ca: caPEM}, 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
}
if c.token != "" {
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)
case "bmc":
return c.bmcCmd(args)
case "fabric", "vlan", "subnet", "range", "ip", "layout", "net", "controllers", "controller", "dhcp", "ipam":
return c.netCmd(cmd, args)
case "admin-cert":
return c.adminCertCmd(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\nlayout: %s\n--- cloud_config ---\n%s\n", t.Name, t.Machines, t.Comment, t.Match, t.Overlays, orDash(t.NetLayout), 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")
layout := fs.String("layout", "", "network layout for machines with this tag")
comment := fs.String("comment", "", "comment")
_ = fs.Parse(args[2:])
t := TagDef{Name: args[1], Match: *match, Comment: *comment, NetLayout: *layout}
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
}
if c.token != "" {
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
}
if c.token != "" {
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}
}
func (c *client) bmcCmd(args []string) error {
if len(args) < 1 {
usage()
}
if args[0] == "drivers" {
var ds []powerDriver
if err := c.do("GET", "/api/v1/power-drivers", nil, &ds); err != nil {
return err
}
for _, d := range ds {
fmt.Printf("%-11s %-8s %s\n", d.Name, d.Family, d.Description)
}
return nil
}
fs := newFlags("bmc")
driver := fs.String("driver", "", "power driver (rollcall bmc drivers)")
address := fs.String("address", "", "BMC address (host, IP or https://host:port)")
port := fs.Int("port", 0, "port (0 = the driver's default)")
user := fs.String("user", "", "account (proxmox: token id user@realm!name)")
password := fs.String("password", "", "password (proxmox: token secret); - reads it from stdin")
verify := fs.Bool("verify", false, "check with a status call before saving")
probe := fs.Bool("probe", false, "re-run Redfish detection with the stored account")
var sets multiFlag
fs.Var(&sets, "set", "driver setting KEY=VALUE, repeatable (system, verify_tls, cipher, privilege, interface, node, vmid); KEY= removes it")
_ = fs.Parse(args[1:])
id := args[0]
req := obj{}
set := func(k, v string) {
if v != "" {
req[k] = v
}
}
set("driver", *driver)
set("address", *address)
set("user", *user)
if *port != 0 {
req["port"] = *port
}
if *password == "-" {
b, err := io.ReadAll(os.Stdin)
if err != nil {
return err
}
req["password"] = strings.TrimRight(string(b), "\r\n")
} else if *password != "" {
req["password"] = *password
}
if len(sets) > 0 {
ex := map[string]string{}
for _, s := range sets {
k, v, _ := strings.Cut(s, "=")
ex[k] = v
}
req["extra"] = ex
}
var d obj
switch {
case *probe:
if err := c.do("POST", "/api/v1/machines/"+id+"/bmc", obj{"probe": true}, &d); err != nil {
return err
}
case len(req) == 0:
if err := c.do("GET", "/api/v1/machines/"+id+"/bmc", nil, &d); err != nil {
return err
}
default:
req["verify"] = *verify
if err := c.do("POST", "/api/v1/machines/"+id+"/bmc", req, &d); err != nil {
return err
}
}
b, _ := d["bmc"].(obj)
drv := str(b["driver"])
if drv == "" {
drv = "ipmi"
}
fmt.Printf("%s driver %s address %s port %s user %s source %s%s\n", id, drv, str(b["address"]), orDash(str(b["port"])), str(b["user"]), str(b["source"]),
map[bool]string{true: " (set by operator)", false: ""}[b["driver_set"] == true])
if ex, ok := b["extra"].(obj); ok && len(ex) > 0 {
var kv []string
for k, v := range ex {
kv = append(kv, k+"="+str(v))
}
sort.Strings(kv)
fmt.Printf(" settings: %s\n", strings.Join(kv, " "))
}
if rf, ok := b["redfish"].(obj); ok {
fmt.Printf(" redfish %s · %s %s · %s\n", str(rf["redfish"]), str(rf["manufacturer"]), str(rf["model"]), str(rf["system"]))
}
if out := str(d["output"]); out != "" {
fmt.Println(" " + out)
}
return nil
}
// localListen turns a listen address into one this host can dial.
func localListen(listen string) string {
host, port, err := net.SplitHostPort(listen)
if err != nil {
return listen
}
switch host {
case "", "0.0.0.0":
host = "127.0.0.1"
case "::":
host = "::1"
}
return net.JoinHostPort(host, port)
}