small pixel drawing of a pufferfish cascade

internal/cli/services.go

package cli

import (
	"bufio"
	"bytes"
	"encoding/json"
	"flag"
	"fmt"
	"io"
	"net"
	"os"
	"sort"
	"strconv"
	"strings"
	"text/tabwriter"

	"git.j3s.sh/cascade/api"
)

type servicesCommand struct {
	flagAPIAddr string
}

func (c servicesCommand) Usage() {
	fmt.Printf(`usage: cascade services [name] [flags]
    with no name, list every service in the cluster.
    with a name, list every instance of that service.

flags:
  -api
    address of the cascade http api to target (default = 127.0.0.1:8500)
`)
}

func (c *servicesCommand) Init(args []string) []string {
	flags := flag.NewFlagSet("", flag.ContinueOnError)
	flags.Usage = c.Usage
	flags.StringVar(&c.flagAPIAddr, "api", "", "")
	if err := flags.Parse(args); err != nil {
		fmt.Println(err)
		os.Exit(1)
	}
	return flags.Args()
}

func RunServices(args []string) {
	c := servicesCommand{}
	rest := c.Init(args)

	cfg := api.DefaultConfig()
	if c.flagAPIAddr != "" {
		cfg.Address = c.flagAPIAddr
	}
	client, err := api.NewClient(cfg)
	if err != nil {
		fmt.Println(err)
		os.Exit(1)
	}

	if len(rest) == 0 {
		listAllServices(client)
		return
	}
	listServiceInstances(client, rest[0])
}

func listAllServices(client *api.Client) {
	services, err := client.Catalog().Services()
	if err != nil {
		fmt.Println(err)
		os.Exit(1)
	}
	if len(services) == 0 {
		fmt.Println("no services known in the cluster")
		return
	}

	names := make([]string, 0, len(services))
	for n := range services {
		names = append(names, n)
	}
	sort.Strings(names)

	var b bytes.Buffer
	tw := tabwriter.NewWriter(&b, 0, 2, 2, ' ', 0)
	fmt.Fprintln(tw, "service\ttags")
	for _, n := range names {
		fmt.Fprintf(tw, "%s\t%s\n", n, joinTagsOrNone(services[n]))
	}
	tw.Flush()
	fmt.Print(b.String())
}

func listServiceInstances(client *api.Client, name string) {
	instances, err := client.Catalog().Service(name)
	if err != nil {
		fmt.Println(err)
		os.Exit(1)
	}
	if len(instances) == 0 {
		fmt.Printf("no instances of %q known in the cluster\n", name)
		return
	}

	var b bytes.Buffer
	tw := tabwriter.NewWriter(&b, 0, 2, 2, ' ', 0)
	fmt.Fprintln(tw, "node\tid\taddr\ttags")
	for _, inst := range instances {
		addr := inst.ServiceAddress
		if addr == "" {
			addr = inst.Address
		}
		if inst.ServicePort != 0 {
			addr = net.JoinHostPort(addr, strconv.Itoa(inst.ServicePort))
		}
		fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", inst.Node, inst.ServiceID, addr, joinTagsOrNone(inst.ServiceTags))
	}
	tw.Flush()
	fmt.Print(b.String())
}

type registerCmd struct {
	flagAPIAddr string
	flagName    string
	flagID      string
	flagAddr    string
	flagPort    int
	flagTags    string
	flagFile    string
}

func (c registerCmd) Usage() {
	fmt.Printf(`usage: cascade register [flags]
    register a service on the local cascade agent. with no flags, prompts
    interactively. with -f, reads a full AgentService JSON document from a
    file (use '-' for stdin).

flags:
  -api    address of the cascade http api to target (default = 127.0.0.1:8500)
  -name   service name (required)
  -port   service port (required)
  -id     service id (default = name)
  -addr   service address (default = empty; consumers use the node address)
  -tags   comma-separated tags
  -f      read AgentService JSON from file or '-' for stdin
`)
}

func RunRegister(args []string) {
	c := registerCmd{}
	flags := flag.NewFlagSet("", flag.ContinueOnError)
	flags.Usage = c.Usage
	flags.StringVar(&c.flagAPIAddr, "api", "", "")
	flags.StringVar(&c.flagName, "name", "", "")
	flags.StringVar(&c.flagID, "id", "", "")
	flags.StringVar(&c.flagAddr, "addr", "", "")
	flags.IntVar(&c.flagPort, "port", 0, "")
	flags.StringVar(&c.flagTags, "tags", "", "")
	flags.StringVar(&c.flagFile, "f", "", "")
	if err := flags.Parse(args); err != nil {
		os.Exit(1)
	}

	var svc *api.AgentService
	var err error
	switch {
	case c.flagFile != "":
		svc, err = readServiceFile(c.flagFile)
	case c.flagName != "" && c.flagPort != 0:
		svc = c.fromFlags()
	default:
		svc, err = c.prompt()
	}
	if err != nil {
		fmt.Println(err)
		os.Exit(1)
	}
	if svc.Service == "" {
		fmt.Println("service name required")
		os.Exit(1)
	}
	if svc.ID == "" {
		svc.ID = svc.Service
	}

	cfg := api.DefaultConfig()
	if c.flagAPIAddr != "" {
		cfg.Address = c.flagAPIAddr
	}
	client, err := api.NewClient(cfg)
	if err != nil {
		fmt.Println(err)
		os.Exit(1)
	}
	if err := client.Agent().ServiceRegister(svc); err != nil {
		fmt.Printf("register failed: %s\n", err)
		os.Exit(1)
	}

	addrDesc := svc.Address
	if addrDesc == "" {
		addrDesc = "<node addr>"
	}
	if svc.Port != 0 {
		addrDesc = net.JoinHostPort(addrDesc, strconv.Itoa(svc.Port))
	}
	fmt.Printf("service registered: %s (%s)", svc.Service, addrDesc)
	if len(svc.Tags) > 0 {
		fmt.Printf(" tags=%s", strings.Join(svc.Tags, ","))
	}
	fmt.Printf("\n")
}

