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/build/bazel/go.MODULE.bazel b/build/bazel/go.MODULE.bazel
index 4d69a62..e5de395 100644
--- a/build/bazel/go.MODULE.bazel
+++ b/build/bazel/go.MODULE.bazel
@@ -22,6 +22,7 @@
"com_github_corverroos_commentwrap",
"com_github_diskfs_go_diskfs",
"com_github_docker_distribution",
+ "com_github_gdamore_tcell_v2",
"com_github_go_delve_delve",
"com_github_golang_migrate_migrate_v4",
"com_github_google_cel_go",
@@ -58,6 +59,7 @@
"com_github_prometheus_client_golang",
"com_github_prometheus_node_exporter",
"com_github_pseudomuto_protoc_gen_doc",
+ "com_github_rivo_uniseg",
"com_github_rmohr_bazeldnf",
"com_github_sbezverk_nfproxy",
"com_github_spf13_cobra",
diff --git a/go.mod b/go.mod
index c306260..2dfac51 100644
--- a/go.mod
+++ b/go.mod
@@ -82,6 +82,7 @@
github.com/corverroos/commentwrap v0.0.0-20191204065359-2926638be44c
github.com/diskfs/go-diskfs v1.2.0
github.com/docker/distribution v2.8.2+incompatible
+ github.com/gdamore/tcell/v2 v2.7.4
github.com/go-delve/delve v1.8.2
github.com/golang-migrate/migrate/v4 v4.15.2
github.com/google/cel-go v0.20.1
@@ -163,6 +164,11 @@
)
require (
+ github.com/gdamore/encoding v1.0.0 // indirect
+ github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
+)
+
+require (
cloud.google.com/go v0.112.1 // indirect
cloud.google.com/go/compute/metadata v0.3.0 // indirect
cloud.google.com/go/iam v1.1.6 // indirect
@@ -312,7 +318,7 @@
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
- github.com/mattn/go-runewidth v0.0.14 // indirect
+ github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/mattn/go-sqlite3 v1.14.17 // indirect
github.com/mattn/go-xmlrpc v0.0.3 // indirect
github.com/mdlayher/socket v0.5.0 // indirect
@@ -364,7 +370,7 @@
github.com/prometheus/procfs v0.15.1 // indirect
github.com/pseudomuto/protokit v0.2.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
- github.com/rivo/uniseg v0.2.0 // indirect
+ github.com/rivo/uniseg v0.4.3
github.com/riza-io/grpc-go v0.2.0 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/rs/cors v1.8.0 // indirect
diff --git a/go.sum b/go.sum
index 895f114..a4bc1c8 100644
--- a/go.sum
+++ b/go.sum
@@ -2014,6 +2014,10 @@
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/garyburd/redigo v0.0.0-20150301180006-535138d7bcd7/go.mod h1:NR3MbYisc3/PwhQ00EMzDiPmrwpPxAn5GI05/YaO1SY=
+github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko=
+github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
+github.com/gdamore/tcell/v2 v2.7.4 h1:sg6/UnTM9jGpZU+oFYAsDahfchWAFW8Xx2yFinNSAYU=
+github.com/gdamore/tcell/v2 v2.7.4/go.mod h1:dSXtXTSK0VsW1biw65DZLZ2NKr7j0qP/0J7ONmsraWg=
github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
@@ -2719,6 +2723,8 @@
github.com/linuxkit/virtsock v0.0.0-20201010232012-f8cee7dfc7a3/go.mod h1:3r6x7q95whyfWQpmGZTu3gk3v2YkMi05HEzl7Tf7YEo=
github.com/lithammer/dedent v1.1.0 h1:VNzHMVCBNG1j0fh3OrsFRkVUwStdDArbgBWoPAffktY=
github.com/lithammer/dedent v1.1.0/go.mod h1:jrXYCQtgg0nJiN+StA2KgR7w6CiQNv9Fd/Z9BP0jIOc=
+github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
+github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/lufia/iostat v1.2.1 h1:tnCdZBIglgxD47RyD55kfWQcJMGzO+1QBziSQfesf2k=
github.com/lufia/iostat v1.2.1/go.mod h1:rEPNA0xXgjHQjuI5Cy05sLlS2oRcSlWHRLrvh/AQ+Pg=
github.com/lyft/protoc-gen-star v0.5.3/go.mod h1:V0xaHgaf5oCCqmcxYcWiDfTiKsZsRc87/1qhoTACD8w=
@@ -2767,8 +2773,8 @@
github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
-github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
-github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
+github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-shellwords v1.0.3/go.mod h1:3xCvwCdWdlDJUrvuMn7Wuy9eWs4pE8vqg+NOMyg4B2o=
github.com/mattn/go-shellwords v1.0.6/go.mod h1:3xCvwCdWdlDJUrvuMn7Wuy9eWs4pE8vqg+NOMyg4B2o=
github.com/mattn/go-shellwords v1.0.10/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
@@ -3208,8 +3214,9 @@
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
-github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/rivo/uniseg v0.4.3 h1:utMvzDsuh3suAEnhH0RdHmoPbU648o6CvXxTx4SBMOw=
+github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/riza-io/grpc-go v0.2.0 h1:2HxQKFVE7VuYstcJ8zqpN84VnAoJ4dCL6YFhJewNcHQ=
github.com/riza-io/grpc-go v0.2.0/go.mod h1:2bDvR9KkKC3KhtlSHfR3dAXjUMT86kg4UfWFyVGWqi8=
github.com/rmohr/bazeldnf v0.5.4 h1:xYSQoQHuCZY+2mZJtt+2KN0G6TmeEOPR6cxPZomTXX4=
diff --git a/metropolis/node/core/BUILD.bazel b/metropolis/node/core/BUILD.bazel
index c6fdb18..b511bb9 100644
--- a/metropolis/node/core/BUILD.bazel
+++ b/metropolis/node/core/BUILD.bazel
@@ -33,6 +33,7 @@
"//metropolis/node/core/network",
"//metropolis/node/core/roleserve",
"//metropolis/node/core/rpc/resolver",
+ "//metropolis/node/core/tconsole",
"//metropolis/node/core/time",
"//metropolis/node/core/update",
"//metropolis/proto/api",
diff --git a/metropolis/node/core/main.go b/metropolis/node/core/main.go
index 3d2969f..d103693 100644
--- a/metropolis/node/core/main.go
+++ b/metropolis/node/core/main.go
@@ -33,6 +33,7 @@
"source.monogon.dev/metropolis/node/core/network"
"source.monogon.dev/metropolis/node/core/roleserve"
"source.monogon.dev/metropolis/node/core/rpc/resolver"
+ "source.monogon.dev/metropolis/node/core/tconsole"
timesvc "source.monogon.dev/metropolis/node/core/time"
"source.monogon.dev/metropolis/node/core/update"
mversion "source.monogon.dev/metropolis/version"
@@ -52,13 +53,8 @@
// Root system logtree.
lt := logtree.New()
- // Set up logger for Metropolis. Currently logs everything to /dev/tty0 and
- // /dev/ttyS{0,1}.
- consoles := []*console{
- {
- path: "/dev/tty0",
- maxWidth: 80,
- },
+ // Set up logger for Metropolis. Currently logs everything to /dev/ttyS{0,1}.
+ serialConsoles := []*console{
{
path: "/dev/ttyS0",
maxWidth: 120,
@@ -73,7 +69,7 @@
crash := make(chan string)
// Open up consoles and set up logging from logtree and crash channel.
- for _, c := range consoles {
+ for _, c := range serialConsoles {
f, err := os.OpenFile(c.path, os.O_WRONLY, 0)
if err != nil {
continue
@@ -99,7 +95,7 @@
}
// Initialize persistent panic handler early
- initPanicHandler(lt, consoles)
+ initPanicHandler(lt, serialConsoles)
// Initial logger. Used until we get to a supervisor.
logger := lt.MustLeveledFor("init")
@@ -215,6 +211,19 @@
return fmt.Errorf("when starting debug service: %w", err)
}
+ // Initialize interactive consoles.
+ interactiveConsoles := []string{"/dev/tty0"}
+ for _, c := range interactiveConsoles {
+ console, err := tconsole.New(tconsole.TerminalLinux, c, &networkSvc.Status, &rs.LocalRoles, &rs.CuratorConnection)
+ if err != nil {
+ logger.Info("Failed to initialize interactive console at %s: %v", c, err)
+ // TODO: fall back to logger
+ } else {
+ logger.Info("Started interactive console at %s", c)
+ supervisor.Run(ctx, "console-"+c, console.Run)
+ }
+ }
+
// Start cluster manager. This kicks off cluster membership machinery,
// which will either start a new cluster, enroll into one or join one.
m := cluster.NewManager(root, networkSvc, rs, updateSvc, nodeParams, haveTPM)
@@ -249,7 +258,7 @@
ctxC()
time.Sleep(time.Second)
// After a bit, kill all console log readers.
- for _, c := range consoles {
+ for _, c := range serialConsoles {
if c.reader == nil {
continue
}
diff --git a/metropolis/node/core/tconsole/BUILD.bazel b/metropolis/node/core/tconsole/BUILD.bazel
new file mode 100644
index 0000000..c8ca99e
--- /dev/null
+++ b/metropolis/node/core/tconsole/BUILD.bazel
@@ -0,0 +1,24 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library")
+
+go_library(
+ name = "tconsole",
+ srcs = [
+ "colors.go",
+ "draw.go",
+ "page_status.go",
+ "tconsole.go",
+ ],
+ importpath = "source.monogon.dev/metropolis/node/core/tconsole",
+ visibility = ["//visibility:public"],
+ deps = [
+ "//metropolis/node/core/network",
+ "//metropolis/node/core/roleserve",
+ "//metropolis/proto/common",
+ "//metropolis/version",
+ "//osbase/event",
+ "//osbase/supervisor",
+ "//version",
+ "@com_github_gdamore_tcell_v2//:tcell",
+ "@com_github_rivo_uniseg//:uniseg",
+ ],
+)
diff --git a/metropolis/node/core/tconsole/colors.go b/metropolis/node/core/tconsole/colors.go
new file mode 100644
index 0000000..d1d5793
--- /dev/null
+++ b/metropolis/node/core/tconsole/colors.go
@@ -0,0 +1,87 @@
+package tconsole
+
+import (
+ "fmt"
+
+ "github.com/gdamore/tcell/v2"
+)
+
+// Terminal is a supported terminal kind. This is a simplistic alternative to
+// terminfo.
+type Terminal string
+
+const (
+ // TerminalLinux is a linux VTY console.
+ TerminalLinux Terminal = "linux"
+ // TerminalGeneric is any other terminal that is supported by tcell.
+ TerminalGeneric Terminal = "generic"
+)
+
+// color type separate from tcell.Color. This is necessary because tcell does not
+// support color remapping in Linux VTYs, only static colors, and we have to
+// build our own color system on top of it.
+//
+// Our color type is abstract, it denotes a given colour in terms of human
+// perception, not an actual colour code.
+type color string
+
+const (
+ // colorBlack is a deep black, suitable for use as a foreground.
+ colorBlack color = "black"
+ // colorPink is a light pink, suitable for use as a background.
+ colorPink color = "pink"
+ // colorBlue is a light blue, suitable for use as a background.
+ colorBlue color = "blue"
+)
+
+// colorDef is an entry for a palette and defines a tcell color to be used from
+// tcell, and an optional RGB colour to remap the given tcell.Color into.
+type colorDef struct {
+ ansiColor tcell.Color
+ rgbColor uint32
+}
+
+type palette map[color]colorDef
+
+var (
+ // paletteLinux is the full-colour palette used on Linux consoles, with exact RGB
+ // color definitions.
+ paletteLinux = palette{
+ colorBlack: colorDef{tcell.ColorBlack + 0, 0x000000},
+ colorPink: colorDef{tcell.ColorBlack + 1, 0xeedfda},
+ colorBlue: colorDef{tcell.ColorBlack + 2, 0x919ba7},
+ }
+ // paletteGeneric is a fallback palette used on systems which do not support
+ // colour remapping and only have 2 colours: black and white.
+ paletteGeneric = palette{
+ colorBlack: colorDef{tcell.ColorBlack, 0},
+ colorPink: colorDef{tcell.ColorWhite, 0},
+ colorBlue: colorDef{tcell.ColorWhite, 0},
+ }
+)
+
+// linuxConsoleOverrideColor uses Linux-specific ANSI OSC codes to remap a given
+// colour to an RGB value. See console_codes(4) for more information.
+//
+// The function returns a string which must be sent to the terminal.
+func linuxConsoleOverrideColor(co tcell.Color, rgb uint32) string {
+ return fmt.Sprintf("\x1b]P%x%06x", int(co-tcell.ColorBlack), rgb)
+}
+
+// setupLinuxConsole configures a given palette with rgcColor date to be
+// displayed on the user screen.
+//
+// The function returns a string which must be sent to the terminal.
+func (p *palette) setupLinuxConsole() string {
+ res := ""
+ for _, v := range *p {
+ res += linuxConsoleOverrideColor(v.ansiColor, v.rgbColor)
+ }
+ return res
+}
+
+// color retrieves a tcell.Color to use for a given color, respecting the
+// currently set palette.
+func (c *Console) color(col color) tcell.Color {
+ return c.palette[col].ansiColor
+}
diff --git a/metropolis/node/core/tconsole/draw.go b/metropolis/node/core/tconsole/draw.go
new file mode 100644
index 0000000..d88e35b
--- /dev/null
+++ b/metropolis/node/core/tconsole/draw.go
@@ -0,0 +1,85 @@
+package tconsole
+
+import (
+ "strings"
+
+ "github.com/gdamore/tcell/v2"
+ "github.com/rivo/uniseg"
+)
+
+// drawText draws a single line of text from left to right, starting at x and y.
+func (c *Console) drawText(x, y int, text string, style tcell.Style) {
+ g := uniseg.NewGraphemes(text)
+ for g.Next() {
+ runes := g.Runes()
+ c.screen.SetContent(x, y, runes[0], runes[1:], style)
+ x += 1
+ }
+}
+
+// drawTextCentered draw a single line of text from left to right, starting at a
+// position so that the center of the line ends up at x and y.
+func (c *Console) drawTextCentered(x, y int, text string, style tcell.Style) {
+ g := uniseg.NewGraphemes(text)
+ var runes [][]rune
+ for g.Next() {
+ runes = append(runes, g.Runes())
+ }
+
+ x -= len(runes) / 2
+
+ for _, r := range runes {
+ c.screen.SetContent(x, y, r[0], r[1:], style)
+ x += 1
+ }
+}
+
+// fillRectangle fills a given rectangle [x0,x1) [y0,y1) with empty space of a
+// given style.
+func (c *Console) fillRectangle(x0, x1, y0, y1 int, style tcell.Style) {
+ for x := x0; x < x1; x++ {
+ for y := y0; y < y1; y++ {
+ c.screen.SetContent(x, y, ' ', nil, style)
+ }
+ }
+}
+
+const logo = `
+ _g@@@@g_ _g@@@@g_
+ _@@@@@@@@@@a g@@@@@@@@@@b
+ @@@@@@@@@@@@@@___@@@@@@@@@@@@@@
+ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
+ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
+ g@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@g
+ g@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@g
+ g@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@g
+ ;@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@,
+ |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"
+ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
+ %@@@@@@@@@@P<@@@@@@@@@@@@@>%@@@@@@@@@@P
+ "+B@@B>' "@@@@@@@@B" '<B@@BP"
+ '"
+`
+
+// drawLogo draws the Monogon logo so that its top left corner is at x, y.
+func (c *Console) drawLogo(x, y int, style tcell.Style) {
+ for i, line := range strings.Split(logo, "\n") {
+ c.drawText(x, y+i, line, style)
+ }
+}
+
+// split calculates a mid-point in the [0, capacity) domain so that it splits it
+// into two parts fairly with minA and minB used as minimum size hints for each
+// section.
+func split(capacity, minA, minB int) int {
+ slack := capacity - (minA + minB)
+ propA := float64(minA) / float64(minA+minB)
+ slackA := int(propA * float64(slack))
+ return minA + slackA
+}
+
+// center calculates a point at which to start drawing a 'size'-sized element in
+// a 'capacity'-sized container so that it ends in the middle of said container.
+func center(capacity, size int) int {
+ return (capacity - size) / 2
+}
diff --git a/metropolis/node/core/tconsole/page_status.go b/metropolis/node/core/tconsole/page_status.go
new file mode 100644
index 0000000..13c4b63
--- /dev/null
+++ b/metropolis/node/core/tconsole/page_status.go
@@ -0,0 +1,78 @@
+package tconsole
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/gdamore/tcell/v2"
+
+ mversion "source.monogon.dev/metropolis/version"
+ "source.monogon.dev/version"
+)
+
+// pageStatusData encompasses all data to be shown within the status page.
+type pageStatusData struct {
+ netAddr string
+ roles string
+ id string
+ fingerprint string
+}
+
+// pageStatus renders the status page to the user given pageStatusData.
+func (c *Console) pageStatus(d *pageStatusData) {
+ c.screen.Clear()
+ sty1 := tcell.StyleDefault.Background(c.color(colorPink)).Foreground(c.color(colorBlack))
+ sty2 := tcell.StyleDefault.Background(c.color(colorBlue)).Foreground(c.color(colorBlack))
+
+ logoWidth := len(strings.Split(logo, "\n")[1])
+ logoHeight := len(strings.Split(logo, "\n"))
+
+ // Vertical split between top copyright string and main display part.
+ splitV := split(c.height, 4, logoHeight)
+
+ // Colour the split.
+ c.fillRectangle(0, c.width, 0, splitV, sty1)
+ c.fillRectangle(0, c.width, splitV, c.height, sty2)
+
+ // Draw the top part.
+ c.drawTextCentered(c.width/2, splitV/2, "Monogon Cluster Operating System", sty1)
+ c.drawTextCentered(c.width/2, splitV/2+1, "Copyright 2022-2024 The Monogon Project Authors", sty1)
+
+ // Horizontal split between left logo and right status lines, a la 'fetch'.
+ splitH := split(c.width, logoWidth, 60)
+
+ // Status lines.
+ lines := []string{
+ fmt.Sprintf("Version: %s", version.Semver(mversion.Version)),
+ fmt.Sprintf("Node ID: %s", d.id),
+ fmt.Sprintf("CA fingerprint: %s", d.fingerprint),
+ fmt.Sprintf("Management address: %s", d.netAddr),
+ fmt.Sprintf("Roles: %s", d.roles),
+ }
+ // Calculate longest line.
+ maxLine := 0
+ for _, l := range lines {
+ if len(l) > maxLine {
+ maxLine = len(l)
+ }
+ }
+
+ // If logo wouldn't fit, don't bother, save space for important data.
+ drawLogo := true
+ if splitH < logoWidth {
+ drawLogo = false
+ splitH = center(c.width, maxLine)
+ }
+
+ // Draw lines.
+ for i, line := range lines {
+ c.drawText(splitH, splitV+center(c.height-splitV, len(lines))+i, line, sty2)
+ }
+
+ // Draw logo.
+ if drawLogo {
+ c.drawLogo(splitH-logoWidth, splitV+center(c.height-splitV, logoHeight), sty2)
+ }
+
+ c.screen.Show()
+}
diff --git a/metropolis/node/core/tconsole/standalone/BUILD.bazel b/metropolis/node/core/tconsole/standalone/BUILD.bazel
new file mode 100644
index 0000000..cd7a653
--- /dev/null
+++ b/metropolis/node/core/tconsole/standalone/BUILD.bazel
@@ -0,0 +1,22 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
+
+go_library(
+ name = "standalone_lib",
+ srcs = ["main.go"],
+ importpath = "source.monogon.dev/metropolis/node/core/tconsole/standalone",
+ visibility = ["//visibility:private"],
+ deps = [
+ "//metropolis/node/core/network",
+ "//metropolis/node/core/roleserve",
+ "//metropolis/node/core/tconsole",
+ "//metropolis/proto/common",
+ "//osbase/event/memory",
+ "//osbase/supervisor",
+ ],
+)
+
+go_binary(
+ name = "standalone",
+ embed = [":standalone_lib"],
+ visibility = ["//visibility:public"],
+)
diff --git a/metropolis/node/core/tconsole/standalone/main.go b/metropolis/node/core/tconsole/standalone/main.go
new file mode 100644
index 0000000..65b3976
--- /dev/null
+++ b/metropolis/node/core/tconsole/standalone/main.go
@@ -0,0 +1,90 @@
+package main
+
+// This is a standalone tconsole test application which works independently from
+// a Metropolis instance. It's intended to be used during tconsole development to
+// make the iteration cycle faster (not needing to boot up a whole node just to
+// test the console).
+
+import (
+ "context"
+ "fmt"
+ "log"
+ mrand "math/rand"
+ "net"
+ "os"
+ "os/signal"
+ "time"
+
+ "source.monogon.dev/metropolis/node/core/network"
+ "source.monogon.dev/metropolis/node/core/roleserve"
+ "source.monogon.dev/metropolis/node/core/tconsole"
+ cpb "source.monogon.dev/metropolis/proto/common"
+ "source.monogon.dev/osbase/event/memory"
+ "source.monogon.dev/osbase/supervisor"
+)
+
+func main() {
+ var netV memory.Value[*network.Status]
+ var rolesV memory.Value[*cpb.NodeRoles]
+ var curV memory.Value[*roleserve.CuratorConnection]
+
+ tc, err := tconsole.New(tconsole.TerminalGeneric, "/proc/self/fd/0", &netV, &rolesV, &curV)
+ if err != nil {
+ log.Fatalf("tconsole.New: %v", err)
+ }
+
+ ctx, ctxC := context.WithCancel(context.Background())
+ go func() {
+ <-tc.Quit
+ ctxC()
+ }()
+
+ delay := func(ctx context.Context, d time.Duration) error {
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-time.After(d):
+ return nil
+ }
+ }
+
+ signal.Ignore(os.Interrupt)
+ supervisor.New(ctx, func(ctx context.Context) error {
+ supervisor.Run(ctx, "tconsole", tc.Run)
+ supervisor.Run(ctx, "net-dawdle", func(ctx context.Context) error {
+ supervisor.Signal(ctx, supervisor.SignalHealthy)
+ for {
+ if err := delay(ctx, time.Millisecond*1000); err != nil {
+ return err
+ }
+ netV.Set(&network.Status{
+ ExternalAddress: net.ParseIP(fmt.Sprintf("203.0.113.%d", mrand.Intn(256))),
+ })
+ }
+ })
+ supervisor.Run(ctx, "roles-dawdle", func(ctx context.Context) error {
+ supervisor.Signal(ctx, supervisor.SignalHealthy)
+ for {
+ if err := delay(ctx, time.Millisecond*1200); err != nil {
+ return err
+ }
+ nr := &cpb.NodeRoles{}
+ if mrand.Intn(2) == 0 {
+ nr.KubernetesWorker = &cpb.NodeRoles_KubernetesWorker{}
+ }
+ if mrand.Intn(2) == 0 {
+ nr.ConsensusMember = &cpb.NodeRoles_ConsensusMember{}
+ }
+ if mrand.Intn(2) == 0 {
+ nr.KubernetesController = &cpb.NodeRoles_KubernetesController{}
+ }
+ rolesV.Set(nr)
+ }
+ })
+ supervisor.Signal(ctx, supervisor.SignalHealthy)
+ <-ctx.Done()
+ return ctx.Err()
+ })
+ <-ctx.Done()
+ tc.Cleanup()
+}
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))
+ }
+ }
+}