network/uisp/main.go
2026-05-09 16:54:28 -05:00

900 lines
24 KiB
Go

package main
import (
"bytes"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"net/http"
"os"
"sort"
"strings"
"text/tabwriter"
"time"
)
const defaultBaseURL = "https://uisp.vntx.net"
type Client struct {
BaseURL string
Token string
HTTP *http.Client
}
func NewClient() (*Client, error) {
tok := os.Getenv("UISP_KEY")
if tok == "" {
tok = os.Getenv("UISP_TOKEN")
}
if tok == "" {
return nil, errors.New("UISP_KEY env var is not set")
}
base := os.Getenv("UISP_URL")
if base == "" {
base = defaultBaseURL
}
return &Client{
BaseURL: strings.TrimRight(base, "/"),
Token: tok,
HTTP: &http.Client{Timeout: 60 * time.Second},
}, nil
}
func (c *Client) do(method, path string, body any, out any) error {
var rdr io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return err
}
rdr = bytes.NewReader(b)
}
url := c.BaseURL + "/nms/api/v2.1" + path
req, err := http.NewRequest(method, url, rdr)
if err != nil {
return err
}
req.Header.Set("X-Auth-Token", c.Token)
req.Header.Set("Accept", "application/json")
if body != nil {
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 {
return fmt.Errorf("%s %s: %s: %s", method, url, resp.Status, strings.TrimSpace(string(data)))
}
if out != nil && len(data) > 0 {
if err := json.Unmarshal(data, out); err != nil {
return fmt.Errorf("decode %s: %w (body: %s)", url, err, truncate(string(data), 200))
}
}
return nil
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}
// Device shapes — UISP returns rich nested JSON; we only model what we use.
type Device struct {
Identification struct {
ID string `json:"id"`
Name string `json:"name"`
Hostname string `json:"hostname"`
DisplayName string `json:"displayName"`
Model string `json:"model"`
ModelName string `json:"modelName"`
Type string `json:"type"`
Category string `json:"category"`
MAC string `json:"mac"`
Authorized bool `json:"authorized"`
FirmwareVersion string `json:"firmwareVersion"`
PlatformID string `json:"platformId"`
PlatformName string `json:"platformName"`
Site *struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"site"`
} `json:"identification"`
Firmware struct {
Current string `json:"current"`
Latest string `json:"latest"`
LatestStatus string `json:"latestStatus"`
Compatible bool `json:"compatible"`
} `json:"firmware"`
Overview struct {
Status string `json:"status"`
Unauthorized bool `json:"unauthorized"`
CanUpgrade bool `json:"canUpgrade"`
} `json:"overview"`
Discovery *struct {
Status string `json:"status"`
Error string `json:"error"`
Protocol string `json:"protocol"`
IsProcessing bool `json:"isProcessing"`
} `json:"discovery"`
}
// Firmware is one entry from /nms/api/v2.1/firmwares.
// UISP wraps everything under "identification".
type Firmware struct {
Identification struct {
ID string `json:"id"`
Version string `json:"version"`
Stable bool `json:"stable"`
Lite bool `json:"lite"`
PlatformID string `json:"platformId"`
Models []string `json:"models"`
Origin string `json:"origin"`
FirmwareCompatibility string `json:"firmwareCompatibility"`
} `json:"identification"`
Semver struct {
Major int `json:"major"`
Minor int `json:"minor"`
Patch int `json:"patch"`
} `json:"semver"`
}
func (c *Client) ListFirmwares() ([]Firmware, error) {
var fws []Firmware
if err := c.do("GET", "/firmwares", nil, &fws); err != nil {
return nil, err
}
return fws, nil
}
// latestFirmwareFor finds the highest-version stable firmware matching the
// device's platformId and model. Returns "" if none found.
func latestFirmwareFor(fws []Firmware, d Device) string {
plat := d.Identification.PlatformID
model := d.Identification.Model
if plat == "" || model == "" {
return ""
}
var best string
for _, fw := range fws {
if fw.Identification.PlatformID != plat {
continue
}
if !modelMatches(fw, model) {
continue
}
v := fw.Identification.Version
if best == "" || versionLess(best, v) {
best = v
}
}
return best
}
func modelMatches(fw Firmware, model string) bool {
if len(fw.Identification.Models) == 0 {
return true // some firmwares apply to a whole platform
}
for _, m := range fw.Identification.Models {
if m == model {
return true
}
}
return false
}
// versionLess compares two firmware version strings (e.g. "6.3.24", "8.7.13").
// Returns true if a < b. Treats trailing non-numeric segments as 0.
func versionLess(a, b string) bool {
ap, bp := parseVersion(a), parseVersion(b)
for i := 0; i < len(ap) || i < len(bp); i++ {
var av, bv int
if i < len(ap) {
av = ap[i]
}
if i < len(bp) {
bv = bp[i]
}
if av != bv {
return av < bv
}
}
return false
}
func parseVersion(v string) []int {
v = strings.TrimPrefix(v, "v")
// Cut at first non-version separator like "-" or "+".
if i := strings.IndexAny(v, "-+ "); i >= 0 {
v = v[:i]
}
parts := strings.Split(v, ".")
out := make([]int, 0, len(parts))
for _, p := range parts {
n := 0
for _, r := range p {
if r < '0' || r > '9' {
break
}
n = n*10 + int(r-'0')
}
out = append(out, n)
}
return out
}
func (c *Client) ListDevices() ([]Device, error) {
var devs []Device
if err := c.do("GET", "/devices?withInterfaces=false", nil, &devs); err != nil {
return nil, err
}
return devs, nil
}
// ListDiscovered returns devices UISP has detected but not yet adopted
// (the "Pending Adoption" list in the UI).
func (c *Client) ListDiscovered() ([]Device, error) {
var devs []Device
if err := c.do("GET", "/devices/discovered", nil, &devs); err != nil {
return nil, err
}
return devs, nil
}
// ConnectUbnt adopts one or more discovered Ubiquiti devices using the
// supplied credentials. UISP attempts to log in with these creds; on success
// the devices appear in /devices and are assignable to a site.
func (c *Client) ConnectUbnt(deviceIDs []string, username, password string, httpsPort int) error {
if httpsPort == 0 {
httpsPort = 443
}
body := map[string]any{
"deviceIds": deviceIDs,
"username": username,
"password": password,
"httpsPort": httpsPort,
}
return c.do("POST", "/discovery/connect/ubnt", body, nil)
}
type Site struct {
ID string `json:"id"`
Identification struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
} `json:"identification"`
Name string `json:"name"`
}
func (c *Client) ListSites() ([]Site, error) {
var sites []Site
if err := c.do("GET", "/sites", nil, &sites); err != nil {
return nil, err
}
return sites, nil
}
func (c *Client) FindSiteByName(name string) (string, error) {
sites, err := c.ListSites()
if err != nil {
return "", err
}
want := strings.ToLower(strings.TrimSpace(name))
for _, s := range sites {
// API returns site name under identification.name; fall back to top-level fields.
n := s.Identification.Name
if n == "" {
n = s.Name
}
if strings.ToLower(n) == want {
id := s.Identification.ID
if id == "" {
id = s.ID
}
return id, nil
}
}
return "", fmt.Errorf("site %q not found", name)
}
// AssignDevicesToSite uses the same /devices/authorize endpoint the UI
// hits after adoption — supplying siteId + deviceIds bulk-assigns them.
func (c *Client) AssignDevicesToSite(deviceIDs []string, siteID string) error {
body := map[string]any{
"siteId": siteID,
"deviceIds": deviceIDs,
}
return c.do("POST", "/devices/authorize", body, nil)
}
// StandardCredentials are the canonical username/password combos used across
// the VNTX fleet. They're tried in order; first 2xx wins.
var StandardCredentials = []struct{ User, Pass string }{
{"ubnt", "fngckewl"},
{"ubnt", "vntx1830"},
{"ubnt", "Vntx1830"},
{"ubnt", "vntx1830vntx"},
}
func (c *Client) SetDeviceCredentials(id, user, pw string) error {
body := map[string]any{"username": user, "password": pw}
return c.do("POST", "/devices/"+id+"/credentials", body, nil)
}
// TryStandardCredentials walks the StandardCredentials list and stops on the
// first one the API accepts (2xx). Returns the user/pw that worked.
func (c *Client) TryStandardCredentials(id string) (string, string, error) {
var lastErr error
for _, kp := range StandardCredentials {
if err := c.SetDeviceCredentials(id, kp.User, kp.Pass); err != nil {
lastErr = err
continue
}
return kp.User, kp.Pass, nil
}
if lastErr == nil {
lastErr = errors.New("no credentials attempted")
}
return "", "", lastErr
}
func (c *Client) UpgradeDevice(id string) error {
return c.do("POST", "/devices/"+id+"/system/upgrade", nil, nil)
}
func dispName(d Device) string {
if d.Identification.Hostname != "" {
return d.Identification.Hostname
}
if d.Identification.Name != "" {
return d.Identification.Name
}
return d.Identification.MAC
}
func cmdList(args []string) error {
fs := flag.NewFlagSet("list", flag.ExitOnError)
unauth := fs.Bool("unauthorized", false, "show only unauthorized devices")
upgradable := fs.Bool("upgradable", false, "show only devices with a firmware update available")
jsonOut := fs.Bool("json", false, "output parsed JSON (struct shape)")
rawOut := fs.Bool("raw", false, "dump raw JSON from the API (use to debug field paths)")
if err := fs.Parse(args); err != nil {
return err
}
c, err := NewClient()
if err != nil {
return err
}
if *rawOut {
req, _ := http.NewRequest("GET", c.BaseURL+"/nms/api/v2.1/devices?withInterfaces=false", nil)
req.Header.Set("X-Auth-Token", c.Token)
req.Header.Set("Accept", "application/json")
resp, err := c.HTTP.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
var raw any
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
return err
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(raw)
}
var devs []Device
if *unauth {
devs, err = c.ListDiscovered()
} else {
devs, err = c.ListDevices()
}
if err != nil {
return err
}
fws, fwErr := c.ListFirmwares()
if fwErr != nil {
fmt.Fprintf(os.Stderr, "warning: could not list firmwares: %v\n", fwErr)
}
filtered := devs[:0]
for _, d := range devs {
if *upgradable && !needsUpgrade(d, fws) {
continue
}
filtered = append(filtered, d)
}
sort.Slice(filtered, func(i, j int) bool { return dispName(filtered[i]) < dispName(filtered[j]) })
if *jsonOut {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(filtered)
}
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "ID\tNAME\tMODEL\tSITE\tFW (cur → latest)\tSTATUS\tAUTH\tUPGRADE?")
for _, d := range filtered {
site := ""
if d.Identification.Site != nil {
site = d.Identification.Site.Name
}
cur := firstNonEmpty(d.Firmware.Current, d.Identification.FirmwareVersion)
latest := firstNonEmpty(d.Firmware.Latest, latestFirmwareFor(fws, d))
fw := cur
if latest != "" && latest != cur && versionLess(cur, latest) {
fw = cur + " → " + latest
}
if fw == "" {
fw = "-"
}
auth := "yes"
if isUnauthorized(d) {
auth = "NO"
}
up := ""
if needsUpgrade(d, fws) {
up = "yes"
}
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
d.Identification.ID, dispName(d), d.Identification.ModelName,
site, fw, d.Overview.Status, auth, up)
}
return tw.Flush()
}
func isUnauthorized(d Device) bool {
return d.Overview.Unauthorized || !d.Identification.Authorized
}
func canUpgrade(d Device) bool {
if d.Overview.CanUpgrade {
return true
}
if d.Firmware.Latest != "" && d.Firmware.Current != "" && d.Firmware.Latest != d.Firmware.Current {
return true
}
return false
}
// needsUpgrade decides whether a device should be upgraded. We require a
// real model and current version, then compare against the /firmwares
// catalog. UISP's overview.canUpgrade flag is unreliable — it stays true
// for blackBox/UNKNOWN devices we can't actually update — so we ignore it
// when there's no catalog answer.
func needsUpgrade(d Device, fws []Firmware) bool {
if d.Identification.Type == "blackBox" {
return false
}
model := d.Identification.Model
if model == "" || model == "UNKNOWN" {
return false
}
cur := firstNonEmpty(d.Firmware.Current, d.Identification.FirmwareVersion)
if cur == "" {
return false
}
latest := firstNonEmpty(d.Firmware.Latest, latestFirmwareFor(fws, d))
if latest == "" {
return false
}
return versionLess(cur, latest)
}
func cmdApprove(args []string) error {
fs := flag.NewFlagSet("approve", flag.ExitOnError)
all := fs.Bool("all", false, "approve all discovered (pending-adoption) devices")
dry := fs.Bool("dry-run", false, "show what would be approved without doing it")
site := fs.String("site", "vntx", "site name to assign devices to (empty = skip)")
port := fs.Int("port", 443, "HTTPS port UISP uses to reach the device")
if err := fs.Parse(args); err != nil {
return err
}
c, err := NewClient()
if err != nil {
return err
}
discovered, err := c.ListDiscovered()
if err != nil {
return fmt.Errorf("listing discovered devices: %w", err)
}
// Build the working set: --all = every discovered device; otherwise the
// IDs passed on the CLI must be in the discovered list.
byID := make(map[string]Device, len(discovered))
for _, d := range discovered {
byID[d.Identification.ID] = d
}
var queue []Device
if *all {
queue = discovered
} else {
ids := fs.Args()
if len(ids) == 0 {
return errors.New("usage: uisp approve <device-id>... | --all")
}
for _, id := range ids {
d, ok := byID[id]
if !ok {
fmt.Fprintf(os.Stderr, "skip %s: not in discovered/pending list\n", id)
continue
}
queue = append(queue, d)
}
}
if len(queue) == 0 {
fmt.Println("no devices pending adoption.")
return nil
}
for _, d := range queue {
fmt.Printf(" pending: %s %s %s\n", d.Identification.ID, dispName(d), d.Identification.ModelName)
}
var siteID string
if *site != "" {
siteID, err = c.FindSiteByName(*site)
if err != nil {
return fmt.Errorf("looking up site %q: %w", *site, err)
}
fmt.Printf("site %q resolved to id %s\n", *site, siteID)
}
if *dry {
fmt.Printf("\ndry-run: would adopt %d device(s) by trying %d credential combo(s)",
len(queue), len(StandardCredentials))
if siteID != "" {
fmt.Printf(", then assign to site %q", *site)
}
fmt.Println()
return nil
}
// Walk the credential list; each pass triggers UISP to retry connect on
// every queued device with the new creds. UISP de-dupes — it will only
// actually reconnect devices that haven't been adopted yet. After each
// pass we poll discovery.isProcessing until everything settles.
queueIDs := make([]string, 0, len(queue))
for _, d := range queue {
queueIDs = append(queueIDs, d.Identification.ID)
}
for i, kp := range StandardCredentials {
fmt.Printf("\n[%d/%d] trying user=%s pw=%s on %d device(s)...\n",
i+1, len(StandardCredentials), kp.User, kp.Pass, len(queueIDs))
if err := c.ConnectUbnt(queueIDs, kp.User, kp.Pass, *port); err != nil {
fmt.Fprintf(os.Stderr, " connect call failed: %v\n", err)
continue
}
statuses, err := waitForDiscoverySettle(c, queueIDs, 60*time.Second)
if err != nil {
fmt.Fprintf(os.Stderr, " poll failed: %v\n", err)
continue
}
// Stop early if every device has either succeeded or hit a state
// that won't change with new credentials.
stillRetryable := false
for _, s := range statuses {
if s.Status == "authentication-failed" || s.Status == "authenticating" || s.Status == "starting" {
stillRetryable = true
break
}
}
if !stillRetryable {
fmt.Println(" no more credential-retryable devices, stopping cred sweep.")
break
}
}
// Final state: re-fetch /devices (where adopted devices live) and the
// discovered list (residual + status). A device is "adopted" if it shows
// up in /devices with a real type (not blackBox) — UISP creates the
// blackBox SNMP record before adoption succeeds.
allDevs, err := c.ListDevices()
if err != nil {
return fmt.Errorf("post-adoption ListDevices: %w", err)
}
devByID := map[string]Device{}
for _, d := range allDevs {
devByID[d.Identification.ID] = d
}
finalDiscovered, _ := c.ListDiscovered()
discByID := map[string]Device{}
for _, d := range finalDiscovered {
discByID[d.Identification.ID] = d
}
var adoptedIDs, stillFailed []string
for _, id := range queueIDs {
d, inFleet := devByID[id]
if inFleet && d.Identification.Type != "blackBox" {
adoptedIDs = append(adoptedIDs, id)
} else {
stillFailed = append(stillFailed, id)
}
}
if len(adoptedIDs) > 0 {
fmt.Printf("\nadopted %d device(s):\n", len(adoptedIDs))
for _, id := range adoptedIDs {
fmt.Printf(" %s %s\n", id, dispName(devByID[id]))
}
}
if len(stillFailed) > 0 {
fmt.Printf("\n%d device(s) still pending:\n", len(stillFailed))
for _, id := range stillFailed {
d := byID[id]
cur := discByID[id]
status, errMsg := "?", ""
if cur.Discovery != nil {
status = cur.Discovery.Status
errMsg = cur.Discovery.Error
}
line := fmt.Sprintf(" %s %-32s status=%s", id, dispName(d), status)
if errMsg != "" {
line += " err=" + errMsg
}
fmt.Println(line)
}
}
if siteID != "" && len(adoptedIDs) > 0 {
fmt.Printf("\nassigning %d adopted device(s) to site %q...\n", len(adoptedIDs), *site)
if err := c.AssignDevicesToSite(adoptedIDs, siteID); err != nil {
return fmt.Errorf("site assign: %w", err)
}
fmt.Println(" done.")
}
return nil
}
// waitForDiscoverySettle polls /devices/discovered for the given IDs until
// none of them have isProcessing=true, or until the timeout fires.
func waitForDiscoverySettle(c *Client, ids []string, timeout time.Duration) (map[string]struct{ Status, Error string }, error) {
want := make(map[string]struct{}, len(ids))
for _, id := range ids {
want[id] = struct{}{}
}
deadline := time.Now().Add(timeout)
for {
discovered, err := c.ListDiscovered()
if err != nil {
return nil, err
}
statuses := map[string]struct{ Status, Error string }{}
stillProcessing := 0
for _, d := range discovered {
if _, ok := want[d.Identification.ID]; !ok {
continue
}
if d.Discovery != nil {
statuses[d.Identification.ID] = struct{ Status, Error string }{d.Discovery.Status, d.Discovery.Error}
if d.Discovery.IsProcessing {
stillProcessing++
}
}
}
if stillProcessing == 0 || time.Now().After(deadline) {
fmt.Printf(" settled (%d still processing at timeout)\n", stillProcessing)
return statuses, nil
}
fmt.Printf(" ...waiting on %d device(s)\n", stillProcessing)
time.Sleep(5 * time.Second)
}
}
func cmdUpgrade(args []string) error {
fs := flag.NewFlagSet("upgrade", flag.ExitOnError)
all := fs.Bool("all", false, "upgrade every device with an available update")
yes := fs.Bool("yes", false, "actually perform upgrades (default is dry-run)")
force := fs.Bool("force", false, "upgrade even if current == latest firmware")
if err := fs.Parse(args); err != nil {
return err
}
c, err := NewClient()
if err != nil {
return err
}
type target struct {
id, name, fw string
}
var targets []target
devs, err := c.ListDevices()
if err != nil {
return err
}
fws, fwErr := c.ListFirmwares()
if fwErr != nil {
fmt.Fprintf(os.Stderr, "warning: could not list firmwares: %v\n", fwErr)
}
byID := make(map[string]Device, len(devs))
for _, d := range devs {
byID[d.Identification.ID] = d
}
makeTarget := func(d Device) target {
cur := firstNonEmpty(d.Firmware.Current, d.Identification.FirmwareVersion)
latest := firstNonEmpty(d.Firmware.Latest, latestFirmwareFor(fws, d))
arrow := cur
if latest != "" && latest != cur {
arrow = cur + " → " + latest
}
return target{id: d.Identification.ID, name: dispName(d), fw: arrow}
}
if *all {
for _, d := range devs {
if !needsUpgrade(d, fws) {
continue
}
targets = append(targets, makeTarget(d))
}
} else {
ids := fs.Args()
if len(ids) == 0 {
return errors.New("usage: uisp upgrade <device-id>... [--yes] [--force] | --all [--yes]")
}
for _, id := range ids {
d, ok := byID[id]
if !ok {
fmt.Fprintf(os.Stderr, "skip %s: device not found\n", id)
continue
}
if !needsUpgrade(d, fws) && !*force {
cur := firstNonEmpty(d.Firmware.Current, d.Identification.FirmwareVersion)
latest := firstNonEmpty(d.Firmware.Latest, latestFirmwareFor(fws, d))
fmt.Fprintf(os.Stderr, "skip %s (%s): already on %s (latest %s) — pass --force to override\n",
id, dispName(d), cur, firstNonEmpty(latest, "?"))
continue
}
targets = append(targets, makeTarget(d))
}
}
if len(targets) == 0 {
fmt.Println("no devices need upgrade.")
return nil
}
for _, t := range targets {
fmt.Printf(" %s %s %s\n", t.id, t.name, t.fw)
}
if !*yes {
fmt.Printf("\ndry-run: %d device(s) would be upgraded. Pass --yes to execute.\n", len(targets))
return nil
}
var failed int
for _, t := range targets {
if err := c.UpgradeDevice(t.id); err != nil {
fmt.Fprintf(os.Stderr, "FAIL %s: %v\n", t.id, err)
failed++
continue
}
fmt.Printf("OK %s upgrade triggered\n", t.id)
}
if failed > 0 {
return fmt.Errorf("%d/%d upgrades failed", failed, len(targets))
}
return nil
}
func cmdCreds(args []string) error {
fs := flag.NewFlagSet("creds", flag.ExitOnError)
all := fs.Bool("all", false, "try standard creds against every device")
user := fs.String("user", "", "single username (overrides standard list)")
pass := fs.String("pass", "", "single password (used with --user)")
if err := fs.Parse(args); err != nil {
return err
}
c, err := NewClient()
if err != nil {
return err
}
var ids []string
if *all {
devs, err := c.ListDevices()
if err != nil {
return err
}
for _, d := range devs {
ids = append(ids, d.Identification.ID)
}
} else {
ids = fs.Args()
if len(ids) == 0 {
return errors.New("usage: uisp creds <device-id>... | --all [--user U --pass P]")
}
}
manual := *user != "" || *pass != ""
if manual && (*user == "" || *pass == "") {
return errors.New("--user and --pass must be used together")
}
for _, id := range ids {
if manual {
if err := c.SetDeviceCredentials(id, *user, *pass); err != nil {
fmt.Fprintf(os.Stderr, "FAIL %s: %v\n", id, err)
continue
}
fmt.Printf("OK %s (user=%s)\n", id, *user)
continue
}
u, p, err := c.TryStandardCredentials(id)
if err != nil {
fmt.Fprintf(os.Stderr, "FAIL %s: %v\n", id, err)
continue
}
fmt.Printf("OK %s (user=%s pw=%s)\n", id, u, p)
}
return nil
}
func usage() {
fmt.Fprint(os.Stderr, `uisp - CLI for UISP at $UISP_URL (default `+defaultBaseURL+`)
Auth: export UISP_KEY=<x-auth-token from UISP user settings>
Commands:
list [--unauthorized] [--upgradable] [--json]
approve <device-id>... | --all [--site vntx] [--try-creds] [--dry-run]
creds <device-id>... | --all [--user U --pass P]
upgrade <device-id>... | --all [--yes] (dry-run unless --yes)
Examples:
uisp list --upgradable
uisp approve --all # approve, assign to "vntx", try standard creds
uisp approve --all --site vntx --dry-run
uisp creds --all # retry standard creds across the fleet
uisp upgrade --all --yes
`)
}
func main() {
if len(os.Args) < 2 {
usage()
os.Exit(2)
}
var err error
switch os.Args[1] {
case "list", "ls":
err = cmdList(os.Args[2:])
case "approve":
err = cmdApprove(os.Args[2:])
case "creds":
err = cmdCreds(os.Args[2:])
case "upgrade":
err = cmdUpgrade(os.Args[2:])
case "-h", "--help", "help":
usage()
return
default:
usage()
os.Exit(2)
}
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}