tconsole: init
This introduces the 'tconsole' (terminal console), the default
interface to show in /dev/tty1 on a Metropolis node.
Currently it just shows some basic status in a single page. Upcoming
changes will reintroduce a simple log dump on a different page, as well
as entirely new features like supervision tree inspection.
To iterate quickly on the console, a 'standalone' target is added which
exercises the console on the user's terminal with fake node data.
However only the actual console in Linux displays colours as intended.
Change-Id: I5cfba2bdb320daa080a073e76bf0494aeab6a4d4
Reviewed-on: https://review.monogon.dev/c/monogon/+/3371
Reviewed-by: Leopold Schabel <leo@monogon.tech>
Tested-by: Jenkins CI
diff --git a/metropolis/node/core/tconsole/tconsole.go b/metropolis/node/core/tconsole/tconsole.go
new file mode 100644
index 0000000..52885bd
--- /dev/null
+++ b/metropolis/node/core/tconsole/tconsole.go
@@ -0,0 +1,163 @@
+package tconsole
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "strings"
+
+ "github.com/gdamore/tcell/v2"
+
+ "source.monogon.dev/metropolis/node/core/network"
+ "source.monogon.dev/metropolis/node/core/roleserve"
+ cpb "source.monogon.dev/metropolis/proto/common"
+ "source.monogon.dev/osbase/event"
+ "source.monogon.dev/osbase/supervisor"
+)
+
+// Console is a Terminal Console (TConsole), a user-interactive informational
+// display visible on the TTY of a running Metropolis instance.
+type Console struct {
+ // Quit will be closed when the user press CTRL-C. A new channel will be created
+ // on each New call.
+
+ Quit chan struct{}
+ ttyPath string
+ tty tcell.Tty
+ screen tcell.Screen
+ width int
+ height int
+ // palette chosen for the given terminal.
+ palette palette
+
+ network event.Value[*network.Status]
+ roles event.Value[*cpb.NodeRoles]
+ curatorConn event.Value[*roleserve.CuratorConnection]
+}
+
+// New creates a new Console, taking over the TTY at the given path. The given
+// Terminal type selects between a Linux terminal (VTY) and a generic terminal
+// for testing.
+//
+// network, roles, curatorConn point to various Metropolis subsystems that are
+// used to populate the console data.
+func New(terminal Terminal, ttyPath string, network event.Value[*network.Status], roles event.Value[*cpb.NodeRoles], curatorConn event.Value[*roleserve.CuratorConnection]) (*Console, error) {
+ tty, err := tcell.NewDevTtyFromDev(ttyPath)
+ if err != nil {
+ return nil, err
+ }
+ screen, err := tcell.NewTerminfoScreenFromTty(tty)
+ if err != nil {
+ return nil, err
+ }
+ if err := screen.Init(); err != nil {
+ return nil, err
+ }
+ screen.SetStyle(tcell.StyleDefault)
+
+ var pal palette
+ switch terminal {
+ case TerminalLinux:
+ pal = paletteLinux
+ tty.Write([]byte(pal.setupLinuxConsole()))
+ case TerminalGeneric:
+ pal = paletteGeneric
+ }
+
+ width, height := screen.Size()
+ return &Console{
+ ttyPath: ttyPath,
+ tty: tty,
+ screen: screen,
+ width: width,
+ height: height,
+ network: network,
+ palette: pal,
+ Quit: make(chan struct{}),
+
+ roles: roles,
+ curatorConn: curatorConn,
+ }, nil
+}
+
+// Cleanup should be called when the console exits. This is only used in testing,
+// the Metropolis console always runs.
+func (c *Console) Cleanup() {
+ c.screen.Fini()
+}
+
+func (c *Console) processEvent(ev tcell.Event) {
+ switch ev := ev.(type) {
+ case *tcell.EventKey:
+ if ev.Key() == tcell.KeyCtrlC {
+ close(c.Quit)
+ }
+ case *tcell.EventResize:
+ c.width, c.height = ev.Size()
+ }
+}
+
+// Run blocks while displaying the Console to the user.
+func (c *Console) Run(ctx context.Context) error {
+ // Build channel for console event processing.
+ evC := make(chan tcell.Event)
+ evQuitC := make(chan struct{})
+ defer close(evQuitC)
+ go c.screen.ChannelEvents(evC, evQuitC)
+
+ // Pipe event values into channels.
+ netAddrC := make(chan *network.Status)
+ rolesC := make(chan *cpb.NodeRoles)
+ curatorConnC := make(chan *roleserve.CuratorConnection)
+ if err := supervisor.Run(ctx, "netpipe", event.Pipe(c.network, netAddrC)); err != nil {
+ return err
+ }
+ if err := supervisor.Run(ctx, "rolespipe", event.Pipe(c.roles, rolesC)); err != nil {
+ return err
+ }
+ if err := supervisor.Run(ctx, "curatorpipe", event.Pipe(c.curatorConn, curatorConnC)); err != nil {
+ return err
+ }
+ supervisor.Signal(ctx, supervisor.SignalHealthy)
+
+ pageStatus := pageStatusData{
+ netAddr: "Waiting...",
+ roles: "Waiting...",
+ id: "Waiting...",
+ fingerprint: "Waiting...",
+ }
+
+ for {
+ c.pageStatus(&pageStatus)
+
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case ev := <-evC:
+ c.processEvent(ev)
+ case t := <-netAddrC:
+ pageStatus.netAddr = t.ExternalAddress.String()
+ case t := <-rolesC:
+ var rlist []string
+ if t.ConsensusMember != nil {
+ rlist = append(rlist, "ConsensusMember")
+ }
+ if t.KubernetesController != nil {
+ rlist = append(rlist, "KubernetesController")
+ }
+ if t.KubernetesWorker != nil {
+ rlist = append(rlist, "KubernetesWorker")
+ }
+ pageStatus.roles = strings.Join(rlist, ", ")
+ if pageStatus.roles == "" {
+ pageStatus.roles = "none"
+ }
+ case t := <-curatorConnC:
+ pageStatus.id = t.Credentials.ID()
+ cert := t.Credentials.ClusterCA()
+ sum := sha256.New()
+ sum.Write(cert.Raw)
+ pageStatus.fingerprint = hex.EncodeToString(sum.Sum(nil))
+ }
+ }
+}