func (c registerCmd) fromFlags() *api.AgentService {
	svc := &api.AgentService{
		ID:      c.flagID,
		Service: c.flagName,
		Address: c.flagAddr,
		Port:    c.flagPort,
	}
	if c.flagTags != "" {
		svc.Tags = splitTags(c.flagTags)
	}
	return svc
}

func (c registerCmd) prompt() (*api.AgentService, error) {
	if !isTerminal(os.Stdin) {
		return nil, fmt.Errorf("register requires -name and -port (or -f) when stdin is not a tty")
	}
	r := bufio.NewReader(os.Stdin)

	name := promptLine(r, "service name", c.flagName)
	if name == "" {
		return nil, fmt.Errorf("service name required")
	}

	portDef := ""
	if c.flagPort != 0 {
		portDef = strconv.Itoa(c.flagPort)
	}
	portStr := promptLine(r, "port", portDef)
	port, err := strconv.Atoi(portStr)
	if err != nil || port <= 0 {
		return nil, fmt.Errorf("port must be a positive integer")
	}

	id := promptLine(r, "id", firstNonEmpty(c.flagID, name))
	addr := promptLine(r, "address (empty = use node addr)", c.flagAddr)
	tags := promptLine(r, "tags (comma-separated)", c.flagTags)

	svc := &api.AgentService{
		ID:      id,
		Service: name,
		Address: addr,
		Port:    port,
	}
	if tags != "" {
		svc.Tags = splitTags(tags)
	}
	return svc, nil
}

type deregisterCmd struct {
	flagAPIAddr string
}

func (c deregisterCmd) Usage() {
	fmt.Printf(`usage: cascade deregister <id> [flags]
    remove a service from the local cascade agent.

flags:
  -api    address of the cascade http api to target (default = 127.0.0.1:8500)
`)
}

func RunDeregister(args []string) {
	c := deregisterCmd{}
	flags := flag.NewFlagSet("", flag.ContinueOnError)
	flags.Usage = c.Usage
	flags.StringVar(&c.flagAPIAddr, "api", "", "")
	if err := flags.Parse(args); err != nil {
		os.Exit(1)
	}
	rest := flags.Args()
	if len(rest) == 0 {
		c.Usage()
		os.Exit(1)
	}
	id := rest[0]

	cfg := api.DefaultConfig()
	if c.flagAPIAddr != "" {
		cfg.Address = c.flagAPIAddr
	}
	client, err := api.NewClient(cfg)
	if err != nil {
		fmt.Println(err)
		os.Exit(1)
	}
	if err := client.Agent().ServiceDeregister(id); err != nil {
		fmt.Printf("deregister failed: %s\n", err)
		os.Exit(1)
	}
	fmt.Printf("deregistered %s\n", id)
}

func readServiceFile(path string) (*api.AgentService, error) {
	var r io.Reader
	if path == "-" {
		r = os.Stdin
	} else {
		f, err := os.Open(path)
		if err != nil {
			return nil, err
		}
		defer f.Close()
		r = f
	}
	var svc api.AgentService
	if err := json.NewDecoder(r).Decode(&svc); err != nil {
		return nil, fmt.Errorf("decode %s: %w", path, err)
	}
	return &svc, nil
}

func promptLine(r *bufio.Reader, label, def string) string {
	if def != "" {
		fmt.Printf("%s [%s]: ", label, def)
	} else {
		fmt.Printf("%s: ", label)
	}
	line, err := r.ReadString('\n')
	if err != nil && err != io.EOF {
		return def
	}
	line = strings.TrimRight(line, "\r\n")
	if line == "" {
		return def
	}
	return line
}

func joinTagsOrNone(tags []string) string {
	if len(tags) == 0 {
		return "<none>"
	}
	return strings.Join(tags, ",")
}

func splitTags(s string) []string {
	parts := strings.Split(s, ",")
	out := make([]string, 0, len(parts))
	for _, p := range parts {
		p = strings.TrimSpace(p)
		if p != "" {
			out = append(out, p)
		}
	}
	return out
}

func firstNonEmpty(vals ...string) string {
	for _, v := range vals {
		if v != "" {
			return v
		}
	}
	return ""
}

func isTerminal(f *os.File) bool {
	fi, err := f.Stat()
	if err != nil {
		return false
	}
	return (fi.Mode() & os.ModeCharDevice) != 0
}