ipam.provider infoblox: one record:host per address, named by its DNS
name and configured for DNS in dns_view ("-" = IPAM only); ownership is
the rollcall_machine extensible attribute, changed with extattrs+ so a
host's other attributes stay. Anything else on the address (a host, a
lease, a fixed address, a DHCP or reserved range, network/broadcast) makes
it taken. Delegated networks carry the rollcall attribute. WAPI paging.
The reconcile no longer compares fields an authority does not keep
(Infoblox has no prefix length or status on a host). Built from the WAPI
reference and tested against a fake WAPI; no real grid yet.
Closes #11
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1146 lines
34 KiB
Go
1146 lines
34 KiB
Go
package main
|
|
|
|
// HTTP API and CLI for the network model and rack controllers.
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/netip"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// ---------------------------------------------------------------- HTTP
|
|
|
|
func (a *App) netChanged(r *http.Request) {
|
|
a.reloadNet(r.Context())
|
|
a.hub.bump()
|
|
}
|
|
|
|
func (a *App) handleFabrics(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == http.MethodGet {
|
|
fs, err := a.store.Fabrics(r.Context())
|
|
if err != nil {
|
|
fail(w, 500, err)
|
|
return
|
|
}
|
|
writeJSON(w, 200, fs)
|
|
return
|
|
}
|
|
var f Fabric
|
|
if err := json.NewDecoder(r.Body).Decode(&f); err != nil || !tagNameRe.MatchString(f.Name) {
|
|
fail(w, 400, errors.New("name is required: lowercase letters, digits, . _ -"))
|
|
return
|
|
}
|
|
if f.Kind != "" && f.Kind != "ethernet" && f.Kind != "infiniband" {
|
|
fail(w, 400, errors.New("kind is ethernet or infiniband"))
|
|
return
|
|
}
|
|
if err := a.store.PutFabric(r.Context(), f); err != nil {
|
|
fail(w, 500, err)
|
|
return
|
|
}
|
|
a.netChanged(r)
|
|
writeJSON(w, 200, f)
|
|
}
|
|
|
|
func (a *App) handleFabricDelete(w http.ResponseWriter, r *http.Request) {
|
|
if err := a.store.DeleteFabric(r.Context(), r.PathValue("name")); err != nil {
|
|
fail(w, 500, err)
|
|
return
|
|
}
|
|
a.netChanged(r)
|
|
writeJSON(w, 200, obj{"deleted": r.PathValue("name")})
|
|
}
|
|
|
|
func (a *App) handleVLANs(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == http.MethodGet {
|
|
vs, err := a.store.VLANs(r.Context())
|
|
if err != nil {
|
|
fail(w, 500, err)
|
|
return
|
|
}
|
|
writeJSON(w, 200, vs)
|
|
return
|
|
}
|
|
var v VLAN
|
|
if err := json.NewDecoder(r.Body).Decode(&v); err != nil || v.Fabric == "" || v.VID < 0 || v.VID > 4094 && v.VID < 0x8000 {
|
|
fail(w, 400, errors.New("fabric and vid (0 untagged, 1-4094, or an IB pkey) are required"))
|
|
return
|
|
}
|
|
if v.DHCPMode == "proxy" {
|
|
v.DHCP = true
|
|
} else if v.DHCPMode != "" && v.DHCPMode != "serve" {
|
|
fail(w, 400, fmt.Errorf("dhcp_mode %q: serve or proxy", v.DHCPMode))
|
|
return
|
|
}
|
|
if v.DHCP && v.PrimaryRack == "" {
|
|
v.PrimaryRack = a.cfg.RackName
|
|
}
|
|
racks := a.racksSnapshot()
|
|
for _, rk := range []string{v.PrimaryRack, v.SecondaryRack} {
|
|
if _, ok := racks[rk]; rk != "" && !ok {
|
|
fail(w, 400, fmt.Errorf("rack controller %s is not registered", rk))
|
|
return
|
|
}
|
|
}
|
|
id, err := a.store.PutVLAN(r.Context(), v)
|
|
if err != nil {
|
|
fail(w, 500, err)
|
|
return
|
|
}
|
|
v.ID = id
|
|
a.netChanged(r)
|
|
writeJSON(w, 200, v)
|
|
}
|
|
|
|
func (a *App) handleVLANDelete(w http.ResponseWriter, r *http.Request) {
|
|
vid, _ := strconv.Atoi(r.PathValue("vid"))
|
|
if err := a.store.DeleteVLAN(r.Context(), r.PathValue("fabric"), vid); err != nil {
|
|
fail(w, 500, err)
|
|
return
|
|
}
|
|
a.netChanged(r)
|
|
writeJSON(w, 200, obj{"deleted": fmt.Sprintf("%s/%d", r.PathValue("fabric"), vid)})
|
|
}
|
|
|
|
func (a *App) handleSubnets(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == http.MethodGet {
|
|
ss, err := a.store.Subnets(r.Context())
|
|
if err != nil {
|
|
fail(w, 500, err)
|
|
return
|
|
}
|
|
writeJSON(w, 200, ss)
|
|
return
|
|
}
|
|
var sn Subnet
|
|
if err := json.NewDecoder(r.Body).Decode(&sn); err != nil {
|
|
fail(w, 400, err)
|
|
return
|
|
}
|
|
_, n, err := net.ParseCIDR(sn.CIDR)
|
|
if err != nil {
|
|
fail(w, 400, errors.New("cidr must be a network, e.g. 10.0.0.0/24 or fd00:1::/64"))
|
|
return
|
|
}
|
|
sn.CIDR = n.String()
|
|
if familyOf(sn.CIDR) == 6 {
|
|
if ones, _ := n.Mask.Size(); ones > 126 {
|
|
fail(w, 400, errors.New("an IPv6 subnet needs room: /126 or shorter (/64 is usual)"))
|
|
return
|
|
}
|
|
}
|
|
if sn.Gateway != "" {
|
|
gw, err := netip.ParseAddr(sn.Gateway)
|
|
if err != nil || !sn.contains(gw.String()) {
|
|
fail(w, 400, fmt.Errorf("gateway %q is not an address in %s", sn.Gateway, sn.CIDR))
|
|
return
|
|
}
|
|
sn.Gateway = gw.String()
|
|
}
|
|
sn.Domain = strings.ToLower(strings.TrimSuffix(sn.Domain, "."))
|
|
if sn.Domain != "" && !validDomain(sn.Domain) {
|
|
fail(w, 400, fmt.Errorf("domain %q is not a DNS name", sn.Domain))
|
|
return
|
|
}
|
|
switch sn.Purpose {
|
|
case "", "provisioning", "mgmt", "bmc", "storage", "ib", "other":
|
|
default:
|
|
fail(w, 400, errors.New("purpose is provisioning, mgmt, bmc, storage, ib or other"))
|
|
return
|
|
}
|
|
if sn.Fabric != "" && sn.VLANID == 0 {
|
|
vs, _ := a.store.VLANs(r.Context())
|
|
for _, v := range vs {
|
|
if v.Fabric == sn.Fabric && v.VID == sn.VID {
|
|
sn.VLANID = v.ID
|
|
}
|
|
}
|
|
if sn.VLANID == 0 { // a subnet on a VLAN not declared yet: create it
|
|
fs, _ := a.store.Fabrics(r.Context())
|
|
known := false
|
|
for _, f := range fs {
|
|
known = known || f.Name == sn.Fabric
|
|
}
|
|
if !known {
|
|
fail(w, 400, fmt.Errorf("fabric %s does not exist", sn.Fabric))
|
|
return
|
|
}
|
|
if sn.VLANID, err = a.store.PutVLAN(r.Context(), VLAN{Fabric: sn.Fabric, VID: sn.VID}); err != nil {
|
|
fail(w, 500, err)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
for _, other := range a.subnetsSnapshot() {
|
|
if other.CIDR == sn.CIDR {
|
|
continue
|
|
}
|
|
if _, on, err := net.ParseCIDR(other.CIDR); err == nil && (on.Contains(n.IP) || n.Contains(on.IP)) {
|
|
fail(w, 409, fmt.Errorf("%s overlaps %s", sn.CIDR, other.CIDR))
|
|
return
|
|
}
|
|
}
|
|
if err := a.store.PutSubnet(r.Context(), sn); err != nil {
|
|
fail(w, 500, err)
|
|
return
|
|
}
|
|
a.adoptIPs(r.Context())
|
|
a.netChanged(r)
|
|
writeJSON(w, 200, sn)
|
|
}
|
|
|
|
func (a *App) handleSubnetDelete(w http.ResponseWriter, r *http.Request) {
|
|
cidr := r.PathValue("cidr")
|
|
if ips, _ := a.store.IPs(r.Context(), cidr, ""); len(ips) > 0 && r.URL.Query().Get("force") != "1" {
|
|
fail(w, 409, fmt.Errorf("%s still has %d address(es) handed out (force=1 drops them)", cidr, len(ips)))
|
|
return
|
|
}
|
|
if err := a.store.DeleteSubnet(r.Context(), cidr); err != nil {
|
|
fail(w, 500, err)
|
|
return
|
|
}
|
|
a.netChanged(r)
|
|
writeJSON(w, 200, obj{"deleted": cidr})
|
|
}
|
|
|
|
func (a *App) handleRanges(w http.ResponseWriter, r *http.Request) {
|
|
var rg IPRange
|
|
if err := json.NewDecoder(r.Body).Decode(&rg); err != nil {
|
|
fail(w, 400, err)
|
|
return
|
|
}
|
|
if rg.Kind != "dynamic" && rg.Kind != "reserved" && rg.Kind != "static" {
|
|
fail(w, 400, errors.New("kind is dynamic, reserved or static"))
|
|
return
|
|
}
|
|
sn := subnetByRef(a.subnetsSnapshot(), rg.Subnet)
|
|
if sn == nil {
|
|
fail(w, 404, fmt.Errorf("subnet %s: %w", rg.Subnet, errSubnetUnknown))
|
|
return
|
|
}
|
|
lo, ok1 := parseAddr(rg.Start)
|
|
hi, ok2 := parseAddr(rg.End)
|
|
if !ok1 || !ok2 || hi.Less(lo) || !sn.contains(rg.Start) || !sn.contains(rg.End) {
|
|
fail(w, 400, fmt.Errorf("range %s-%s is not inside %s", rg.Start, rg.End, sn.CIDR))
|
|
return
|
|
}
|
|
rg.Start, rg.End = lo.String(), hi.String()
|
|
for _, o := range sn.Ranges {
|
|
ol, _ := parseAddr(o.Start)
|
|
oh, _ := parseAddr(o.End)
|
|
if !oh.Less(lo) && !hi.Less(ol) {
|
|
fail(w, 409, fmt.Errorf("overlaps the %s range %s-%s", o.Kind, o.Start, o.End))
|
|
return
|
|
}
|
|
}
|
|
rg.Subnet = sn.CIDR
|
|
id, err := a.store.AddRange(r.Context(), rg)
|
|
if err != nil {
|
|
fail(w, 500, err)
|
|
return
|
|
}
|
|
rg.ID = id
|
|
a.netChanged(r)
|
|
writeJSON(w, 200, rg)
|
|
}
|
|
|
|
func (a *App) handleRangeDelete(w http.ResponseWriter, r *http.Request) {
|
|
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
if err := a.store.DeleteRange(r.Context(), id); err != nil {
|
|
fail(w, 500, err)
|
|
return
|
|
}
|
|
a.netChanged(r)
|
|
writeJSON(w, 200, obj{"deleted": id})
|
|
}
|
|
|
|
func (a *App) handleIPs(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
ips, err := a.store.IPs(r.Context(), r.URL.Query().Get("subnet"), r.URL.Query().Get("machine"))
|
|
if err != nil {
|
|
fail(w, 500, err)
|
|
return
|
|
}
|
|
writeJSON(w, 200, ips)
|
|
case http.MethodPost: // reserve one address (a switch, a PDU, a VIP)
|
|
var x IPAddr
|
|
if err := json.NewDecoder(r.Body).Decode(&x); err != nil {
|
|
fail(w, 400, err)
|
|
return
|
|
}
|
|
ip, ok := parseAddr(x.IP)
|
|
if !ok || strings.Contains(x.IP, "/") {
|
|
fail(w, 400, fmt.Errorf("%q is not an address", x.IP))
|
|
return
|
|
}
|
|
x.IP = ip.String() // one spelling: the table's key, NetBox's and DNS's
|
|
if strings.IndexFunc(x.Hostname, func(c rune) bool { return c < ' ' || c == 0x7f }) >= 0 || len(x.Hostname) > 200 {
|
|
fail(w, 400, errors.New("hostname: one line of text"))
|
|
return
|
|
}
|
|
sn := subnetFor(a.subnetsSnapshot(), x.IP)
|
|
if sn == nil {
|
|
fail(w, 404, fmt.Errorf("%s is in no subnet", x.IP))
|
|
return
|
|
}
|
|
ok, err := a.claimIP(r.Context(), IPAddr{IP: x.IP, Subnet: sn.CIDR, Kind: "reserved", Hostname: x.Hostname})
|
|
if err != nil {
|
|
fail(w, 500, err)
|
|
return
|
|
}
|
|
if !ok {
|
|
fail(w, 409, fmt.Errorf("%s is taken", x.IP))
|
|
return
|
|
}
|
|
a.netChanged(r)
|
|
writeJSON(w, 200, obj{"reserved": x.IP, "subnet": sn.CIDR})
|
|
}
|
|
}
|
|
|
|
func (a *App) handleIPDelete(w http.ResponseWriter, r *http.Request) {
|
|
if err := a.store.ReleaseIP(r.Context(), r.PathValue("ip")); err != nil {
|
|
fail(w, 500, err)
|
|
return
|
|
}
|
|
a.kickIPAM()
|
|
a.netChanged(r)
|
|
writeJSON(w, 200, obj{"released": r.PathValue("ip")})
|
|
}
|
|
|
|
func validLayout(l NetLayout) error {
|
|
if !tagNameRe.MatchString(l.Name) {
|
|
return errors.New("name is required: lowercase letters, digits, . _ -")
|
|
}
|
|
if len(l.Interfaces) == 0 {
|
|
return errors.New("a layout needs at least one interface")
|
|
}
|
|
names := map[string]bool{}
|
|
for _, i := range l.Interfaces {
|
|
switch i.Type {
|
|
case "", "physical":
|
|
case "bond":
|
|
if len(i.Members) == 0 {
|
|
return fmt.Errorf("bond %s has no members", i.Name)
|
|
}
|
|
case "vlan":
|
|
if !names[i.Parent] || i.VID < 1 || i.VID > 4094 {
|
|
return fmt.Errorf("vlan %q needs a parent defined above it and a vid 1-4094", i.Name)
|
|
}
|
|
default:
|
|
return fmt.Errorf("interface type %q: physical, bond or vlan", i.Type)
|
|
}
|
|
for _, lk := range i.Links {
|
|
switch lk.Mode {
|
|
case "", "auto", "static", "dhcp", "link_up":
|
|
default:
|
|
return fmt.Errorf("link mode %q: auto, static, dhcp or link_up", lk.Mode)
|
|
}
|
|
}
|
|
n := i.Name
|
|
if n == "" && i.Type == "vlan" {
|
|
n = fmt.Sprintf("%s.%d", i.Parent, i.VID)
|
|
}
|
|
names[n] = true
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *App) handleLayouts(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == http.MethodGet {
|
|
ls, err := a.store.Layouts(r.Context())
|
|
if err != nil {
|
|
fail(w, 500, err)
|
|
return
|
|
}
|
|
ms, _ := a.store.List(r.Context())
|
|
for i := range ls {
|
|
for _, m := range ms {
|
|
if l, _ := a.layoutFor(r.Context(), m); l != nil && strings.HasPrefix(l.Name, ls[i].Name) {
|
|
ls[i].Machines++
|
|
}
|
|
}
|
|
}
|
|
writeJSON(w, 200, ls)
|
|
return
|
|
}
|
|
var l NetLayout
|
|
if err := json.NewDecoder(r.Body).Decode(&l); err != nil {
|
|
fail(w, 400, err)
|
|
return
|
|
}
|
|
if err := validLayout(l); err != nil {
|
|
fail(w, 400, err)
|
|
return
|
|
}
|
|
if err := a.store.PutLayout(r.Context(), l); err != nil {
|
|
fail(w, 500, err)
|
|
return
|
|
}
|
|
writeJSON(w, 200, l)
|
|
}
|
|
|
|
func (a *App) handleLayoutDelete(w http.ResponseWriter, r *http.Request) {
|
|
if err := a.store.DeleteLayout(r.Context(), r.PathValue("name")); err != nil {
|
|
fail(w, 409, err)
|
|
return
|
|
}
|
|
writeJSON(w, 200, obj{"deleted": r.PathValue("name")})
|
|
}
|
|
|
|
// handleMachineNetwork: GET shows the plan and the network-config it renders
|
|
// (without handing out addresses); POST sets the layout and pinned addresses.
|
|
func (a *App) handleMachineNetwork(w http.ResponseWriter, r *http.Request) {
|
|
m, err := a.store.Get(r.Context(), r.PathValue("id"))
|
|
if err != nil {
|
|
fail(w, 500, err)
|
|
return
|
|
}
|
|
if r.Method == http.MethodPost {
|
|
var req struct {
|
|
Layout *string `json:"layout"`
|
|
Rack *string `json:"rack_controller"`
|
|
Static []struct{ Iface, IP string }
|
|
Unstatic []string `json:"unstatic"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
fail(w, 400, err)
|
|
return
|
|
}
|
|
if req.Layout != nil {
|
|
if *req.Layout != "" {
|
|
ls, _ := a.store.Layouts(r.Context())
|
|
found := false
|
|
for _, l := range ls {
|
|
found = found || l.Name == *req.Layout
|
|
}
|
|
if !found {
|
|
fail(w, 404, fmt.Errorf("layout %s does not exist", *req.Layout))
|
|
return
|
|
}
|
|
}
|
|
if err := a.store.SetMachineLayout(r.Context(), m.ID, *req.Layout); err != nil {
|
|
fail(w, 500, err)
|
|
return
|
|
}
|
|
}
|
|
if req.Rack != nil {
|
|
if _, ok := a.racksSnapshot()[*req.Rack]; *req.Rack != "" && !ok {
|
|
fail(w, 404, fmt.Errorf("rack controller %s is not registered", *req.Rack))
|
|
return
|
|
}
|
|
if err := a.store.SetRackController(r.Context(), m.ID, *req.Rack); err != nil {
|
|
fail(w, 500, err)
|
|
return
|
|
}
|
|
m, _ = a.store.Get(r.Context(), m.ID)
|
|
_ = a.writePXEAny(r.Context(), m) // its PXE file moves to the new rack
|
|
}
|
|
for _, ip := range req.Unstatic {
|
|
have, _ := a.store.IPs(r.Context(), "", m.ID)
|
|
for _, x := range have {
|
|
if x.IP == ip && x.Kind == "static" {
|
|
_ = a.store.ReleaseIP(r.Context(), ip)
|
|
a.kickIPAM()
|
|
}
|
|
}
|
|
}
|
|
for _, st := range req.Static {
|
|
sn := subnetFor(a.subnetsSnapshot(), st.IP)
|
|
if sn == nil {
|
|
fail(w, 400, fmt.Errorf("%s is in no subnet", st.IP))
|
|
return
|
|
}
|
|
have, _ := a.store.IPs(r.Context(), sn.CIDR, m.ID)
|
|
for _, x := range have { // one pinned address per interface and subnet
|
|
if x.Iface == st.Iface && x.IP != st.IP {
|
|
_ = a.store.ReleaseIP(r.Context(), x.IP)
|
|
}
|
|
}
|
|
if ok, err := a.claimIP(r.Context(), IPAddr{IP: st.IP, Subnet: sn.CIDR, MachineID: m.ID, Iface: st.Iface, Kind: "static", Hostname: deref(m.NodeName)}); err != nil {
|
|
fail(w, 500, err)
|
|
return
|
|
} else if !ok {
|
|
cur, _ := a.store.IPs(r.Context(), sn.CIDR, m.ID)
|
|
mine := false
|
|
for _, x := range cur {
|
|
mine = mine || x.IP == st.IP
|
|
}
|
|
if !mine {
|
|
fail(w, 409, fmt.Errorf("%s is taken", st.IP))
|
|
return
|
|
}
|
|
_, _ = a.store.pool.Exec(r.Context(), `UPDATE ip_addresses SET kind='static', iface=$2 WHERE ip=$1`, st.IP, st.Iface)
|
|
}
|
|
}
|
|
_ = a.store.AddEvent(r.Context(), m.ID, "network", fmt.Sprintf("layout=%v static=%v unstatic=%v", deref(req.Layout), req.Static, req.Unstatic))
|
|
a.netChanged(r)
|
|
m, _ = a.store.Get(r.Context(), m.ID)
|
|
}
|
|
plan, err := a.netPlan(r.Context(), m, false)
|
|
if err != nil {
|
|
fail(w, 409, err)
|
|
return
|
|
}
|
|
ips, _ := a.store.IPs(r.Context(), "", m.ID)
|
|
writeJSON(w, 200, obj{"id": m.ID, "layout": plan.Layout, "plan": plan, "network_config": networkConfig(plan), "addresses": ips,
|
|
"rack_controller": a.rackOf(m), "modeled": len(a.subnetsSnapshot()) > 0})
|
|
}
|
|
|
|
// ---------------------------------------------------------------- CLI
|
|
|
|
func (c *client) netCmd(cmd string, args []string) error {
|
|
need := func(n int) {
|
|
if len(args) < n {
|
|
usage()
|
|
}
|
|
}
|
|
sub := ""
|
|
if len(args) > 0 {
|
|
sub = args[0]
|
|
}
|
|
switch cmd {
|
|
case "fabric":
|
|
switch sub {
|
|
case "", "list":
|
|
var fs []Fabric
|
|
if err := c.do("GET", "/api/v1/fabrics", nil, &fs); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("%-16s %-11s %s\n", "FABRIC", "KIND", "COMMENT")
|
|
for _, f := range fs {
|
|
fmt.Printf("%-16s %-11s %s\n", f.Name, f.Kind, f.Comment)
|
|
}
|
|
case "add":
|
|
need(2)
|
|
fs := newFlags("fabric add")
|
|
kind := fs.String("kind", "ethernet", "ethernet | infiniband")
|
|
comment := fs.String("comment", "", "comment")
|
|
_ = fs.Parse(args[2:])
|
|
return c.do("POST", "/api/v1/fabrics", Fabric{Name: args[1], Kind: *kind, Comment: *comment}, nil)
|
|
case "rm":
|
|
need(2)
|
|
return c.do("DELETE", "/api/v1/fabrics/"+args[1], nil, nil)
|
|
default:
|
|
usage()
|
|
}
|
|
case "vlan":
|
|
switch sub {
|
|
case "", "list":
|
|
var vs []VLAN
|
|
if err := c.do("GET", "/api/v1/vlans", nil, &vs); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("%-14s %5s %-14s %5s %-6s %-14s %s\n", "FABRIC", "VID", "NAME", "MTU", "DHCP", "RACK", "SECONDARY")
|
|
for _, v := range vs {
|
|
fmt.Printf("%-14s %5d %-14s %5d %-6s %-14s %s\n", v.Fabric, v.VID, v.Name, v.MTU, orDash(v.DHCPMode), v.PrimaryRack, v.SecondaryRack)
|
|
}
|
|
case "add", "set":
|
|
need(3)
|
|
fs := newFlags("vlan add")
|
|
name := fs.String("name", "", "name")
|
|
mtu := fs.Int("mtu", 1500, "MTU")
|
|
dhcp := fs.Bool("dhcp", false, "serve DHCP/PXE on this VLAN")
|
|
proxy := fs.Bool("proxy-dhcp", false, "another DHCP server hands out addresses here; only answer PXE (proxy DHCP)")
|
|
rack := fs.String("rack", "", "rack controller serving it (default: the region's embedded one)")
|
|
sec := fs.String("secondary", "", "second rack controller (the dynamic range is split)")
|
|
_ = fs.Parse(args[3:])
|
|
vid, _ := strconv.Atoi(args[2])
|
|
var v VLAN
|
|
mode := ""
|
|
if *proxy {
|
|
mode = "proxy"
|
|
}
|
|
if err := c.do("POST", "/api/v1/vlans", VLAN{Fabric: args[1], VID: vid, Name: *name, MTU: *mtu, DHCP: *dhcp || *proxy, DHCPMode: mode, PrimaryRack: *rack, SecondaryRack: *sec}, &v); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("vlan %s/%d: dhcp=%s rack=%s\n", v.Fabric, v.VID, orDash(v.DHCPMode), v.PrimaryRack)
|
|
case "rm":
|
|
need(3)
|
|
return c.do("DELETE", "/api/v1/vlans/"+args[1]+"/"+args[2], nil, nil)
|
|
default:
|
|
usage()
|
|
}
|
|
case "subnet":
|
|
switch sub {
|
|
case "", "list":
|
|
var ss []Subnet
|
|
if err := c.do("GET", "/api/v1/subnets", nil, &ss); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("%-24s %-12s %-14s %-12s %-15s %-12s %9s %s\n", "SUBNET", "PURPOSE", "FABRIC/VLAN", "DHCP RACK", "GATEWAY", "DOMAIN", "USED", "RANGES")
|
|
for _, s := range ss {
|
|
dhcp := "-"
|
|
if s.DHCP {
|
|
dhcp = s.PrimaryRack
|
|
if s.SecondaryRack != "" {
|
|
dhcp += "+" + s.SecondaryRack
|
|
}
|
|
}
|
|
var rs []string
|
|
for _, r := range s.Ranges {
|
|
rs = append(rs, fmt.Sprintf("%s:%s-%s", r.Kind, r.Start, lastOctets(r.End)))
|
|
}
|
|
size := strconv.Itoa(s.Size)
|
|
if s.Size == 0 {
|
|
size = "∞"
|
|
}
|
|
fmt.Printf("%-24s %-12s %-14s %-12s %-15s %-12s %4d/%-4s %s\n", s.CIDR, s.Purpose, fmt.Sprintf("%s/%d", orDash(s.Fabric), s.VID), dhcp, orDash(s.Gateway), orDash(s.Domain), s.Used, size, strings.Join(rs, " "))
|
|
}
|
|
case "show":
|
|
need(2)
|
|
var ss []Subnet
|
|
if err := c.do("GET", "/api/v1/subnets", nil, &ss); err != nil {
|
|
return err
|
|
}
|
|
for _, s := range ss {
|
|
if s.CIDR == args[1] || s.Name == args[1] {
|
|
fmt.Printf("%s %s fabric %s vlan %d mtu %d dhcp=%v rack=%s %s\n gateway %s dns %v domain %s\n", s.CIDR, s.Purpose, s.Fabric, s.VID, s.MTU, s.DHCP, s.PrimaryRack, s.SecondaryRack, orDash(s.Gateway), s.DNS, orDash(s.Domain))
|
|
for _, r := range s.Ranges {
|
|
fmt.Printf(" range %-4d %-8s %s - %s %s\n", r.ID, r.Kind, r.Start, r.End, r.Comment)
|
|
}
|
|
var ips []IPAddr
|
|
if err := c.do("GET", "/api/v1/ips?subnet="+s.CIDR, nil, &ips); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf(" %d of %d addresses in use\n", len(ips), s.Size)
|
|
for _, x := range ips {
|
|
fmt.Printf(" %-15s %-8s %-16s %-10s %s\n", x.IP, x.Kind, orDash(x.MachineID), x.Iface, x.Hostname)
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
return errNotFound
|
|
case "add", "set":
|
|
need(2)
|
|
fs := newFlags("subnet add")
|
|
fabric := fs.String("fabric", "", "fabric (the VLAN is created when missing)")
|
|
vid := fs.Int("vid", 0, "VLAN id (0 = untagged)")
|
|
purpose := fs.String("purpose", "provisioning", "provisioning | mgmt | bmc | storage | ib | other")
|
|
gw := fs.String("gateway", "", "default gateway")
|
|
dns := fs.String("dns", "", "comma-separated DNS servers")
|
|
domain := fs.String("domain", "", "DNS domain")
|
|
name := fs.String("name", "", "name")
|
|
comment := fs.String("comment", "", "comment")
|
|
_ = fs.Parse(args[2:])
|
|
sn := Subnet{CIDR: args[1], Fabric: *fabric, VID: *vid, Purpose: *purpose, Gateway: *gw, Domain: *domain, Name: *name, Comment: *comment, DNS: []string{}}
|
|
if *dns != "" {
|
|
sn.DNS = strings.Split(*dns, ",")
|
|
}
|
|
var out Subnet
|
|
if err := c.do("POST", "/api/v1/subnets", sn, &out); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("subnet %s saved\n", out.CIDR)
|
|
case "rm":
|
|
need(2)
|
|
q := ""
|
|
if len(args) > 2 && args[2] == "-force" {
|
|
q = "?force=1"
|
|
}
|
|
return c.do("DELETE", "/api/v1/subnets/"+args[1]+q, nil, nil)
|
|
default:
|
|
usage()
|
|
}
|
|
case "range":
|
|
switch sub {
|
|
case "add":
|
|
need(5)
|
|
fs := newFlags("range add")
|
|
comment := fs.String("comment", "", "comment")
|
|
_ = fs.Parse(args[5:])
|
|
var rg IPRange
|
|
if err := c.do("POST", "/api/v1/ranges", IPRange{Subnet: args[1], Kind: args[2], Start: args[3], End: args[4], Comment: *comment}, &rg); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("range %d: %s %s-%s on %s\n", rg.ID, rg.Kind, rg.Start, rg.End, rg.Subnet)
|
|
case "rm":
|
|
need(2)
|
|
return c.do("DELETE", "/api/v1/ranges/"+args[1], nil, nil)
|
|
default:
|
|
usage()
|
|
}
|
|
case "ip":
|
|
switch sub {
|
|
case "", "list":
|
|
fs := newFlags("ip list")
|
|
subnet := fs.String("subnet", "", "only this subnet")
|
|
_ = fs.Parse(args[min(1, len(args)):])
|
|
var ips []IPAddr
|
|
if err := c.do("GET", "/api/v1/ips?subnet="+*subnet, nil, &ips); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("%-15s %-18s %-8s %-16s %-10s %s\n", "IP", "SUBNET", "KIND", "MACHINE", "IFACE", "HOSTNAME")
|
|
for _, x := range ips {
|
|
fmt.Printf("%-15s %-18s %-8s %-16s %-10s %s\n", x.IP, x.Subnet, x.Kind, orDash(x.MachineID), x.Iface, x.Hostname)
|
|
}
|
|
case "reserve":
|
|
need(2)
|
|
fs := newFlags("ip reserve")
|
|
host := fs.String("name", "", "what it is (a switch, a VIP)")
|
|
_ = fs.Parse(args[2:])
|
|
return c.do("POST", "/api/v1/ips", IPAddr{IP: args[1], Hostname: *host}, nil)
|
|
case "release":
|
|
need(2)
|
|
return c.do("DELETE", "/api/v1/ips/"+args[1], nil, nil)
|
|
default:
|
|
usage()
|
|
}
|
|
case "layout":
|
|
switch sub {
|
|
case "", "list":
|
|
var ls []NetLayout
|
|
if err := c.do("GET", "/api/v1/layouts", nil, &ls); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("%-18s %8s %-50s %s\n", "LAYOUT", "MACHINES", "INTERFACES", "COMMENT")
|
|
for _, l := range ls {
|
|
fmt.Printf("%-18s %8d %-50s %s\n", l.Name, l.Machines, trunc(layoutSummary(l), 50), l.Comment)
|
|
}
|
|
case "show":
|
|
need(2)
|
|
var ls []NetLayout
|
|
if err := c.do("GET", "/api/v1/layouts", nil, &ls); err != nil {
|
|
return err
|
|
}
|
|
for _, l := range ls {
|
|
if l.Name == args[1] {
|
|
b, _ := json.MarshalIndent(l, "", " ")
|
|
fmt.Println(string(b))
|
|
return nil
|
|
}
|
|
}
|
|
return errNotFound
|
|
case "add":
|
|
need(3)
|
|
fs := newFlags("layout add")
|
|
comment := fs.String("comment", "", "comment")
|
|
_ = fs.Parse(args[3:])
|
|
b, err := readFileArg(args[2])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var l NetLayout
|
|
if err := json.Unmarshal(b, &l); err != nil {
|
|
return fmt.Errorf("%s: %w", args[2], err)
|
|
}
|
|
l.Name = args[1]
|
|
if *comment != "" {
|
|
l.Comment = *comment
|
|
}
|
|
return c.do("POST", "/api/v1/layouts", l, nil)
|
|
case "rm":
|
|
need(2)
|
|
return c.do("DELETE", "/api/v1/layouts/"+args[1], nil, nil)
|
|
default:
|
|
usage()
|
|
}
|
|
case "net":
|
|
need(2)
|
|
switch sub {
|
|
case "show":
|
|
var d obj
|
|
if err := c.do("GET", "/api/v1/machines/"+args[1]+"/network", nil, &d); err != nil {
|
|
return err
|
|
}
|
|
printNet(d)
|
|
case "set":
|
|
fs := newFlags("net set")
|
|
layout := fs.String("layout", "\x00", "layout name (\"\" = from tags / default)")
|
|
rack := fs.String("rack", "\x00", "rack controller serving it (a machine moved to another segment)")
|
|
var statics, unstatic multiFlag
|
|
fs.Var(&statics, "static", "IFACE=IP pins an address (repeatable)")
|
|
fs.Var(&unstatic, "unstatic", "IP drops a pinned address (repeatable)")
|
|
_ = fs.Parse(args[2:])
|
|
req := obj{"unstatic": []string(unstatic)}
|
|
if *layout != "\x00" {
|
|
req["layout"] = *layout
|
|
}
|
|
if *rack != "\x00" {
|
|
req["rack_controller"] = *rack
|
|
}
|
|
var st []obj
|
|
for _, s := range statics {
|
|
i, ip, ok := strings.Cut(s, "=")
|
|
if !ok {
|
|
return fmt.Errorf("-static wants IFACE=IP")
|
|
}
|
|
st = append(st, obj{"iface": i, "ip": ip})
|
|
}
|
|
req["static"] = st
|
|
var d obj
|
|
if err := c.do("POST", "/api/v1/machines/"+args[1]+"/network", req, &d); err != nil {
|
|
return err
|
|
}
|
|
printNet(d)
|
|
default:
|
|
usage()
|
|
}
|
|
case "controllers", "controller":
|
|
switch {
|
|
case cmd == "controller" && sub == "rm":
|
|
need(2)
|
|
return c.do("DELETE", "/api/v1/controllers/"+args[1], nil, nil)
|
|
case cmd == "controller" && sub == "join-code":
|
|
fs := newFlags("controller join-code")
|
|
name := fs.String("name", "", "bind the code to this rack controller name")
|
|
ttl := fs.Int("ttl", 60, "minutes the code is good for")
|
|
addr := fs.String("addr", "", "host:port the rack reaches the region's HTTPS at (default: how you reached it)")
|
|
_ = fs.Parse(args[1:])
|
|
var d obj
|
|
if err := c.do("POST", "/api/v1/controllers/join", obj{"name": *name, "ttl_minutes": *ttl, "addr": *addr}, &d); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("%s\n\none use, until %s; region %s, CA %s\non the rack host: rollcall rack join <code> -url http://<its address on the segment>\n",
|
|
str(d["code"]), str(d["expires"]), str(d["addr"]), str(d["ca_fingerprint"]))
|
|
return nil
|
|
default:
|
|
var rs []RackController
|
|
if err := c.do("GET", "/api/v1/controllers", nil, &rs); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("%-14s %-9s %-7s %-24s %8s %-22s %-20s %s\n", "CONTROLLER", "KIND", "ONLINE", "URL", "MACHINES", "VERSION", "LAST SEEN", "INTERFACES")
|
|
for _, r := range rs {
|
|
var ifs []string
|
|
for _, i := range r.Interfaces {
|
|
ifs = append(ifs, i.Name+"="+strings.Join(i.Addrs, ","))
|
|
}
|
|
fmt.Printf("%-14s %-9s %-7v %-24s %8d %-22s %-20s %s\n", r.Name, r.Kind, r.Online, r.URL, r.Machines, orDash(r.Version), timeStr(r.LastSeen), strings.Join(ifs, " "))
|
|
}
|
|
}
|
|
case "dhcp":
|
|
need(1)
|
|
data, err := c.raw("GET", "/api/v1/controllers/"+args[0]+"/dhcp")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
os.Stdout.Write(data)
|
|
case "ipam":
|
|
return c.ipamCmd(sub, args)
|
|
case "dns":
|
|
return c.dnsCmd(sub, args)
|
|
case "reservations":
|
|
return c.reservationsCmd(sub, args)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *client) reservationsCmd(sub string, args []string) error {
|
|
show := func(rep dhcpResReport) {
|
|
for _, x := range []struct {
|
|
what string
|
|
list []string
|
|
}{{"put", rep.Put}, {"delete", rep.Deleted}, {"conflict", rep.Conflicts}, {"skipped", rep.Skipped}} {
|
|
for _, v := range x.list {
|
|
fmt.Printf("%-9s %s\n", x.what, v)
|
|
}
|
|
}
|
|
dry := ""
|
|
if rep.DryRun {
|
|
dry = " (dry run, nothing changed)"
|
|
}
|
|
fmt.Printf("%s: %d put, %d deleted, %d conflicts%s\n", orDash(rep.Provider), len(rep.Put), len(rep.Deleted), len(rep.Conflicts), dry)
|
|
if rep.Error != "" {
|
|
fmt.Println("error:", rep.Error)
|
|
}
|
|
}
|
|
switch sub {
|
|
case "", "status":
|
|
var st struct {
|
|
Provider string `json:"provider"`
|
|
URL string `json:"url"`
|
|
Last dhcpResReport `json:"last_sync"`
|
|
}
|
|
if err := c.do("GET", "/api/v1/dhcp-reservations", nil, &st); err != nil {
|
|
return err
|
|
}
|
|
if st.Provider == "" {
|
|
fmt.Println("no DHCP reservation provider (dhcp_reservations.provider in config.json: kea or openwrt)")
|
|
return nil
|
|
}
|
|
fmt.Printf("provider %s at %s\n", st.Provider, st.URL)
|
|
if !st.Last.At.IsZero() {
|
|
fmt.Printf("last sync %s\n", st.Last.At.Local().Format("2006-01-02 15:04:05"))
|
|
show(st.Last)
|
|
}
|
|
case "sync":
|
|
fs := newFlags("reservations sync")
|
|
dry := fs.Bool("dry", false, "show what would change")
|
|
_ = fs.Parse(args[min(1, len(args)):])
|
|
q := ""
|
|
if *dry {
|
|
q = "?dry=1"
|
|
}
|
|
var rep dhcpResReport
|
|
err := c.do("POST", "/api/v1/dhcp-reservations/sync"+q, nil, &rep)
|
|
if rep.Provider != "" || rep.Error != "" {
|
|
show(rep)
|
|
}
|
|
return err
|
|
default:
|
|
usage()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *client) ipamCmd(sub string, args []string) error {
|
|
printRep := func(rep ipamReport) {
|
|
for _, x := range []struct {
|
|
k string
|
|
v []string
|
|
}{{"created", rep.Created}, {"updated", rep.Updated}, {"deleted", rep.Deleted}, {"conflict", rep.Conflicts}} {
|
|
for _, ip := range x.v {
|
|
fmt.Printf("%-9s %s\n", x.k, ip)
|
|
}
|
|
}
|
|
dry := ""
|
|
if rep.DryRun {
|
|
dry = " (dry run, nothing changed)"
|
|
}
|
|
fmt.Printf("%s: %d created, %d updated, %d deleted, %d conflicts%s\n", orDash(rep.Provider),
|
|
len(rep.Created), len(rep.Updated), len(rep.Deleted), len(rep.Conflicts), dry)
|
|
if rep.Error != "" {
|
|
fmt.Println("error:", rep.Error)
|
|
}
|
|
if d := rep.Devices; d != nil {
|
|
for _, x := range []struct {
|
|
k string
|
|
v []string
|
|
}{{"created", d.Created}, {"updated", d.Updated}, {"deleted", d.Deleted}, {"conflict", d.Conflicts}} {
|
|
for _, n := range x.v {
|
|
fmt.Printf("%-9s device %s\n", x.k, n)
|
|
}
|
|
}
|
|
fmt.Printf("devices: %d created, %d updated, %d deleted, %d conflicts\n", len(d.Created), len(d.Updated), len(d.Deleted), len(d.Conflicts))
|
|
if d.Error != "" {
|
|
fmt.Println("error:", d.Error)
|
|
}
|
|
}
|
|
}
|
|
switch sub {
|
|
case "", "status":
|
|
var st struct {
|
|
Provider string `json:"provider"`
|
|
URL string `json:"url"`
|
|
Zone string `json:"dns_zone"`
|
|
Tag string `json:"tag"`
|
|
Field string `json:"machine_field"`
|
|
Last ipamReport `json:"last_sync"`
|
|
}
|
|
if err := c.do("GET", "/api/v1/ipam", nil, &st); err != nil {
|
|
return err
|
|
}
|
|
if st.Provider == "" {
|
|
fmt.Println("built-in IPAM only (set ipam.provider in config.json: netbox or infoblox)")
|
|
return nil
|
|
}
|
|
owner := fmt.Sprintf("tag %q, custom field %q", st.Tag, st.Field)
|
|
if st.Provider == "infoblox" {
|
|
owner = fmt.Sprintf("extensible attribute %q (delegated networks: %q)", st.Field, st.Tag)
|
|
}
|
|
fmt.Printf("provider %s %s\nzone %s\nowner %s\n", st.Provider, st.URL, orDash(st.Zone), owner)
|
|
if st.Last.At.IsZero() {
|
|
fmt.Println("last sync never")
|
|
return nil
|
|
}
|
|
fmt.Printf("last sync %s\n", st.Last.At.Local().Format("2006-01-02 15:04:05"))
|
|
printRep(st.Last)
|
|
case "sync":
|
|
fs := newFlags("ipam sync")
|
|
dry := fs.Bool("dry", false, "show what would change")
|
|
_ = fs.Parse(args[min(1, len(args)):])
|
|
q := ""
|
|
if *dry {
|
|
q = "?dry=1"
|
|
}
|
|
var rep ipamReport
|
|
err := c.do("POST", "/api/v1/ipam/sync"+q, nil, &rep)
|
|
if rep.Provider != "" || rep.Error != "" {
|
|
printRep(rep)
|
|
}
|
|
return err
|
|
case "prefixes", "import":
|
|
var res struct {
|
|
Prefixes []extPrefix `json:"prefixes"`
|
|
Imported []string `json:"imported"`
|
|
}
|
|
method := "GET"
|
|
if sub == "import" {
|
|
method = "POST"
|
|
}
|
|
if err := c.do(method, "/api/v1/ipam/prefixes", nil, &res); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("%-22s %-6s %-5s %-10s %s\n", "PREFIX", "LOCAL", "VLAN", "SITE", "DESCRIPTION")
|
|
for _, p := range res.Prefixes {
|
|
vid := "-"
|
|
if p.VID > 0 {
|
|
vid = strconv.Itoa(p.VID)
|
|
}
|
|
local := "no"
|
|
if p.Local {
|
|
local = "yes"
|
|
if p.Comment != "" {
|
|
local = p.Comment
|
|
}
|
|
}
|
|
fmt.Printf("%-22s %-6s %-5s %-10s %s\n", p.Prefix, local, vid, orDash(p.Site), p.Desc)
|
|
}
|
|
if sub == "prefixes" {
|
|
fmt.Println("prefixes tagged for Rollcall; `rollcall ipam import` creates the missing subnets")
|
|
}
|
|
default:
|
|
usage()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type multiFlag []string
|
|
|
|
func (m *multiFlag) String() string { return strings.Join(*m, ",") }
|
|
func (m *multiFlag) Set(v string) error { *m = append(*m, v); return nil }
|
|
|
|
func lastOctets(ip string) string {
|
|
if strings.Contains(ip, ":") {
|
|
if i := strings.LastIndex(ip, ":"); i >= 0 {
|
|
return ip[i:]
|
|
}
|
|
}
|
|
if i := strings.LastIndex(ip, "."); i >= 0 {
|
|
return ip[i+1:]
|
|
}
|
|
return ip
|
|
}
|
|
|
|
func layoutSummary(l NetLayout) string {
|
|
var p []string
|
|
for _, i := range l.Interfaces {
|
|
var links []string
|
|
for _, lk := range i.Links {
|
|
links = append(links, lk.Subnet+":"+orDash(lk.Mode))
|
|
}
|
|
switch i.Type {
|
|
case "bond":
|
|
p = append(p, fmt.Sprintf("%s(%s)[%s]", orDash(i.Name), strings.Join(i.Members, "+"), strings.Join(links, ",")))
|
|
case "vlan":
|
|
p = append(p, fmt.Sprintf("%s.%d[%s]", i.Parent, i.VID, strings.Join(links, ",")))
|
|
default:
|
|
p = append(p, fmt.Sprintf("%s[%s]", orDash(i.Select), strings.Join(links, ",")))
|
|
}
|
|
}
|
|
return strings.Join(p, " ")
|
|
}
|
|
|
|
func printNet(d obj) {
|
|
fmt.Printf("%s layout %s rack controller %s\n", str(d["id"]), str(d["layout"]), str(d["rack_controller"]))
|
|
if d["modeled"] == false {
|
|
fmt.Println(" no subnets are defined: deployed machines use DHCP (rollcall subnet add …)")
|
|
}
|
|
plan, _ := d["plan"].(obj)
|
|
for _, iv := range toList(plan["interfaces"]) {
|
|
i, _ := iv.(obj)
|
|
desc := str(i["type"])
|
|
switch desc {
|
|
case "physical":
|
|
desc = str(i["mac"])
|
|
case "bond":
|
|
var ms []string
|
|
for _, x := range toList(i["members"]) {
|
|
ms = append(ms, str(x))
|
|
}
|
|
desc = "bond " + str(i["bond_mode"]) + " of " + strings.Join(ms, ",")
|
|
case "vlan":
|
|
desc = fmt.Sprintf("vlan %s on %s", str(i["vid"]), str(i["parent"]))
|
|
}
|
|
mtu := ""
|
|
if num(i["mtu"]) > 0 {
|
|
mtu = fmt.Sprintf(" mtu %d", int(num(i["mtu"])))
|
|
}
|
|
fmt.Printf(" %-12s %s%s\n", str(i["name"]), desc, mtu)
|
|
for _, lv := range toList(i["links"]) {
|
|
l, _ := lv.(obj)
|
|
ip := str(l["ip"])
|
|
if ip == "" && str(l["mode"]) == "auto" {
|
|
ip = "(allocated at deploy)"
|
|
} else if ip != "" {
|
|
ip = fmt.Sprintf("%s/%d", ip, int(num(l["prefix"])))
|
|
}
|
|
gw := ""
|
|
if l["default_gateway"] == true {
|
|
gw = " default via " + str(l["gateway"])
|
|
}
|
|
fmt.Printf(" %-8s %-18s %s%s\n", str(l["mode"]), orDash(str(l["subnet"])), ip, gw)
|
|
}
|
|
}
|
|
for _, w := range toList(plan["warnings"]) {
|
|
fmt.Printf(" ! %s\n", str(w))
|
|
}
|
|
b, _ := json.MarshalIndent(d["network_config"], " ", " ")
|
|
fmt.Printf(" network-config:\n %s\n", b)
|
|
}
|
|
|
|
func (c *client) dnsCmd(sub string, args []string) error {
|
|
show := func(rep dnsReport) {
|
|
for _, x := range rep.Upserted {
|
|
fmt.Printf("%-9s %s\n", "write", x)
|
|
}
|
|
for _, x := range rep.Deleted {
|
|
fmt.Printf("%-9s %s\n", "delete", x)
|
|
}
|
|
for _, x := range rep.Conflicts {
|
|
fmt.Printf("%-9s %s\n", "conflict", x)
|
|
}
|
|
dry := ""
|
|
if rep.DryRun {
|
|
dry = " (dry run, nothing changed)"
|
|
}
|
|
fmt.Printf("%s %s: %d written, %d deleted, %d conflicts%s\n", orDash(rep.Provider), rep.Zone, len(rep.Upserted), len(rep.Deleted), len(rep.Conflicts), dry)
|
|
if rep.Error != "" {
|
|
fmt.Println("error:", rep.Error)
|
|
}
|
|
}
|
|
switch sub {
|
|
case "", "status":
|
|
var st struct {
|
|
Provider string `json:"provider"`
|
|
Zone string `json:"zone"`
|
|
Last dnsReport `json:"last_sync"`
|
|
}
|
|
if err := c.do("GET", "/api/v1/dns", nil, &st); err != nil {
|
|
return err
|
|
}
|
|
if st.Provider == "" {
|
|
fmt.Println("no DNS provider (dns.provider in config.json: powerdns or rfc2136)")
|
|
return nil
|
|
}
|
|
fmt.Printf("provider %s, zone %s\n", st.Provider, st.Zone)
|
|
if !st.Last.At.IsZero() {
|
|
fmt.Printf("last sync %s\n", st.Last.At.Local().Format("2006-01-02 15:04:05"))
|
|
show(st.Last)
|
|
}
|
|
case "sync":
|
|
fs := newFlags("dns sync")
|
|
dry := fs.Bool("dry", false, "show what would change")
|
|
_ = fs.Parse(args[min(1, len(args)):])
|
|
q := ""
|
|
if *dry {
|
|
q = "?dry=1"
|
|
}
|
|
var rep dnsReport
|
|
err := c.do("POST", "/api/v1/dns/sync"+q, nil, &rep)
|
|
if rep.Provider != "" || rep.Error != "" {
|
|
show(rep)
|
|
}
|
|
return err
|
|
default:
|
|
usage()
|
|
}
|
|
return nil
|
|
}
|