diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9b064a9a2..ca9c4195c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,3 +33,50 @@ And do not terminate with a dot. ## Do not capitalize pflag description string And do not terminate with a dot. + +# Commands + +## Name a command that shows things after what it renders + +`list`, aliased `ls`, when the answer is a row per thing: a flat table whose +columns are selectable, that `-o json`, `-o tab` and `-o flat` reshape, and +that a script reads. Most of the tree is this. + +`status` when the answer is a composed view of one subject, whose layout +carries meaning that rows cannot: the resource tree of `om instance +status`, the node-column board of `om ccfg status`. Reformatting one of those +as a table would lose information. + +The test is mechanical: a command whose renderer is a `tab=` column spec is a +listing, whatever its subject. `om daemon hb status`, `om daemon relay status` +and `om node relay status` were listings wearing the other name, and are +`list` now. + +## Keep the name a renamed command answered to + +Give the new name to the command, and build the old one from it with `Use` +overridden, the aliases cleared and `Hidden: true`. It stays out of the help +and out of the completion, and the scripts that type it keep working. + +# Rendering + +## Do not use color.Set + +`color.Set` is the imperative form of the fatih/color api: it writes the escape +sequence to the package output, which is the process stdout, as a side effect, +and returns the color to render with. A function composing a string therefore +leaves a bare, never reset sequence on the terminal ahead of whatever it +returns, and the first line rendered afterwards inherits it. That is how a +section holding a comment came out italic in `om config show`. + +Use `color.New`. It builds the same color and writes nothing, and its `Fprint`, +`Sprint` and `SprintFunc` open and close the sequence around the text they +render. + +## Paint where the output is + +An escape sequence belongs to the code writing to the terminal, not to the type +carrying the value. A daemon type is published, read back by api clients and by +the tui, which paints cells of its own: it hands the state over, and the +renderer draws the icon standing for it. + diff --git a/core/commoncmd/config.go b/core/commoncmd/config.go index 5092851dc..b55437e96 100644 --- a/core/commoncmd/config.go +++ b/core/commoncmd/config.go @@ -128,13 +128,13 @@ func ColorizeINI(b []byte) []byte { // Section header if sectionRE.MatchString(line) { - color.Set(color.FgHiYellow, color.Bold).Fprintln(out, line) + color.New(color.FgHiYellow, color.Bold).Fprintln(out, line) continue } // Comment if commentRE.MatchString(line) { - color.Set(color.FgHiBlack, color.Italic).Fprintln(out, line) + color.New(color.FgHiBlack, color.Italic).Fprintln(out, line) continue } @@ -150,13 +150,13 @@ func ColorizeINI(b []byte) []byte { // Colorize key key, scope, scopeFound := strings.Cut(key, "@") - color.Set(color.FgCyan).Fprint(out, key) + color.New(color.FgCyan).Fprint(out, key) if scopeFound { - color.Set(color.FgHiMagenta).Fprint(out, "@"+scope) + color.New(color.FgHiMagenta).Fprint(out, "@"+scope) } // Colorize delimiter - color.Set(color.FgHiBlack).Fprint(out, delim) + color.New(color.FgHiBlack).Fprint(out, delim) // Highlight references in the value referenceMatches := referenceRE.FindAllStringIndex(value, -1) @@ -168,7 +168,7 @@ func ColorizeINI(b []byte) []byte { // Write reference part in green + bold referenceText := value[match[0]:match[1]] - color.Set(color.FgGreen, color.Bold).Fprint(out, referenceText) + color.New(color.FgGreen, color.Bold).Fprint(out, referenceText) lastPos = match[1] } // Write remaining part after last reference @@ -179,7 +179,7 @@ func ColorizeINI(b []byte) []byte { } if inlineComment != "" { - color.Set(color.FgHiBlack, color.Italic).Fprint(out, inlineComment) + color.New(color.FgHiBlack, color.Italic).Fprint(out, inlineComment) } out.WriteString("\n") @@ -204,7 +204,7 @@ func ColorizeINI(b []byte) []byte { // Write reference part in green + bold referenceText := line[match[0]:match[1]] - color.Set(color.FgGreen, color.Bold).Fprint(out, referenceText) + color.New(color.FgGreen, color.Bold).Fprint(out, referenceText) lastPos = match[1] } // Write remaining part after last reference diff --git a/core/commoncmd/config_test.go b/core/commoncmd/config_test.go new file mode 100644 index 000000000..af4323add --- /dev/null +++ b/core/commoncmd/config_test.go @@ -0,0 +1,56 @@ +package commoncmd + +import ( + "bytes" + "testing" + + "github.com/fatih/color" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// colorized renders src with the colors on, and returns what ColorizeINI +// returned and what it wrote past its return, which is meant to be nothing. +func colorized(t *testing.T, src string) (rendered string, leaked string) { + t.Helper() + t.Setenv("NO_COLOR", "") + savedOutput, savedNoColor := color.Output, color.NoColor + buf := bytes.NewBuffer(nil) + color.Output, color.NoColor = buf, false + t.Cleanup(func() { color.Output, color.NoColor = savedOutput, savedNoColor }) + b := ColorizeINI([]byte(src)) + return string(b), buf.String() +} + +// TestColorizeINIWritesNothingOfItsOwn pins that the rendering is the +// returned bytes and nothing else. +// +// It used to set each color with color.Set, which writes the escape sequence +// to the process output as a side effect and returns the color to render +// with. Every colored element therefore left a bare, never reset sequence +// ahead of the whole rendering. +func TestColorizeINIWritesNothingOfItsOwn(t *testing.T) { + _, leaked := colorized(t, "[fs#1]\n# why this flag\ntype = flag # inline\nnodes = {clusternodes}\n") + assert.Empty(t, leaked, "the rendering must be the returned bytes, not a side effect") +} + +// TestColorizeINISectionHeaderCarriesNoCommentAttribute pins the symptom the +// leak showed: the italic of a comment was still on when the first line was +// drawn, so the section header of a commented section was rendered italic. +func TestColorizeINISectionHeaderCarriesNoCommentAttribute(t *testing.T) { + const italic = "\x1b[3m" + for _, src := range []string{ + "[fs#1]\n# why this flag\ntype = flag\n", + "# why this section\n[fs#1]\ntype = flag\n", + "[DEFAULT]\nnodes = *\n\n# why this section\n[fs#1]\ntype = flag\n", + } { + rendered, leaked := colorized(t, src) + require.Empty(t, leaked) + + // The first thing drawn opens with its own attributes, so nothing a + // later line sets can reach it. + first, _, _ := bytes.Cut([]byte(rendered), []byte("\n")) + assert.Truef(t, bytes.HasPrefix(first, []byte("\x1b[")), "the first line must open its own sequence, got %q", first) + assert.NotContainsf(t, string(first), italic, "the first line of %q must not be italic", src) + } +} diff --git a/core/commoncmd/daemon_hb_status.go b/core/commoncmd/daemon_hb_list.go similarity index 56% rename from core/commoncmd/daemon_hb_status.go rename to core/commoncmd/daemon_hb_list.go index f428b5bfc..18dc0748a 100644 --- a/core/commoncmd/daemon_hb_status.go +++ b/core/commoncmd/daemon_hb_list.go @@ -2,10 +2,10 @@ package commoncmd import ( "encoding/json" - "fmt" "sort" "strings" + "github.com/fatih/color" "github.com/spf13/cobra" "github.com/opensvc/om3/v3/core/client" @@ -17,7 +17,7 @@ import ( ) type ( - CmdDaemonHeartbeatStatus struct { + CmdDaemonHeartbeatList struct { Color string Output string NodeSelector string @@ -26,13 +26,57 @@ type ( } ) -func NewCmdDaemonHeartbeatStatus(defaultNodeSelectorFilter string) *cobra.Command { - options := CmdDaemonHeartbeatStatus{ +// heartbeatStreamRow is a row of the heartbeat listing. +// +// The state and the beating flag travel from the node as values, and the +// icons standing for them are drawn here, where the output is. The daemon +// type carrying the values holds no escape sequence: it is published, and +// the tui reads the same values to paint cells of its own. +type heartbeatStreamRow struct { + daemonsubsystem.HeartbeatStreamPeerStatusTableEntry +} + +type heartbeatStreamRows []heartbeatStreamRow + +// Unstructured returns the row the tab renderer reads: what the entry +// carries, plus the two icon columns the default output names. +func (t heartbeatStreamRow) Unstructured() map[string]any { + m := t.HeartbeatStreamPeerStatusTableEntry.Unstructured() + m["state_icon"] = heartbeatStateIcon(t.State) + m["beating_icon"] = heartbeatBeatingIcon(t.IsSingleNode || t.IsBeating) + return m +} + +// heartbeatStateIcon draws the run state of a stream. +func heartbeatStateIcon(state string) string { + switch state { + case "running": + return color.New(color.FgGreen).Sprint("O") + case "stopped", "failed": + return color.New(color.FgRed).Sprint("X") + case "warning": + return color.New(color.FgYellow).Sprint("!") + default: + return color.New(color.FgHiBlack).Sprint("?") + } +} + +// heartbeatBeatingIcon draws whether a stream is beating. +func heartbeatBeatingIcon(beating bool) string { + if beating { + return color.New(color.FgGreen).Sprint("O") + } + return color.New(color.FgRed).Sprint("X") +} + +func NewCmdDaemonHeartbeatList(defaultNodeSelectorFilter string) *cobra.Command { + options := CmdDaemonHeartbeatList{ NodeSelector: defaultNodeSelectorFilter, } cmd := &cobra.Command{ - Use: "status", - Short: fmt.Sprintf("daemon heartbeat status"), + Use: "list", + Short: "list the heartbeat streams and their state", + Aliases: []string{"ls"}, RunE: func(cmd *cobra.Command, args []string) error { return options.Run() }, @@ -46,7 +90,7 @@ func NewCmdDaemonHeartbeatStatus(defaultNodeSelectorFilter string) *cobra.Comman return cmd } -func (t *CmdDaemonHeartbeatStatus) Run() error { +func (t *CmdDaemonHeartbeatList) Run() error { cli, err := client.New() if err != nil { return err @@ -79,7 +123,7 @@ func (t *CmdDaemonHeartbeatStatus) Run() error { isSingleNode := len(data.Cluster.Node) == 1 - table := make(daemonsubsystem.HeartbeatStreamPeerStatusTable, 0) + table := make(heartbeatStreamRows, 0) for nodename, nodeData := range data.Cluster.Node { if nodeMap != nil { if _, ok := nodeMap[nodename]; !ok { @@ -103,7 +147,7 @@ func (t *CmdDaemonHeartbeatStatus) Run() error { } } } - table = append(table, e) + table = append(table, heartbeatStreamRow{e}) } } @@ -128,3 +172,14 @@ func (t *CmdDaemonHeartbeatStatus) Run() error { return nil } + +// NewCmdDaemonHeartbeatStatus is the name the list command answered to +// before the listings were named after what they render: a row per stream. +// It is kept for the readers whose fingers and scripts type it. +func NewCmdDaemonHeartbeatStatus(defaultNodeSelectorFilter string) *cobra.Command { + cmd := NewCmdDaemonHeartbeatList(defaultNodeSelectorFilter) + cmd.Use = "status" + cmd.Aliases = nil + cmd.Hidden = true + return cmd +} diff --git a/core/commoncmd/daemon_hb_list_test.go b/core/commoncmd/daemon_hb_list_test.go new file mode 100644 index 000000000..0b81249a7 --- /dev/null +++ b/core/commoncmd/daemon_hb_list_test.go @@ -0,0 +1,86 @@ +package commoncmd + +import ( + "strings" + "testing" + + "github.com/fatih/color" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/opensvc/om3/v3/daemon/daemonsubsystem" +) + +func heartbeatRow(t *testing.T, state string, isBeating, isSingleNode bool) map[string]any { + t.Helper() + t.Setenv("NO_COLOR", "") + savedNoColor := color.NoColor + color.NoColor = false + t.Cleanup(func() { color.NoColor = savedNoColor }) + + entry := daemonsubsystem.HeartbeatStreamPeerStatusTableEntry{ + HeartbeatStreamPeerStatus: daemonsubsystem.HeartbeatStreamPeerStatus{ + IsBeating: isBeating, + }, + IsSingleNode: isSingleNode, + } + entry.Status.State = state + return heartbeatStreamRow{entry}.Unstructured() +} + +// TestHeartbeatStreamRowDrawsTheIcons pins that the listing composes the +// two icon columns its default output names, from the values the daemon +// type carries. +func TestHeartbeatStreamRowDrawsTheIcons(t *testing.T) { + for _, tc := range []struct { + state string + isBeating bool + isSingleNode bool + stateIcon string + beatingIcon string + }{ + {"running", true, false, "O", "O"}, + {"stopped", false, false, "X", "X"}, + {"failed", false, false, "X", "X"}, + {"warning", true, false, "!", "O"}, + {"", false, false, "?", "X"}, + + // A single node has no peer to beat with, and is not stale for it. + {"running", false, true, "O", "O"}, + } { + m := heartbeatRow(t, tc.state, tc.isBeating, tc.isSingleNode) + stateIcon, ok := m["state_icon"].(string) + require.Truef(t, ok, "state %q has no state_icon", tc.state) + beatingIcon, ok := m["beating_icon"].(string) + require.Truef(t, ok, "state %q has no beating_icon", tc.state) + + assert.Equalf(t, tc.stateIcon, stripANSI(stateIcon), "state icon of %q", tc.state) + assert.Equalf(t, tc.beatingIcon, stripANSI(beatingIcon), "beating icon of %q", tc.state) + assert.Containsf(t, stateIcon, "\x1b[", "the state icon of %q must be colored", tc.state) + assert.Containsf(t, beatingIcon, "\x1b[", "the beating icon of %q must be colored", tc.state) + } +} + +// TestHeartbeatStreamRowKeepsWhatTheEntryCarries pins that the row adds to +// the entry rather than replacing it: the other columns of the default +// output are still resolved. +func TestHeartbeatStreamRowKeepsWhatTheEntryCarries(t *testing.T) { + m := heartbeatRow(t, "running", true, false) + for _, key := range []string{"id", "node", "peer", "type", "desc", "changed_at", "state", "is_beating"} { + assert.Containsf(t, m, key, "%s is named by the default output", key) + } +} + +func stripANSI(s string) string { + for { + i := strings.Index(s, "\x1b[") + if i < 0 { + return s + } + j := strings.IndexByte(s[i:], 'm') + if j < 0 { + return s + } + s = s[:i] + s[i+j+1:] + } +} diff --git a/core/commoncmd/daemon_hb_restart.go b/core/commoncmd/daemon_hb_restart.go index 1ef940dea..2004eba7d 100644 --- a/core/commoncmd/daemon_hb_restart.go +++ b/core/commoncmd/daemon_hb_restart.go @@ -12,18 +12,29 @@ import ( type ( CmdDaemonHeartbeatRestart struct { CmdDaemonSubAction - Name string + Names []string } ) func NewCmdDaemonHeartbeatRestart() *cobra.Command { options := CmdDaemonHeartbeatRestart{} cmd := &cobra.Command{ - Use: "restart NAME", - Short: "restart a daemon heartbeat rx or tx", - Args: cobra.ExactArgs(1), + Use: "restart NAME...", + Short: "restart daemon heartbeat rx or tx streams", + Long: ForProgram("Stop then start the named directions of the configured heartbeats.\n\n" + + HeartbeatStreamNameHelp), + Example: ForProgram(` # restart the receiver of hb#1 on the local node + om daemon hb restart 1.rx + + # restart both streams of hb#1 + om daemon hb restart 1 + + # restart both streams of hb#1 and hb#2 on every node + om daemon hb restart 1 2 --node '*'`), + Args: cobra.MinimumNArgs(1), + ValidArgsFunction: validHeartbeatStreamNames, RunE: func(cmd *cobra.Command, args []string) error { - options.Name = args[0] + options.Names = args return options.Run() }, } @@ -33,8 +44,9 @@ func NewCmdDaemonHeartbeatRestart() *cobra.Command { } func (t *CmdDaemonHeartbeatRestart) Run() error { - fn := func(ctx context.Context, c *client.T, nodename string) (response *http.Response, err error) { - return c.PostDaemonHeartbeatRestart(ctx, nodename, t.Name) - } - return t.CmdDaemonSubAction.Run(fn) + return t.CmdDaemonSubAction.RunForEach(t.Names, func(name string) apiFuncWithNode { + return func(ctx context.Context, c *client.T, nodename string) (response *http.Response, err error) { + return c.PostDaemonHeartbeatRestart(ctx, nodename, name) + } + }) } diff --git a/core/commoncmd/daemon_hb_sign.go b/core/commoncmd/daemon_hb_sign.go index 2f3bb6694..e8c20e9a7 100644 --- a/core/commoncmd/daemon_hb_sign.go +++ b/core/commoncmd/daemon_hb_sign.go @@ -12,18 +12,26 @@ import ( type ( CmdDaemonHeartbeatSign struct { CmdDaemonSubAction - Name string + Names []string } ) func NewCmdHeartbeatSign() *cobra.Command { options := CmdDaemonHeartbeatSign{} cmd := &cobra.Command{ - Use: "sign NAME", + Use: "sign NAME...", Short: "sign a heartbeat disk", - Args: cobra.ExactArgs(1), + Long: ForProgram("Write the signature the nodes of a disk heartbeat claim their slot with.\n\n" + + HeartbeatNameHelp + "\n\nThe heartbeat must be of type disk: this action writes to its dev."), + Example: ForProgram(` # sign the disk of hb#2 on the local node + om daemon hb sign 2 + + # sign the disks of hb#2 and hb#3 on every node + om daemon hb sign 2 3 --node '*'`), + Args: cobra.MinimumNArgs(1), + ValidArgsFunction: validHeartbeatNames, RunE: func(cmd *cobra.Command, args []string) error { - options.Name = args[0] + options.Names = args return options.Run() }, } @@ -33,8 +41,9 @@ func NewCmdHeartbeatSign() *cobra.Command { } func (t *CmdDaemonHeartbeatSign) Run() error { - fn := func(ctx context.Context, c *client.T, nodename string) (response *http.Response, err error) { - return c.PostDaemonHeartbeatSign(ctx, nodename, t.Name) - } - return t.CmdDaemonSubAction.Run(fn) + return t.CmdDaemonSubAction.RunForEach(t.Names, func(name string) apiFuncWithNode { + return func(ctx context.Context, c *client.T, nodename string) (response *http.Response, err error) { + return c.PostDaemonHeartbeatSign(ctx, nodename, name) + } + }) } diff --git a/core/commoncmd/daemon_hb_start.go b/core/commoncmd/daemon_hb_start.go index 6ffd3fe62..81304d7de 100644 --- a/core/commoncmd/daemon_hb_start.go +++ b/core/commoncmd/daemon_hb_start.go @@ -2,7 +2,6 @@ package commoncmd import ( "context" - "fmt" "net/http" "github.com/spf13/cobra" @@ -13,18 +12,32 @@ import ( type ( CmdDaemonHeartbeatStart struct { CmdDaemonSubAction - Name string + Names []string } ) func NewCmdDaemonHeartbeatStart() *cobra.Command { options := CmdDaemonHeartbeatStart{} cmd := &cobra.Command{ - Use: "start NAME", - Short: fmt.Sprintf("start a daemon heartbeat rx or tx"), - Args: cobra.ExactArgs(1), + Use: "start NAME...", + Short: "start daemon heartbeat rx or tx streams", + Long: ForProgram("Start the named directions of the configured heartbeats.\n\n" + + HeartbeatStreamNameHelp), + Example: ForProgram(` # start the receiver of hb#1 on the local node + om daemon hb start 1.rx + + # start both streams of hb#1 + om daemon hb start 1 + + # start the sender of hb#1 and the receiver of hb#2 on every node + om daemon hb start 1.tx 2.rx --node '*' + + # the id "om daemon hb ls" shows is accepted as it reads + om daemon hb start hb#1.rx`), + Args: cobra.MinimumNArgs(1), + ValidArgsFunction: validHeartbeatStreamNames, RunE: func(cmd *cobra.Command, args []string) error { - options.Name = args[0] + options.Names = args return options.Run() }, } @@ -34,8 +47,9 @@ func NewCmdDaemonHeartbeatStart() *cobra.Command { } func (t *CmdDaemonHeartbeatStart) Run() error { - fn := func(ctx context.Context, c *client.T, nodename string) (response *http.Response, err error) { - return c.PostDaemonHeartbeatStart(ctx, nodename, t.Name) - } - return t.CmdDaemonSubAction.Run(fn) + return t.CmdDaemonSubAction.RunForEach(t.Names, func(name string) apiFuncWithNode { + return func(ctx context.Context, c *client.T, nodename string) (response *http.Response, err error) { + return c.PostDaemonHeartbeatStart(ctx, nodename, name) + } + }) } diff --git a/core/commoncmd/daemon_hb_stop.go b/core/commoncmd/daemon_hb_stop.go index 8e858d61f..eb8d4324e 100644 --- a/core/commoncmd/daemon_hb_stop.go +++ b/core/commoncmd/daemon_hb_stop.go @@ -2,7 +2,6 @@ package commoncmd import ( "context" - "fmt" "net/http" "github.com/spf13/cobra" @@ -13,18 +12,29 @@ import ( type ( CmdDaemonHeartbeatStop struct { CmdDaemonSubAction - Name string + Names []string } ) func NewCmdDaemonHeartbeatStop() *cobra.Command { options := CmdDaemonHeartbeatStop{} cmd := &cobra.Command{ - Use: "stop NAME", - Short: fmt.Sprintf("stop a daemon heartbeat rx or tx"), - Args: cobra.ExactArgs(1), + Use: "stop NAME...", + Short: "stop daemon heartbeat rx or tx streams", + Long: ForProgram("Stop the named directions of the configured heartbeats.\n\n" + + HeartbeatStreamNameHelp), + Example: ForProgram(` # stop the receiver of hb#1 on the local node + om daemon hb stop 1.rx + + # stop both streams of hb#2 + om daemon hb stop hb#2 + + # stop the sender of hb#2 and the receiver of hb#1 on every node + om daemon hb stop hb#2.tx 1.rx --node '*'`), + Args: cobra.MinimumNArgs(1), + ValidArgsFunction: validHeartbeatStreamNames, RunE: func(cmd *cobra.Command, args []string) error { - options.Name = args[0] + options.Names = args return options.Run() }, } @@ -34,8 +44,9 @@ func NewCmdDaemonHeartbeatStop() *cobra.Command { } func (t *CmdDaemonHeartbeatStop) Run() error { - fn := func(ctx context.Context, c *client.T, nodename string) (response *http.Response, err error) { - return c.PostDaemonHeartbeatStop(ctx, nodename, t.Name) - } - return t.CmdDaemonSubAction.Run(fn) + return t.CmdDaemonSubAction.RunForEach(t.Names, func(name string) apiFuncWithNode { + return func(ctx context.Context, c *client.T, nodename string) (response *http.Response, err error) { + return c.PostDaemonHeartbeatStop(ctx, nodename, name) + } + }) } diff --git a/core/commoncmd/daemon_hb_wipe.go b/core/commoncmd/daemon_hb_wipe.go index f40035832..bc204c280 100644 --- a/core/commoncmd/daemon_hb_wipe.go +++ b/core/commoncmd/daemon_hb_wipe.go @@ -12,18 +12,26 @@ import ( type ( CmdDaemonHeartbeatWipe struct { CmdDaemonSubAction - Name string + Names []string } ) func NewCmdHeartbeatWipe() *cobra.Command { options := CmdDaemonHeartbeatWipe{} cmd := &cobra.Command{ - Use: "wipe NAME", + Use: "wipe NAME...", Short: "wipe a heartbeat disk", - Args: cobra.ExactArgs(1), + Long: ForProgram("Remove the signature the nodes of a disk heartbeat claim their slot with.\n\n" + + HeartbeatNameHelp + "\n\nThe heartbeat must be of type disk: this action writes to its dev."), + Example: ForProgram(` # wipe the disk of hb#2 on the local node + om daemon hb wipe 2 + + # wipe the disks of hb#2 and hb#3 on every node + om daemon hb wipe 2 3 --node '*'`), + Args: cobra.MinimumNArgs(1), + ValidArgsFunction: validHeartbeatNames, RunE: func(cmd *cobra.Command, args []string) error { - options.Name = args[0] + options.Names = args return options.Run() }, } @@ -33,8 +41,9 @@ func NewCmdHeartbeatWipe() *cobra.Command { } func (t *CmdDaemonHeartbeatWipe) Run() error { - fn := func(ctx context.Context, c *client.T, nodename string) (response *http.Response, err error) { - return c.PostDaemonHeartbeatWipe(ctx, nodename, t.Name) - } - return t.CmdDaemonSubAction.Run(fn) + return t.CmdDaemonSubAction.RunForEach(t.Names, func(name string) apiFuncWithNode { + return func(ctx context.Context, c *client.T, nodename string) (response *http.Response, err error) { + return c.PostDaemonHeartbeatWipe(ctx, nodename, name) + } + }) } diff --git a/core/commoncmd/daemon_listener_restart.go b/core/commoncmd/daemon_listener_restart.go index 6436ba509..4fcbce235 100644 --- a/core/commoncmd/daemon_listener_restart.go +++ b/core/commoncmd/daemon_listener_restart.go @@ -2,7 +2,6 @@ package commoncmd import ( "context" - "fmt" "net/http" "github.com/spf13/cobra" @@ -21,8 +20,15 @@ type ( func NewCmdDaemonListenerRestart() *cobra.Command { options := CmdDaemonListenerRestart{} cmd := &cobra.Command{ - Use: "restart NAME", - Short: fmt.Sprintf("restart a daemon listener"), + Use: "restart NAME", + Short: "restart a daemon listener", + Long: ForProgram("Stop then start one of the listeners the daemon serves the api with.\n\n" + + ListenerNameHelp), + Example: ForProgram(` # restart the tcp listener on the local node + om daemon listener restart api.inet + + # restart it on every node + om daemon listener restart api.inet --node '*'`), Args: cobra.ExactArgs(1), ValidArgsFunction: validListenerNames, RunE: func(cmd *cobra.Command, args []string) error { diff --git a/core/commoncmd/daemon_listener_start.go b/core/commoncmd/daemon_listener_start.go index 1da8616ae..bbfe1a762 100644 --- a/core/commoncmd/daemon_listener_start.go +++ b/core/commoncmd/daemon_listener_start.go @@ -2,7 +2,6 @@ package commoncmd import ( "context" - "fmt" "net/http" "github.com/spf13/cobra" @@ -21,8 +20,15 @@ type ( func NewCmdDaemonListenerStart() *cobra.Command { options := CmdDaemonListenerStart{} cmd := &cobra.Command{ - Use: "start NAME", - Short: fmt.Sprintf("start a daemon a listener"), + Use: "start NAME", + Short: "start a daemon listener", + Long: ForProgram("Start one of the listeners the daemon serves the api with.\n\n" + + ListenerNameHelp), + Example: ForProgram(` # start the tcp listener on the local node + om daemon listener start api.inet + + # start it on every node + om daemon listener start api.inet --node '*'`), Args: cobra.ExactArgs(1), ValidArgsFunction: validListenerNames, RunE: func(cmd *cobra.Command, args []string) error { diff --git a/core/commoncmd/daemon_listener_stop.go b/core/commoncmd/daemon_listener_stop.go index d6e0988a8..4cf2cd2a6 100644 --- a/core/commoncmd/daemon_listener_stop.go +++ b/core/commoncmd/daemon_listener_stop.go @@ -2,7 +2,6 @@ package commoncmd import ( "context" - "fmt" "net/http" "github.com/spf13/cobra" @@ -21,8 +20,15 @@ type ( func NewCmdDaemonListenerStop() *cobra.Command { options := CmdDaemonListenerStop{} cmd := &cobra.Command{ - Use: "stop NAME", - Short: fmt.Sprintf("stop a daemon listener"), + Use: "stop NAME", + Short: "stop a daemon listener", + Long: ForProgram("Stop one of the listeners the daemon serves the api with.\n\n" + + ListenerNameHelp), + Example: ForProgram(` # stop the tcp listener on the local node + om daemon listener stop api.inet + + # stop it on every node + om daemon listener stop api.inet --node '*'`), Args: cobra.ExactArgs(1), ValidArgsFunction: validListenerNames, RunE: func(cmd *cobra.Command, args []string) error { diff --git a/core/commoncmd/daemon_relay_status.go b/core/commoncmd/daemon_relay_list.go similarity index 72% rename from core/commoncmd/daemon_relay_status.go rename to core/commoncmd/daemon_relay_list.go index 85f650351..74ab7498c 100644 --- a/core/commoncmd/daemon_relay_status.go +++ b/core/commoncmd/daemon_relay_list.go @@ -13,17 +13,18 @@ import ( ) type ( - CmdDaemonRelayStatus struct { + CmdDaemonRelayList struct { Color string Output string } ) -func NewCmdDaemonRelayStatus() *cobra.Command { - var options CmdDaemonRelayStatus +func NewCmdDaemonRelayList() *cobra.Command { + var options CmdDaemonRelayList cmd := &cobra.Command{ - Use: "status", - Short: "show the local daemon relay clients and last data update time", + Use: "list", + Short: "list the local daemon relay clients and their last data update time", + Aliases: []string{"ls"}, RunE: func(cmd *cobra.Command, args []string) error { return options.Run() }, @@ -34,7 +35,7 @@ func NewCmdDaemonRelayStatus() *cobra.Command { return cmd } -func (t *CmdDaemonRelayStatus) Run() error { +func (t *CmdDaemonRelayList) Run() error { cli, err := client.New() if err != nil { return err @@ -64,3 +65,14 @@ func (t *CmdDaemonRelayStatus) Run() error { }.Print() return nil } + +// NewCmdDaemonRelayStatus is the name the list command answered to before +// the listings were named after what they render: a row per relay client. +// It is kept for the readers whose fingers and scripts type it. +func NewCmdDaemonRelayStatus() *cobra.Command { + cmd := NewCmdDaemonRelayList() + cmd.Use = "status" + cmd.Aliases = nil + cmd.Hidden = true + return cmd +} diff --git a/core/commoncmd/daemon_sub_action.go b/core/commoncmd/daemon_sub_action.go index d364c7b45..08f33510d 100644 --- a/core/commoncmd/daemon_sub_action.go +++ b/core/commoncmd/daemon_sub_action.go @@ -33,6 +33,17 @@ type ( // answers are a problem document or a small json object. const maxSubActionBodySize = 1 << 20 +// RunForEach runs the action once per name, so a command taking several +// names attempts them all rather than stopping on the first that fails, +// and reports every failure. +func (t *CmdDaemonSubAction) RunForEach(names []string, fn func(name string) apiFuncWithNode) error { + var errs error + for _, name := range names { + errs = errors.Join(errs, t.Run(fn(name))) + } + return errs +} + // Run daemon sub-component action func (t *CmdDaemonSubAction) Run(fn apiFuncWithNode) error { if t.NodeSelector == "" { diff --git a/core/commoncmd/daemon_subsystems.go b/core/commoncmd/daemon_subsystems.go index 85893ce06..fe9b822bb 100644 --- a/core/commoncmd/daemon_subsystems.go +++ b/core/commoncmd/daemon_subsystems.go @@ -1,10 +1,15 @@ package commoncmd import ( + "os" + "path/filepath" + "regexp" + "slices" "strings" "github.com/spf13/cobra" + "github.com/opensvc/om3/v3/core/clusterhb" "github.com/opensvc/om3/v3/daemon/daemonenv" ) @@ -76,12 +81,13 @@ func validAuditSubsystems(_ *cobra.Command, args []string, toComplete string) ([ return filterPrefix(AuditSubsystems, toComplete), cobra.ShellCompDirectiveNoFileComp } -// validListenerNames completes the listeners an action may address. +// validListenerNames completes the listeners a start, stop or restart may +// address, which the unix socket listener is not one of. func validListenerNames(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if len(args) > 0 { return nil, cobra.ShellCompDirectiveNoFileComp } - return filterPrefix(daemonenv.ListenerNames, toComplete), cobra.ShellCompDirectiveNoFileComp + return filterPrefix(daemonenv.LifecycleListenerNames, toComplete), cobra.ShellCompDirectiveNoFileComp } func filterPrefix(candidates []string, toComplete string) []string { @@ -96,3 +102,116 @@ func filterPrefix(candidates []string, toComplete string) []string { } return l } + +// HeartbeatNameHelp is the NAME paragraph of the actions addressing a +// heartbeat as a whole, which all take the same argument. +const HeartbeatNameHelp = `NAME is a heartbeat: the index of a hb# section of the cluster +configuration, "1" for "hb#1". The "hb#" prefix the ID column of +"om daemon hb ls" shows is accepted too. A heartbeat the node does not +configure is refused. + +Several names may be given, and the action is attempted on each.` + +// HeartbeatStreamNameHelp is the NAME paragraph of the actions addressing +// one direction of a heartbeat, which is a component of its own. +const HeartbeatStreamNameHelp = `NAME is a heartbeat stream: the index of a hb# section of the +cluster configuration, suffixed with .rx for the receiver or .tx for the +sender, "1.rx" for the receiver of "hb#1". The "hb#" prefix the ID column +of "om daemon hb ls" shows is accepted too. A stream the node does not +configure is refused. + +A heartbeat named without a suffix, "1", addresses both of its streams. + +Several names may be given, and the action is attempted on each.` + +// ListenerNameHelp is the NAME paragraph of the listener lifecycle actions, +// naming the listeners they may be addressed to. There are only two +// listeners, and they are the same on every node, so they are named here +// rather than looked up. +// +// The unix socket listener is named too, saying why it is not a value: a +// reader who knows it exists, from an audit or from a status, would take its +// absence for an oversight. +var ListenerNameHelp = `NAME is: + + ` + daemonenv.ListenerNameInet + ` the listener serving the tcp port + +` + daemonenv.ListenerNameUX + `, the listener serving the unix socket, has no start, stop or restart +of its own: it lives as long as the daemon does, and the request asking +for it travels through it. Restart the daemon to restart it.` + +// heartbeatSectionNames returns the heartbeat sections of the local cluster +// configuration, as "hb#1". +// +// It is a variable so a test can pin the names a host has no configuration +// to hold. +var heartbeatSectionNames = func() []string { + n, err := clusterhb.New() + if err != nil { + return nil + } + return n.HbNames() +} + +// HeartbeatNames returns the heartbeats the local cluster configuration +// defines, named as the actions take them: "1" for the "hb#1" section. +func HeartbeatNames() []string { + sections := heartbeatSectionNames() + l := make([]string, 0, len(sections)) + for _, section := range sections { + l = append(l, strings.TrimPrefix(section, "hb#")) + } + return l +} + +// HeartbeatStreamNames returns the names a stream action may be addressed +// to: each heartbeat the local cluster configuration defines, which stands +// for both of its streams, and each of those streams. +func HeartbeatStreamNames() []string { + names := HeartbeatNames() + l := make([]string, 0, len(names)*3) + for _, name := range names { + l = append(l, name, name+".rx", name+".tx") + } + return l +} + +// validHeartbeatNames completes the heartbeats an action may address. The +// actions take several names, so it keeps completing after the first, less +// the ones already on the line. +func validHeartbeatNames(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return filterPrefix(without(HeartbeatNames(), args), toComplete), cobra.ShellCompDirectiveNoFileComp +} + +// validHeartbeatStreamNames completes the heartbeat streams an action may +// address, and the heartbeats standing for both of theirs. +func validHeartbeatStreamNames(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return filterPrefix(without(HeartbeatStreamNames(), args), toComplete), cobra.ShellCompDirectiveNoFileComp +} + +// without returns the candidates the command line does not already hold. +func without(candidates, taken []string) []string { + l := make([]string, 0, len(candidates)) + for _, candidate := range candidates { + if !slices.Contains(taken, candidate) { + l = append(l, candidate) + } + } + return l +} + +// omWord matches the name of the om command where a help text names it, +// which the word boundaries keep apart from a word ending with it. +var omWord = regexp.MustCompile(`\bom\b`) + +// ForProgram renders a help text written for the om command under the name +// the binary was invoked as, so an ox help does not tell its reader to type +// om. The commands of the two binaries are built here, from one source, and +// the root command names itself the same way. +func ForProgram(s string) string { + name := filepath.Base(os.Args[0]) + if name == "om" { + return s + } + return omWord.ReplaceAllString(s, name) +} diff --git a/core/commoncmd/daemon_subsystems_test.go b/core/commoncmd/daemon_subsystems_test.go index 3492cb398..a45b6cbb1 100644 --- a/core/commoncmd/daemon_subsystems_test.go +++ b/core/commoncmd/daemon_subsystems_test.go @@ -1,9 +1,11 @@ package commoncmd import ( + "os" "strings" "testing" + "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "github.com/opensvc/om3/v3/daemon/daemonenv" @@ -38,3 +40,85 @@ func TestFilterPrefix(t *testing.T) { assert.Equal(t, []string{"icfg", "icfg:"}, filterPrefix(AuditSubsystems, "icf")) assert.Empty(t, filterPrefix(AuditSubsystems, "nosuch")) } + +// TestListenerNameHelpNamesThemAll pins that the help a user reads names +// every listener, rather than a list kept beside them by hand: the ones a +// start, stop or restart may address, and the one that answers to none of +// the three, whose absence would otherwise read as an oversight. +func TestListenerNameHelpNamesThemAll(t *testing.T) { + for _, name := range daemonenv.ListenerNames { + assert.Containsf(t, ListenerNameHelp, name, "%s is missing from the help", name) + } + for _, name := range daemonenv.LifecycleListenerNames { + assert.Containsf(t, daemonenv.ListenerNames, name, "%s is addressable but is no listener", name) + } + assert.NotContains(t, daemonenv.LifecycleListenerNames, daemonenv.ListenerNameUX, + "the unix socket listener lives as long as the daemon does") + assert.Contains(t, ListenerNameHelp, "lives as long as the daemon", + "the help must say why the unix socket listener is not a value") +} + +func TestForProgram(t *testing.T) { + withProgram := func(t *testing.T, path string) { + t.Helper() + saved := os.Args[0] + os.Args[0] = path + t.Cleanup(func() { os.Args[0] = saved }) + } + const help = ` # start it + om daemon hb start 1.rx + + the id "om daemon hb ls" shows, from the om command` + + t.Run("om reads its own name", func(t *testing.T) { + withProgram(t, "/usr/bin/om") + assert.Equal(t, help, ForProgram(help)) + }) + t.Run("ox reads its own name", func(t *testing.T) { + withProgram(t, "/usr/bin/ox") + got := ForProgram(help) + assert.NotContains(t, strings.Fields(got), "om", "an ox help must not tell its reader to type om") + assert.Contains(t, got, "ox daemon hb start 1.rx") + assert.Contains(t, got, `"ox daemon hb ls"`) + + // A word ending with the command name is not the command name. + assert.Contains(t, got, "from the ox command") + }) +} + +// TestHeartbeatStreamNamesHoldTheHeartbeats pins that the completion of a +// stream action offers the heartbeats too, since naming one addresses both +// of its streams. +func TestHeartbeatStreamNamesHoldTheHeartbeats(t *testing.T) { + saved := heartbeatSectionNames + heartbeatSectionNames = func() []string { return []string{"hb#1", "hb#11"} } + t.Cleanup(func() { heartbeatSectionNames = saved }) + + assert.Equal(t, []string{"1", "11"}, HeartbeatNames()) + assert.Equal(t, []string{"1", "1.rx", "1.tx", "11", "11.rx", "11.tx"}, HeartbeatStreamNames()) +} + +func TestWithout(t *testing.T) { + assert.Equal(t, []string{"1.rx"}, without([]string{"1.rx", "1.tx"}, []string{"1.tx"})) + assert.Equal(t, []string{"1.rx", "1.tx"}, without([]string{"1.rx", "1.tx"}, nil)) + assert.Empty(t, without([]string{"1.rx"}, []string{"1.rx"})) +} + +// TestListingsAreNamedList pins the rule CONTRIBUTING.md states: a command whose +// answer is a row per thing is named list, aliased ls, and the name it +// answered to before stays, hidden. +func TestListingsAreNamedList(t *testing.T) { + for name, cmds := range map[string][2]*cobra.Command{ + "daemon hb": {NewCmdDaemonHeartbeatList(""), NewCmdDaemonHeartbeatStatus("")}, + "daemon relay": {NewCmdDaemonRelayList(), NewCmdDaemonRelayStatus()}, + } { + list, legacy := cmds[0], cmds[1] + assert.Equalf(t, "list", list.Use, "%s renders a row per thing", name) + assert.Containsf(t, list.Aliases, "ls", "%s list must answer to ls", name) + assert.Falsef(t, list.Hidden, "%s list must be advertised", name) + + assert.Equalf(t, "status", legacy.Use, "%s must still answer to its former name", name) + assert.Truef(t, legacy.Hidden, "%s status must not be advertised", name) + assert.Emptyf(t, legacy.Aliases, "%s status must not carry the aliases of the name that replaced it", name) + } +} diff --git a/core/om/daemon.go b/core/om/daemon.go index 9581ff62b..6a985b1aa 100644 --- a/core/om/daemon.go +++ b/core/om/daemon.go @@ -47,6 +47,7 @@ func init() { ) cmdDaemonHeartbeat.AddCommand( + commoncmd.NewCmdDaemonHeartbeatList(hostname.Hostname()), commoncmd.NewCmdDaemonHeartbeatStatus(hostname.Hostname()), commoncmd.NewCmdDaemonHeartbeatRestart(), commoncmd.NewCmdDaemonHeartbeatStart(), @@ -63,6 +64,7 @@ func init() { ) cmdDaemonRelay.AddCommand( + commoncmd.NewCmdDaemonRelayList(), commoncmd.NewCmdDaemonRelayStatus(), ) } diff --git a/core/om/factory.go b/core/om/factory.go index 65f4db06e..0354cbb3b 100644 --- a/core/om/factory.go +++ b/core/om/factory.go @@ -1224,11 +1224,12 @@ func newCmdNodeRegister() *cobra.Command { return cmd } -func newCmdNodeRelayStatus() *cobra.Command { - var options commands.CmdNodeRelayStatus +func newCmdNodeRelayList() *cobra.Command { + var options commands.CmdNodeRelayList cmd := &cobra.Command{ - Use: "status", - Short: "show the clients and last data update time of the configured relays", + Use: "list", + Short: "list the clients of the configured relays and their last data update time", + Aliases: []string{"ls"}, RunE: func(cmd *cobra.Command, args []string) error { return options.Run() }, @@ -1239,6 +1240,17 @@ func newCmdNodeRelayStatus() *cobra.Command { return cmd } +// newCmdNodeRelayStatus is the name the list command answered to before the +// listings were named after what they render: a row per relay client. It is +// kept for the readers whose fingers and scripts type it. +func newCmdNodeRelayStatus() *cobra.Command { + cmd := newCmdNodeRelayList() + cmd.Use = "status" + cmd.Aliases = nil + cmd.Hidden = true + return cmd +} + func newCmdNodeConfigUpdate() *cobra.Command { var options commands.CmdNodeConfigUpdate cmd := commoncmd.NewCmdAnyConfigUpdate() diff --git a/core/om/node.go b/core/om/node.go index 24bd650cb..febd875f8 100644 --- a/core/om/node.go +++ b/core/om/node.go @@ -217,6 +217,7 @@ func init() { newCmdNodePushPkg(), ) cmdNodeRelay.AddCommand( + newCmdNodeRelayList(), newCmdNodeRelayStatus(), ) cmdNodeScan.AddCommand( diff --git a/core/omcmd/node_relay_status.go b/core/omcmd/node_relay_list.go similarity index 93% rename from core/omcmd/node_relay_status.go rename to core/omcmd/node_relay_list.go index 966a38e9a..98f8b0af7 100644 --- a/core/omcmd/node_relay_status.go +++ b/core/omcmd/node_relay_list.go @@ -13,13 +13,13 @@ import ( ) type ( - CmdNodeRelayStatus struct { + CmdNodeRelayList struct { OptsGlobal Relays string } ) -func (t *CmdNodeRelayStatus) Run() error { +func (t *CmdNodeRelayList) Run() error { cli, err := client.New() if err != nil { return err diff --git a/core/ox/daemon.go b/core/ox/daemon.go index d734e39db..22b33b4ef 100644 --- a/core/ox/daemon.go +++ b/core/ox/daemon.go @@ -43,6 +43,7 @@ func init() { commoncmd.NewCmdDaemonHeartbeatRestart(), commoncmd.NewCmdDaemonHeartbeatStart(), commoncmd.NewCmdDaemonHeartbeatStop(), + commoncmd.NewCmdDaemonHeartbeatList(""), commoncmd.NewCmdDaemonHeartbeatStatus(""), commoncmd.NewCmdHeartbeatSign(), commoncmd.NewCmdHeartbeatWipe(), @@ -56,6 +57,7 @@ func init() { ) cmdDaemonRelay.AddCommand( + commoncmd.NewCmdDaemonRelayList(), commoncmd.NewCmdDaemonRelayStatus(), ) } diff --git a/core/ox/factory.go b/core/ox/factory.go index 12df754ef..817c49404 100644 --- a/core/ox/factory.go +++ b/core/ox/factory.go @@ -1247,11 +1247,12 @@ func newCmdNodeRegister() *cobra.Command { return cmd } -func newCmdNodeRelayStatus() *cobra.Command { - var options commands.CmdNodeRelayStatus +func newCmdNodeRelayList() *cobra.Command { + var options commands.CmdNodeRelayList cmd := &cobra.Command{ - Use: "status", - Short: "show the clients and last data update time of the configured relays", + Use: "list", + Short: "list the clients of the configured relays and their last data update time", + Aliases: []string{"ls"}, RunE: func(cmd *cobra.Command, args []string) error { return options.Run() }, @@ -1262,6 +1263,17 @@ func newCmdNodeRelayStatus() *cobra.Command { return cmd } +// newCmdNodeRelayStatus is the name the list command answered to before the +// listings were named after what they render: a row per relay client. It is +// kept for the readers whose fingers and scripts type it. +func newCmdNodeRelayStatus() *cobra.Command { + cmd := newCmdNodeRelayList() + cmd.Use = "status" + cmd.Aliases = nil + cmd.Hidden = true + return cmd +} + func newCmdNodeSysreport() *cobra.Command { var options commands.CmdNodeSysreport cmd := &cobra.Command{ diff --git a/core/ox/node.go b/core/ox/node.go index 33009604d..3048b2d5e 100644 --- a/core/ox/node.go +++ b/core/ox/node.go @@ -203,6 +203,7 @@ func init() { newCmdNodeValidateConfig(), ) cmdNodeRelay.AddCommand( + newCmdNodeRelayList(), newCmdNodeRelayStatus(), ) cmdNodeSchedule.AddCommand( diff --git a/core/oxcmd/node_relay_status.go b/core/oxcmd/node_relay_list.go similarity index 93% rename from core/oxcmd/node_relay_status.go rename to core/oxcmd/node_relay_list.go index 6aac3decd..4d92f4aec 100644 --- a/core/oxcmd/node_relay_status.go +++ b/core/oxcmd/node_relay_list.go @@ -13,13 +13,13 @@ import ( ) type ( - CmdNodeRelayStatus struct { + CmdNodeRelayList struct { OptsGlobal Relays string } ) -func (t *CmdNodeRelayStatus) Run() error { +func (t *CmdNodeRelayList) Run() error { cli, err := client.New() if err != nil { return err diff --git a/daemon/api/api.yaml b/daemon/api/api.yaml index 2067035e5..daf9f54a7 100644 --- a/daemon/api/api.yaml +++ b/daemon/api/api.yaml @@ -5266,7 +5266,19 @@ components: DaemonHeartbeatName: type: string example: 1.rx - description: Heartbeat name, example '1.rx' for heartbeat receiver of 'hb#1' section + description: | + Heartbeat name. + + A stream action (start, stop, restart) takes a stream: the index of a + 'hb#' section of the cluster configuration suffixed with '.rx' + for the receiver or '.tx' for the sender, '1.rx' for the receiver of + 'hb#1'. A heartbeat named without a suffix, '1', addresses both of + its streams. A disk action (sign, wipe) takes the heartbeat itself, + '1' for 'hb#1'. + + The 'hb#' prefix a heartbeat status shows in a stream id is accepted + in both, so a name read there can be sent back. A name the node does + not configure is refused. DaemonHeartbeatStream: allOf: @@ -5341,6 +5353,11 @@ components: The listeners are named the same here and in the audit subsystem list: api.ux serves the unix socket, api.inet the tcp port. + Only api.inet answers to a start, stop or restart. api.ux lives as + long as the daemon does, and the request asking for it travels + through it, so the three actions refuse it: restart the daemon to + restart it. + DaemonLocal: type: object required: diff --git a/daemon/api/codegen_server_gen.go b/daemon/api/codegen_server_gen.go index 8e605b4cd..5c16286c5 100644 --- a/daemon/api/codegen_server_gen.go +++ b/daemon/api/codegen_server_gen.go @@ -6756,261 +6756,266 @@ var swaggerSpec = []string{ "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", } // decodeSpec returns the embedded OpenAPI spec as raw JSON bytes, diff --git a/daemon/api/codegen_type_gen.go b/daemon/api/codegen_type_gen.go index 0bf64f98e..8f7735947 100644 --- a/daemon/api/codegen_type_gen.go +++ b/daemon/api/codegen_type_gen.go @@ -1066,7 +1066,18 @@ type DRBDConfig struct { Data []byte `json:"data"` } -// DaemonHeartbeatName Heartbeat name, example '1.rx' for heartbeat receiver of 'hb#1' section +// DaemonHeartbeatName Heartbeat name. +// +// A stream action (start, stop, restart) takes a stream: the index of a +// 'hb#' section of the cluster configuration suffixed with '.rx' +// for the receiver or '.tx' for the sender, '1.rx' for the receiver of +// 'hb#1'. A heartbeat named without a suffix, '1', addresses both of +// its streams. A disk action (sign, wipe) takes the heartbeat itself, +// '1' for 'hb#1'. +// +// The 'hb#' prefix a heartbeat status shows in a stream id is accepted +// in both, so a name read there can be sent back. A name the node does +// not configure is refused. type DaemonHeartbeatName = string // DaemonListener defines model for DaemonListener. @@ -1084,6 +1095,11 @@ type DaemonListener struct { // // The listeners are named the same here and in the audit subsystem // list: api.ux serves the unix socket, api.inet the tcp port. +// +// Only api.inet answers to a start, stop or restart. api.ux lives as +// long as the daemon does, and the request asking for it travels +// through it, so the three actions refuse it: restart the daemon to +// restart it. type DaemonListenerName string // DaemonPid defines model for DaemonPid. @@ -2376,7 +2392,18 @@ type RidOptional = string // Roles defines model for Roles. type Roles = []Role -// InPathHeartbeatName Heartbeat name, example '1.rx' for heartbeat receiver of 'hb#1' section +// InPathHeartbeatName Heartbeat name. +// +// A stream action (start, stop, restart) takes a stream: the index of a +// 'hb#' section of the cluster configuration suffixed with '.rx' +// for the receiver or '.tx' for the sender, '1.rx' for the receiver of +// 'hb#1'. A heartbeat named without a suffix, '1', addresses both of +// its streams. A disk action (sign, wipe) takes the heartbeat itself, +// '1' for 'hb#1'. +// +// The 'hb#' prefix a heartbeat status shows in a stream id is accepted +// in both, so a name read there can be sent back. A name the node does +// not configure is refused. type InPathHeartbeatName = DaemonHeartbeatName // InPathKind defines model for inPathKind. @@ -2386,6 +2413,11 @@ type InPathKind = Kind // // The listeners are named the same here and in the audit subsystem // list: api.ux serves the unix socket, api.inet the tcp port. +// +// Only api.inet answers to a start, stop or restart. api.ux lives as +// long as the daemon does, and the request asking for it travels +// through it, so the three actions refuse it: restart the daemon to +// restart it. type InPathListenerName = DaemonListenerName // InPathName defines model for inPathName. diff --git a/daemon/daemonapi/lib_heartbeat.go b/daemon/daemonapi/lib_heartbeat.go new file mode 100644 index 000000000..1602a8673 --- /dev/null +++ b/daemon/daemonapi/lib_heartbeat.go @@ -0,0 +1,126 @@ +package daemonapi + +import ( + "fmt" + "slices" + "strings" + + "github.com/opensvc/om3/v3/core/clusterhb" + "github.com/opensvc/om3/v3/daemon/api" +) + +// heartbeatStreamSuffixes are the two directions a heartbeat runs in, each a +// component of its own, started and stopped on its own. +var heartbeatStreamSuffixes = []string{"rx", "tx"} + +// configuredHeartbeatNames returns the heartbeat section names the merged node +// and cluster configuration defines, as "hb#1". +// +// It is a variable so a test can pin the names a node has no configuration to +// hold. +var configuredHeartbeatNames = func() ([]string, error) { + n, err := clusterhb.New() + if err != nil { + return nil, err + } + return n.HbNames(), nil +} + +// heartbeatName returns the configuration section of the heartbeat an action +// addresses, "hb#1" for "1". +// +// The "hb#" prefix "om daemon hb ls" shows in a stream id is accepted too, +// so a name read there can be typed back. +func heartbeatName(name api.InPathHeartbeatName) (string, error) { + names, err := configuredHeartbeatNames() + if err != nil { + return "", err + } + section := sectionOfHeartbeatName(name) + if slices.Contains(names, section) { + return section, nil + } + return "", fmt.Errorf("unknown heartbeat: %s%s", name, expectedHeartbeatNames(names)) +} + +// heartbeatStreamNames returns the component names of the heartbeat streams an +// action addresses: the one a stream name designates, "hb#1.rx" for "1.rx", or +// both of the ones a heartbeat name does, "hb#1.rx" and "hb#1.tx" for "1". +// +// The two streams of a heartbeat run and stop on their own, which is why they +// are addressed on their own. They are one thing to a caller naming the +// heartbeat, who had to name both to say so. +// +// The action is published as a message the heartbeat janitor subscribes to by +// component name, so a name no stream answers to is accepted, queued and acted +// on by nobody: "1.rxx" was a no-op the caller was told nothing about. +func heartbeatStreamNames(name api.InPathHeartbeatName) ([]string, error) { + names, err := configuredHeartbeatNames() + if err != nil { + return nil, err + } + stream := sectionOfHeartbeatName(name) + for _, section := range names { + if stream == section { + l := make([]string, 0, len(heartbeatStreamSuffixes)) + for _, suffix := range heartbeatStreamSuffixes { + l = append(l, section+"."+suffix) + } + return l, nil + } + for _, suffix := range heartbeatStreamSuffixes { + if stream == section+"."+suffix { + return []string{stream}, nil + } + } + } + return nil, fmt.Errorf("unknown heartbeat stream: %s%s", name, expectedHeartbeatStreamNames(names)) +} + +// sectionOfHeartbeatName returns the name as the configuration spells it, +// whether the caller typed the "hb#" prefix or not. +func sectionOfHeartbeatName(name api.InPathHeartbeatName) string { + return "hb#" + strings.TrimPrefix(string(name), "hb#") +} + +// expectedHeartbeatNames renders the tail of the error a refused heartbeat +// name is reported with, naming the heartbeats the node would have accepted. +func expectedHeartbeatNames(names []string) string { + l := shortHeartbeatNames(names) + if len(l) == 0 { + return noHeartbeatConfigured + } + return ", expected one of " + strings.Join(l, ", ") +} + +// expectedHeartbeatStreamNames renders the same tail for a refused stream +// name, naming the streams and then the heartbeats, which stand for both of +// their streams. +func expectedHeartbeatStreamNames(names []string) string { + short := shortHeartbeatNames(names) + if len(short) == 0 { + return noHeartbeatConfigured + } + l := make([]string, 0, len(short)*len(heartbeatStreamSuffixes)) + for _, name := range short { + for _, suffix := range heartbeatStreamSuffixes { + l = append(l, name+"."+suffix) + } + } + return ", expected one of " + strings.Join(l, ", ") + + ", or " + strings.Join(short, ", ") + " for both streams of a heartbeat" +} + +// noHeartbeatConfigured is the tail of the error a name is refused with when +// no name at all would have been accepted. +const noHeartbeatConfigured = ", and this node configures no heartbeat" + +// shortHeartbeatNames renders the section names as the api takes them, "1" +// for "hb#1". +func shortHeartbeatNames(names []string) []string { + l := make([]string, 0, len(names)) + for _, section := range names { + l = append(l, strings.TrimPrefix(section, "hb#")) + } + return l +} diff --git a/daemon/daemonapi/lib_heartbeat_test.go b/daemon/daemonapi/lib_heartbeat_test.go new file mode 100644 index 000000000..87686b039 --- /dev/null +++ b/daemon/daemonapi/lib_heartbeat_test.go @@ -0,0 +1,98 @@ +package daemonapi + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/opensvc/om3/v3/daemon/api" +) + +// withConfiguredHeartbeats pins the heartbeats the node configures, so the +// names are checked against a known list rather than against whatever +// configuration the test host carries. +func withConfiguredHeartbeats(t *testing.T, names ...string) { + t.Helper() + saved := configuredHeartbeatNames + configuredHeartbeatNames = func() ([]string, error) { return names, nil } + t.Cleanup(func() { configuredHeartbeatNames = saved }) +} + +func TestHeartbeatStreamNames(t *testing.T) { + withConfiguredHeartbeats(t, "hb#1", "hb#11") + for _, tc := range []struct { + name string + want []string + }{ + {"1.rx", []string{"hb#1.rx"}}, + {"1.tx", []string{"hb#1.tx"}}, + {"11.rx", []string{"hb#11.rx"}}, + + // The stream id "om daemon hb ls" shows carries the prefix, so + // a name read there is typed back as it was read. + {"hb#1.rx", []string{"hb#1.rx"}}, + + // A heartbeat is both of its streams: naming it is naming them, + // which the caller had to spell out. + {"1", []string{"hb#1.rx", "hb#1.tx"}}, + {"hb#11", []string{"hb#11.rx", "hb#11.tx"}}, + } { + got, err := heartbeatStreamNames(api.InPathHeartbeatName(tc.name)) + require.NoErrorf(t, err, "%s must be accepted", tc.name) + assert.Equal(t, tc.want, got) + } +} + +func TestHeartbeatStreamNamesRefusesWhatNoStreamAnswersTo(t *testing.T) { + withConfiguredHeartbeats(t, "hb#1", "hb#11") + // "1.rxx" is the one to refuse loudest: it was accepted, queued, and + // acted on by nobody. "2.rx" is a stream of a heartbeat this node does + // not configure, and "2" a heartbeat it does not configure at all. + for _, name := range []string{"", "1.rxx", "1.RX", "2.rx", "2", "1.rx.tx"} { + _, err := heartbeatStreamNames(api.InPathHeartbeatName(name)) + require.Errorf(t, err, "%s must not be accepted", name) + assert.Containsf(t, err.Error(), "1.rx, 1.tx, 11.rx, 11.tx", "the error must name the streams that exist, got %s", err) + assert.Containsf(t, err.Error(), "or 1, 11 for both streams", "the error must say a heartbeat names both, got %s", err) + } +} + +func TestHeartbeatName(t *testing.T) { + withConfiguredHeartbeats(t, "hb#1", "hb#11") + for _, tc := range []struct { + name string + want string + }{ + {"1", "hb#1"}, + {"11", "hb#11"}, + {"hb#1", "hb#1"}, + } { + got, err := heartbeatName(api.InPathHeartbeatName(tc.name)) + require.NoErrorf(t, err, "%s must be accepted", tc.name) + assert.Equal(t, tc.want, got) + } + for _, name := range []string{"", "2", "1.rx", "hb#"} { + _, err := heartbeatName(api.InPathHeartbeatName(name)) + require.Errorf(t, err, "%s must not be accepted", name) + assert.Containsf(t, err.Error(), "expected one of 1, 11", "the error must name the heartbeats that exist, got %s", err) + } +} + +func TestHeartbeatNameOnANodeWithNoHeartbeat(t *testing.T) { + withConfiguredHeartbeats(t) + for _, err := range []error{ + second(heartbeatName(api.InPathHeartbeatName("1"))), + secondOfSlice(heartbeatStreamNames(api.InPathHeartbeatName("1.rx"))), + } { + require.Error(t, err) + assert.Contains(t, err.Error(), "configures no heartbeat") + } +} + +func second(_ string, err error) error { + return err +} + +func secondOfSlice(_ []string, err error) error { + return err +} diff --git a/daemon/daemonapi/lib_listener.go b/daemon/daemonapi/lib_listener.go index 1329c8269..f76f9b413 100644 --- a/daemon/daemonapi/lib_listener.go +++ b/daemon/daemonapi/lib_listener.go @@ -22,3 +22,22 @@ func listenerName(name api.InPathListenerName) (string, error) { } return s, nil } + +// lifecycleListenerName returns the name of the listener a start, stop or +// restart addresses. +// +// The unix socket listener answers to none of the three: it lives as long as +// the daemon does, and the request asking for its restart travels through it. +// It used to be handed the message, log that it ignored it, and leave the +// caller told the action was queued. +func lifecycleListenerName(name api.InPathListenerName, action string) (string, error) { + s, err := listenerName(name) + if err != nil { + return "", err + } + if !slices.Contains(daemonenv.LifecycleListenerNames, s) { + return "", fmt.Errorf("%s has no %s of its own: it lives as long as the daemon does, expected one of %s", + s, action, strings.Join(daemonenv.LifecycleListenerNames, ", ")) + } + return s, nil +} diff --git a/daemon/daemonapi/lib_listener_test.go b/daemon/daemonapi/lib_listener_test.go index 2bf048c3d..92b1fe8a8 100644 --- a/daemon/daemonapi/lib_listener_test.go +++ b/daemon/daemonapi/lib_listener_test.go @@ -35,3 +35,28 @@ func TestListenerNameRefusesWhatNoListenerAnswersTo(t *testing.T) { assert.Contains(t, err.Error(), daemonenv.ListenerNameInet, "the error must name the listeners that exist") } } + +// TestLifecycleListenerNameRefusesTheOneWithNoLifecycle pins that the +// three actions refuse the unix socket listener rather than publishing a +// message it receives, ignores, and leaves the caller told was queued. +func TestLifecycleListenerNameRefusesTheOneWithNoLifecycle(t *testing.T) { + for _, action := range []string{"start", "stop", "restart"} { + got, err := lifecycleListenerName(api.InPathListenerName(daemonenv.ListenerNameInet), action) + require.NoErrorf(t, err, "%s must be addressable for a %s", daemonenv.ListenerNameInet, action) + assert.Equal(t, daemonenv.ListenerNameInet, got) + + _, err = lifecycleListenerName(api.InPathListenerName(daemonenv.ListenerNameUX), action) + require.Errorf(t, err, "%s must refuse a %s", daemonenv.ListenerNameUX, action) + assert.Contains(t, err.Error(), "lives as long as the daemon", "the error must say why") + assert.Contains(t, err.Error(), daemonenv.ListenerNameInet, "the error must name the listener that answers") + } +} + +// TestLifecycleListenerNameStillRefusesAnUnknownName keeps the name check +// in front of the lifecycle check: an unknown name is not a listener with +// no lifecycle, it is no listener at all. +func TestLifecycleListenerNameStillRefusesAnUnknownName(t *testing.T) { + _, err := lifecycleListenerName(api.InPathListenerName("nope"), "restart") + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown listener") +} diff --git a/daemon/daemonapi/lib_post_daemon_sub_action.go b/daemon/daemonapi/lib_post_daemon_sub_action.go index 579f2ae4c..cd1c36eeb 100644 --- a/daemon/daemonapi/lib_post_daemon_sub_action.go +++ b/daemon/daemonapi/lib_post_daemon_sub_action.go @@ -2,6 +2,7 @@ package daemonapi import ( "net/http" + "strings" "github.com/labstack/echo/v4" @@ -11,10 +12,21 @@ import ( "github.com/opensvc/om3/v3/util/pubsub" ) -func (a *DaemonAPI) postDaemonSubAction(ctx echo.Context, nodename api.InPathNodeName, action, localName string, fn func(c *client.T) (*http.Response, error)) error { - if len(localName) == 0 { +// postDaemonSubAction publishes the action for each of the components the +// request names. +// +// A name can designate more than one component: a heartbeat is both of its +// streams, which run and stop on their own but are one thing to the caller +// naming the heartbeat. +func (a *DaemonAPI) postDaemonSubAction(ctx echo.Context, nodename api.InPathNodeName, action string, localNames []string, fn func(c *client.T) (*http.Response, error)) error { + if len(localNames) == 0 { return JSONProblemf(ctx, http.StatusBadRequest, "Invalid parameter", "sub component localName is empty") } + for _, localName := range localNames { + if len(localName) == 0 { + return JSONProblemf(ctx, http.StatusBadRequest, "Invalid parameter", "sub component localName is empty") + } + } switch action { case "restart": case "start": @@ -24,9 +36,11 @@ func (a *DaemonAPI) postDaemonSubAction(ctx echo.Context, nodename api.InPathNod } if nodename == a.localhost || nodename == "localhost" { log := LogHandler(ctx, "postDaemonSubAction") - log.Infof("ask to %s component: %s", action, localName) - a.Bus.Pub(&msgbus.DaemonCtl{Component: localName, Action: action}, pubsub.Label{"id", localName}, labelOriginAPI) - return JSONProblemf(ctx, http.StatusOK, "daemon action queued", "%s %s", action, localName) + for _, localName := range localNames { + log.Infof("ask to %s component: %s", action, localName) + a.Bus.Pub(&msgbus.DaemonCtl{Component: localName, Action: action}, pubsub.Label{"id", localName}, labelOriginAPI) + } + return JSONProblemf(ctx, http.StatusOK, "daemon action queued", "%s %s", action, strings.Join(localNames, ", ")) } return a.proxy(ctx, nodename, fn) } diff --git a/daemon/daemonapi/post_daemon_hb_restart.go b/daemon/daemonapi/post_daemon_hb_restart.go index 08fa965c7..36226fe4d 100644 --- a/daemon/daemonapi/post_daemon_hb_restart.go +++ b/daemon/daemonapi/post_daemon_hb_restart.go @@ -1,7 +1,6 @@ package daemonapi import ( - "fmt" "net/http" "github.com/labstack/echo/v4" @@ -15,7 +14,11 @@ func (a *DaemonAPI) PostDaemonHeartbeatRestart(ctx echo.Context, nodename api.In return err } nodename = a.parseNodename(nodename) - return a.postDaemonSubAction(ctx, nodename, "restart", fmt.Sprintf("hb#%s", name), func(c *client.T) (*http.Response, error) { + localNames, err := heartbeatStreamNames(name) + if err != nil { + return JSONProblemf(ctx, http.StatusBadRequest, "Invalid parameter", "%s", err) + } + return a.postDaemonSubAction(ctx, nodename, "restart", localNames, func(c *client.T) (*http.Response, error) { return c.PostDaemonHeartbeatRestart(ctx.Request().Context(), nodename, name) }) } diff --git a/daemon/daemonapi/post_daemon_hb_sign.go b/daemon/daemonapi/post_daemon_hb_sign.go index 28db439d1..73dd8b694 100644 --- a/daemon/daemonapi/post_daemon_hb_sign.go +++ b/daemon/daemonapi/post_daemon_hb_sign.go @@ -28,33 +28,36 @@ func (a *DaemonAPI) PostDaemonHeartbeatSign(ctx echo.Context, nodename api.InPat func localPostDaemonHeartbeatSign(ctx echo.Context, name api.InPathHeartbeatName) error { log := LogHandler(ctx, "postDaemonHeartbeatSign") + section, err := heartbeatName(name) + if err != nil { + return JSONProblemf(ctx, http.StatusBadRequest, "Invalid parameter", "%s", err) + } var i any - i, err := object.NewCluster(object.WithVolatile(true)) + i, err = object.NewCluster(object.WithVolatile(true)) if err != nil { log.Warnf("new cluster object failed: %v", err) return JSONProblemf(ctx, http.StatusInternalServerError, "new cluster object failed", "%s", err) } config := (i.(configProvider)).Config() - section := "hb#" + string(name) hbType := config.GetString(key.New(section, "type")) if hbType != "disk" { - log.Tracef("sign heartbeat disk refused: unexpected hb#%s.type %s", name, hbType) - return JSONProblemf(ctx, http.StatusBadRequest, "Invalid parameter", "sign heartbeat disk refused: unexpected hb#%s.type %s", name, hbType) + log.Tracef("sign heartbeat disk refused: unexpected %s.type %s", section, hbType) + return JSONProblemf(ctx, http.StatusBadRequest, "Invalid parameter", "sign heartbeat disk refused: unexpected %s.type %s", section, hbType) } devPath := config.GetString(key.New(section, "dev")) if devPath == "" { - log.Warnf("sign heartbeat disk refused: unexpected empty hb#%s.dev", name) - return JSONProblemf(ctx, http.StatusBadRequest, "Invalid parameter", "sign heartbeat disk refused: unexpected empty hb#%s.dev", name) + log.Warnf("sign heartbeat disk refused: unexpected empty %s.dev", section) + return JSONProblemf(ctx, http.StatusBadRequest, "Invalid parameter", "sign heartbeat disk refused: unexpected empty %s.dev", section) } - log.Infof("sign heartbeat disk %s dev %s", name, devPath) + log.Infof("sign heartbeat disk %s dev %s", section, devPath) err = sign.CreateAndFillDisk(devPath) if err != nil { - log.Warnf("sign heartbeat disk %s dev %s: %s", name, devPath, err) - return JSONProblemf(ctx, http.StatusInternalServerError, "Heartbeat disk sign error", "sign heartbeat disk %s dev %s: %s", name, devPath, err) + log.Warnf("sign heartbeat disk %s dev %s: %s", section, devPath, err) + return JSONProblemf(ctx, http.StatusInternalServerError, "Heartbeat disk sign error", "sign heartbeat disk %s dev %s: %s", section, devPath, err) } - return JSONProblemf(ctx, http.StatusOK, "Heartbeat disk signed", "sign heartbeat %s on %s", name, devPath) + return JSONProblemf(ctx, http.StatusOK, "Heartbeat disk signed", "sign heartbeat %s on %s", section, devPath) } diff --git a/daemon/daemonapi/post_daemon_hb_start.go b/daemon/daemonapi/post_daemon_hb_start.go index 0b2a8ffac..744651fb1 100644 --- a/daemon/daemonapi/post_daemon_hb_start.go +++ b/daemon/daemonapi/post_daemon_hb_start.go @@ -1,7 +1,6 @@ package daemonapi import ( - "fmt" "net/http" "github.com/labstack/echo/v4" @@ -15,7 +14,11 @@ func (a *DaemonAPI) PostDaemonHeartbeatStart(ctx echo.Context, nodename api.InPa return err } nodename = a.parseNodename(nodename) - return a.postDaemonSubAction(ctx, nodename, "start", fmt.Sprintf("hb#%s", name), func(c *client.T) (*http.Response, error) { + localNames, err := heartbeatStreamNames(name) + if err != nil { + return JSONProblemf(ctx, http.StatusBadRequest, "Invalid parameter", "%s", err) + } + return a.postDaemonSubAction(ctx, nodename, "start", localNames, func(c *client.T) (*http.Response, error) { return c.PostDaemonHeartbeatStart(ctx.Request().Context(), nodename, name) }) } diff --git a/daemon/daemonapi/post_daemon_hb_stop.go b/daemon/daemonapi/post_daemon_hb_stop.go index 35f5b1eb1..4a6738067 100644 --- a/daemon/daemonapi/post_daemon_hb_stop.go +++ b/daemon/daemonapi/post_daemon_hb_stop.go @@ -1,7 +1,6 @@ package daemonapi import ( - "fmt" "net/http" "github.com/labstack/echo/v4" @@ -15,7 +14,11 @@ func (a *DaemonAPI) PostDaemonHeartbeatStop(ctx echo.Context, nodename api.InPat return err } nodename = a.parseNodename(nodename) - return a.postDaemonSubAction(ctx, nodename, "stop", fmt.Sprintf("hb#%s", name), func(c *client.T) (*http.Response, error) { + localNames, err := heartbeatStreamNames(name) + if err != nil { + return JSONProblemf(ctx, http.StatusBadRequest, "Invalid parameter", "%s", err) + } + return a.postDaemonSubAction(ctx, nodename, "stop", localNames, func(c *client.T) (*http.Response, error) { return c.PostDaemonHeartbeatStop(ctx.Request().Context(), nodename, name) }) } diff --git a/daemon/daemonapi/post_daemon_hb_wipe.go b/daemon/daemonapi/post_daemon_hb_wipe.go index 017998c1c..b2ba8d11c 100644 --- a/daemon/daemonapi/post_daemon_hb_wipe.go +++ b/daemon/daemonapi/post_daemon_hb_wipe.go @@ -28,25 +28,28 @@ func (a *DaemonAPI) PostDaemonHeartbeatWipe(ctx echo.Context, nodename api.InPat func localPostDaemonHeartbeatWipe(ctx echo.Context, name api.InPathHeartbeatName) error { log := LogHandler(ctx, "postDaemonHeartbeatWipe") + section, err := heartbeatName(name) + if err != nil { + return JSONProblemf(ctx, http.StatusBadRequest, "Invalid parameter", "%s", err) + } var i any - i, err := object.NewCluster(object.WithVolatile(true)) + i, err = object.NewCluster(object.WithVolatile(true)) if err != nil { log.Warnf("new cluster object failed: %v", err) return JSONProblemf(ctx, http.StatusInternalServerError, "NewCluster", "new cluster object failed: %v", err) } config := (i.(configProvider)).Config() - section := "hb#" + string(name) hbType := config.GetString(key.New(section, "type")) if hbType != "disk" { - log.Tracef("refuse to wipe heartbeat disk: unexpected hb#%s.type %s", name, hbType) - return JSONProblemf(ctx, http.StatusBadRequest, "Invalid parameter", "refuse to wipe heartbeat disk: unexpected hb#%s.type %s", name, hbType) + log.Tracef("refuse to wipe heartbeat disk: unexpected %s.type %s", section, hbType) + return JSONProblemf(ctx, http.StatusBadRequest, "Invalid parameter", "refuse to wipe heartbeat disk: unexpected %s.type %s", section, hbType) } devPath := config.GetString(key.New(section, "dev")) if devPath == "" { - log.Warnf("refuse to wipe heartbeat disk: unexpected empty hb#%s.dev", name) - return JSONProblemf(ctx, http.StatusBadRequest, "Invalid parameter", "refuse to wipe heartbeat disk: unexpected empty hb#%s.dev", name) + log.Warnf("refuse to wipe heartbeat disk: unexpected empty %s.dev", section) + return JSONProblemf(ctx, http.StatusBadRequest, "Invalid parameter", "refuse to wipe heartbeat disk: unexpected empty %s.dev", section) } hasSignature, err := sign.EnsureSignature(devPath) @@ -56,16 +59,16 @@ func localPostDaemonHeartbeatWipe(ctx echo.Context, name api.InPathHeartbeatName } if !hasSignature { - log.Infof("heartbeat %s dev %s has no signature, nothing to wipe", name, devPath) - return JSONProblemf(ctx, http.StatusBadRequest, "Invalid parameter", "heartbeat %s dev %s has no signature, nothing to wipe", name, devPath) + log.Infof("heartbeat %s dev %s has no signature, nothing to wipe", section, devPath) + return JSONProblemf(ctx, http.StatusBadRequest, "Invalid parameter", "heartbeat %s dev %s has no signature, nothing to wipe", section, devPath) } - log.Infof("wipe heartbeat %s dev %s", name, devPath) + log.Infof("wipe heartbeat %s dev %s", section, devPath) err = sign.RemoveHeaderFromDisk(devPath) if err != nil { - log.Warnf("wipe heartbeat disk %s dev %s failed: remove header: %s", name, devPath, err) - return JSONProblemf(ctx, http.StatusInternalServerError, "RemoveHeaderFromDisk", "wipe heartbeat disk %s dev %s failed: remove header: %s", name, devPath, err) + log.Warnf("wipe heartbeat disk %s dev %s failed: remove header: %s", section, devPath, err) + return JSONProblemf(ctx, http.StatusInternalServerError, "RemoveHeaderFromDisk", "wipe heartbeat disk %s dev %s failed: remove header: %s", section, devPath, err) } - return JSONProblemf(ctx, http.StatusOK, "heartbeat disk wiped", "wipe heartbeat %s on %s", name, devPath) + return JSONProblemf(ctx, http.StatusOK, "heartbeat disk wiped", "wipe heartbeat %s on %s", section, devPath) } diff --git a/daemon/daemonapi/post_daemon_listener_restart.go b/daemon/daemonapi/post_daemon_listener_restart.go index b610feda8..3c6181c9f 100644 --- a/daemon/daemonapi/post_daemon_listener_restart.go +++ b/daemon/daemonapi/post_daemon_listener_restart.go @@ -14,11 +14,11 @@ func (a *DaemonAPI) PostDaemonListenerRestart(ctx echo.Context, nodename api.InP return err } nodename = a.parseNodename(nodename) - localName, err := listenerName(name) + localName, err := lifecycleListenerName(name, "restart") if err != nil { return JSONProblemf(ctx, http.StatusBadRequest, "Invalid parameter", "%s", err) } - return a.postDaemonSubAction(ctx, nodename, "restart", localName, func(c *client.T) (*http.Response, error) { + return a.postDaemonSubAction(ctx, nodename, "restart", []string{localName}, func(c *client.T) (*http.Response, error) { return c.PostDaemonListenerRestart(ctx.Request().Context(), nodename, name) }) } diff --git a/daemon/daemonapi/post_daemon_listener_start.go b/daemon/daemonapi/post_daemon_listener_start.go index ef8e460fb..930cd8fe9 100644 --- a/daemon/daemonapi/post_daemon_listener_start.go +++ b/daemon/daemonapi/post_daemon_listener_start.go @@ -16,11 +16,11 @@ func (a *DaemonAPI) PostDaemonListenerStart(ctx echo.Context, nodename api.InPat // parseNodename here too: start was the one action of the three not // normalizing the nodename it was given. nodename = a.parseNodename(nodename) - localName, err := listenerName(name) + localName, err := lifecycleListenerName(name, "start") if err != nil { return JSONProblemf(ctx, http.StatusBadRequest, "Invalid parameter", "%s", err) } - return a.postDaemonSubAction(ctx, nodename, "start", localName, func(c *client.T) (*http.Response, error) { + return a.postDaemonSubAction(ctx, nodename, "start", []string{localName}, func(c *client.T) (*http.Response, error) { return c.PostDaemonListenerStart(ctx.Request().Context(), nodename, name) }) } diff --git a/daemon/daemonapi/post_daemon_listener_stop.go b/daemon/daemonapi/post_daemon_listener_stop.go index 819fa9485..e6759d293 100644 --- a/daemon/daemonapi/post_daemon_listener_stop.go +++ b/daemon/daemonapi/post_daemon_listener_stop.go @@ -14,11 +14,11 @@ func (a *DaemonAPI) PostDaemonListenerStop(ctx echo.Context, nodename api.InPath return err } nodename = a.parseNodename(nodename) - localName, err := listenerName(name) + localName, err := lifecycleListenerName(name, "stop") if err != nil { return JSONProblemf(ctx, http.StatusBadRequest, "Invalid parameter", "%s", err) } - return a.postDaemonSubAction(ctx, nodename, "stop", localName, func(c *client.T) (*http.Response, error) { + return a.postDaemonSubAction(ctx, nodename, "stop", []string{localName}, func(c *client.T) (*http.Response, error) { return c.PostDaemonListenerStop(ctx.Request().Context(), nodename, name) }) } diff --git a/daemon/daemonenv/main.go b/daemon/daemonenv/main.go index 9b7922d21..bba50b764 100644 --- a/daemon/daemonenv/main.go +++ b/daemon/daemonenv/main.go @@ -30,6 +30,15 @@ const ( // ListenerNames are the listeners an api action may be addressed to. var ListenerNames = []string{ListenerNameUX, ListenerNameInet} +// LifecycleListenerNames are the listeners a start, stop or restart may be +// addressed to. +// +// The unix socket listener is not one of them: it lives as long as the daemon +// does, and the request asking for its restart travels through it. It used to +// accept the three actions, log that it ignored them, and answer the caller +// that they were queued. +var LifecycleListenerNames = []string{ListenerNameInet} + var ( HTTPPort = 1215 diff --git a/daemon/daemonsubsystem/hb.go b/daemon/daemonsubsystem/hb.go index 05e796034..e2924e0cd 100644 --- a/daemon/daemonsubsystem/hb.go +++ b/daemon/daemonsubsystem/hb.go @@ -2,8 +2,6 @@ package daemonsubsystem import ( "time" - - "github.com/fatih/color" ) type ( @@ -81,6 +79,12 @@ type ( } ) +// Unstructured returns the entry as the tab renderer reads it, which is +// values and the words standing for them. +// +// The icons a listing draws the state and the beating flag as are not here: +// they are escape sequences, and this type is published by the daemon and +// read by the tui, which paints its own cells from these values. func (t HeartbeatStreamPeerStatusTableEntry) Unstructured() map[string]any { stateText := t.Status.State if stateText == "" { @@ -97,25 +101,6 @@ func (t HeartbeatStreamPeerStatusTableEntry) Unstructured() map[string]any { } } - var stateIcon string - switch t.Status.State { - case "running": - stateIcon = color.New(color.FgGreen).Sprint("O") - case "stopped", "failed": - stateIcon = color.New(color.FgRed).Sprint("X") - case "warning": - stateIcon = color.New(color.FgYellow).Sprint("!") - default: - stateIcon = color.New(color.FgHiBlack).Sprint("?") - } - - var beatingIcon string - if t.IsSingleNode || t.IsBeating { - beatingIcon = color.New(color.FgGreen).Sprint("O") - } else { - beatingIcon = color.New(color.FgRed).Sprint("X") - } - peer := t.Peer if peer == "" { peer = "N/A" @@ -132,7 +117,6 @@ func (t HeartbeatStreamPeerStatusTableEntry) Unstructured() map[string]any { "alerts": t.Alerts, "id": t.Status.ID, "state": stateText, - "state_icon": stateIcon, "state_text": stateText, "configured_at": t.Status.ConfiguredAt, "updated_at": t.Status.UpdatedAt, @@ -145,7 +129,6 @@ func (t HeartbeatStreamPeerStatusTableEntry) Unstructured() map[string]any { "last_beating_at": t.LastBeatingAt, "is_beating": t.IsBeating, "beating": beatingText, - "beating_icon": beatingIcon, } } diff --git a/daemon/daemonsubsystem/hb_test.go b/daemon/daemonsubsystem/hb_test.go new file mode 100644 index 000000000..f0fa9d765 --- /dev/null +++ b/daemon/daemonsubsystem/hb_test.go @@ -0,0 +1,64 @@ +package daemonsubsystem + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestHeartbeatUnstructuredHoldsNoEscapeSequence pins the layer this type +// sits in. It is published by the daemon, read by the tui and by the api +// clients, and it used to compose the green "O" and the red "X" a listing +// draws, which are escape sequences. The listing draws them now, from the +// values here. +func TestHeartbeatUnstructuredHoldsNoEscapeSequence(t *testing.T) { + for _, state := range []string{"running", "stopped", "failed", "warning", "", "unknown"} { + for _, isBeating := range []bool{true, false} { + for _, isSingleNode := range []bool{true, false} { + entry := HeartbeatStreamPeerStatusTableEntry{ + Node: "n1", + Peer: "n2", + Type: "unicast", + HeartbeatStreamPeerStatus: HeartbeatStreamPeerStatus{ + IsBeating: isBeating, + }, + IsSingleNode: isSingleNode, + } + entry.Status.State = state + for key, value := range entry.Unstructured() { + s, ok := value.(string) + if !ok { + continue + } + assert.NotContainsf(t, s, "\x1b", "%s holds an escape sequence: %q", key, s) + } + } + } + } +} + +// TestHeartbeatUnstructuredKeepsTheValuesTheListingDrawsFrom pins the keys +// the icons are drawn from, so removing one is a test failure rather than +// an empty column. +func TestHeartbeatUnstructuredKeepsTheValuesTheListingDrawsFrom(t *testing.T) { + entry := HeartbeatStreamPeerStatusTableEntry{} + entry.Status.State = "running" + entry.IsBeating = true + m := entry.Unstructured() + assert.Equal(t, "running", m["state"]) + assert.Equal(t, true, m["is_beating"]) + assert.Equal(t, "beating", m["beating"]) + + entry.IsBeating = false + assert.Equal(t, "stale", entry.Unstructured()["beating"]) +} + +// TestHeartbeatUnstructuredNamesTheUnknownState pins that an empty state +// reads as a word rather than as nothing. +func TestHeartbeatUnstructuredNamesTheUnknownState(t *testing.T) { + entry := HeartbeatStreamPeerStatusTableEntry{} + m := entry.Unstructured() + assert.Equal(t, "unknown", m["state"]) + assert.Equal(t, "N/A", m["peer"]) + assert.Equal(t, "N/A", m["desc"]) +} diff --git a/daemon/listener/lsnrhttpux/main.go b/daemon/listener/lsnrhttpux/main.go index 9b008b8ae..2293c197c 100644 --- a/daemon/listener/lsnrhttpux/main.go +++ b/daemon/listener/lsnrhttpux/main.go @@ -101,15 +101,18 @@ func (t *T) serve(ctx context.Context, errC chan<- error) { t.log.Infof("stopped") } -// janitor startup initial http ux listener, then watch events to stop, start or restart listener. -// events are: DaemonCtl,name=api.ux, ClusterConfigUpdated,node= with changed lsnr addr or port -// TODO: also watch for tls setting changed +// janitor watches the events the ux listener acts on, which are the audit +// sessions starting and stopping. +// +// It subscribes to no daemon control message: this listener has no start, +// stop or restart of its own, it lives as long as the daemon does, and the +// api refuses those actions rather than publishing a message it would +// receive and ignore. func (t *T) janitor(ctx context.Context, errC chan<- error) { defer t.wg.Done() sub := pubsub.SubFromContext(ctx, "daemon.lsnr.http.ux") sub.AddFilter(&msgbus.AuditStart{}) sub.AddFilter(&msgbus.AuditStop{}) - sub.AddFilter(&msgbus.DaemonCtl{}, pubsub.Label{"id", daemonenv.ListenerNameUX}) sub.Start() defer func() { if err := sub.Stop(); err != nil { @@ -127,11 +130,6 @@ func (t *T) janitor(ctx context.Context, errC chan<- error) { t.log.HandleAuditStart(m.Q, m.Subsystems, daemonenv.ListenerNameFamily, daemonenv.ListenerNameUX) case *msgbus.AuditStop: t.log.HandleAuditStop(m.Q, m.Subsystems, daemonenv.ListenerNameFamily, daemonenv.ListenerNameUX) - case *msgbus.DaemonCtl: - // The log level actions were the only ones this - // listener acted on. It has no start, stop or restart - // of its own: it lives as long as the daemon does. - t.log.Infof("daemon control %s asked, ignored", m.Action) } } }