diff --git a/cmd/rcs/console/connect.go b/cmd/rcs/console/connect.go new file mode 100644 index 0000000..3c03651 --- /dev/null +++ b/cmd/rcs/console/connect.go @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: © 2026 OpenCHAMI a Series of LF Projects, LLC +// +// SPDX-License-Identifier: MIT + +package console + +import ( + "os" + + "github.com/spf13/cobra" + + "github.com/OpenCHAMI/ochami/internal/cli" + "github.com/OpenCHAMI/ochami/internal/cli/rcs" + "github.com/OpenCHAMI/ochami/internal/log" +) + +func newConnectCmd() *cobra.Command { + return &cobra.Command{ + Use: "connect [nodeID]", + Short: "Connects to a console", + Long: `Connects to an interactive console session on the specified node. + +See ochami-rcs(1) for more details.`, + Example: ` # Connect to a node console + ochami rcs console connect x0c0s1b0n0`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + cli.HandleToken(cmd) + + nodeID := args[0] + rcsClient := rcs.GetClient(cmd) + err := rcsClient.ConnectConsole(cmd.Context(), nodeID, cli.Token, os.Stdin, os.Stdout) + if err != nil { + log.Logger.Error().Err(err).Msg("failed to connect to console") + cli.LogHelpError(cmd) + os.Exit(1) + } + }, + } +} diff --git a/cmd/rcs/console/console.go b/cmd/rcs/console/console.go new file mode 100644 index 0000000..9c0da5a --- /dev/null +++ b/cmd/rcs/console/console.go @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: © 2026 OpenCHAMI a Series of LF Projects, LLC +// +// SPDX-License-Identifier: MIT + +package console + +import ( + "github.com/spf13/cobra" + + "github.com/OpenCHAMI/ochami/internal/cli" +) + +func NewCmd() *cobra.Command { + // consoleCmd represents the "rcs console" command + var consoleCmd = &cobra.Command{ + Use: "console", + Short: "Console operations", + Long: `Console operations for remote-console. + +See ochami-rcs(1) for more details.`, + Run: func(cmd *cobra.Command, args []string) { + cli.PrintUsageHandleError(cmd) + }, + } + + // Add subcommands + consoleCmd.AddCommand( + newListCmd(), + newShowCmd(), + newConnectCmd(), + ) + + return consoleCmd +} diff --git a/cmd/rcs/console/list.go b/cmd/rcs/console/list.go new file mode 100644 index 0000000..f49a238 --- /dev/null +++ b/cmd/rcs/console/list.go @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: © 2026 OpenCHAMI a Series of LF Projects, LLC +// +// SPDX-License-Identifier: MIT + +package console + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/OpenCHAMI/ochami/internal/cli" + "github.com/OpenCHAMI/ochami/internal/cli/rcs" + "github.com/OpenCHAMI/ochami/internal/log" + "github.com/OpenCHAMI/ochami/pkg/format" +) + +func newListCmd() *cobra.Command { + listCmd := &cobra.Command{ + Use: "list", + Short: "Returns a list of the available consoles", + Long: `Returns a list of the available consoles. + +See ochami-rcs(1) for more details.`, + Example: ` # List available consoles + ochami rcs console list`, + Run: func(cmd *cobra.Command, args []string) { + cli.HandleToken(cmd) + + rcsClient := rcs.GetClient(cmd) + consoles, err := rcsClient.ListConsoles(cli.Token) + if err != nil { + log.Logger.Error().Err(err).Msg("failed to list consoles") + cli.LogHelpError(cmd) + os.Exit(1) + } + if outBytes, err := format.MarshalData(consoles, cli.FormatOutput); err != nil { + log.Logger.Error().Err(err).Msg("failed to format output") + cli.LogHelpError(cmd) + os.Exit(1) + } else { + fmt.Println(string(outBytes)) + } + }, + } + + listCmd.Flags().VarP(&cli.FormatOutput, "format-output", "F", "format of output printed to standard output (json,json-pretty,yaml)") + listCmd.RegisterFlagCompletionFunc("format-output", cli.CompletionFormatData) + + return listCmd +} diff --git a/cmd/rcs/console/show.go b/cmd/rcs/console/show.go new file mode 100644 index 0000000..104b5e1 --- /dev/null +++ b/cmd/rcs/console/show.go @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: © 2026 OpenCHAMI a Series of LF Projects, LLC +// +// SPDX-License-Identifier: MIT + +package console + +import ( + "os" + + "github.com/spf13/cobra" + + "github.com/OpenCHAMI/ochami/internal/cli" + "github.com/OpenCHAMI/ochami/internal/cli/rcs" + "github.com/OpenCHAMI/ochami/internal/log" +) + +func newShowCmd() *cobra.Command { + var follow bool + var lines int + + var showCmd = &cobra.Command{ + Use: "show [nodeID]", + Short: "Shows the console", + Long: `Shows console output for the specified node. + +See ochami-rcs(1) for more details.`, + Example: ` # Show console output for a node + ochami rcs console show x0c0s1b0n0`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + cli.HandleToken(cmd) + + follow, err := cmd.Flags().GetBool("follow") + if err != nil { + log.Logger.Error().Err(err).Msg("unable to get follow flag") + cli.LogHelpError(cmd) + os.Exit(1) + } + + lines, err := cmd.Flags().GetInt("lines") + if err != nil { + log.Logger.Error().Err(err).Msg("unable to get lines flag") + cli.LogHelpError(cmd) + os.Exit(1) + } + + nodeID := args[0] + + rcsClient := rcs.GetClient(cmd) + err = rcsClient.ShowConsole(cmd.Context(), nodeID, follow, lines, cli.Token, os.Stdout) + if err != nil { + log.Logger.Error().Err(err).Msg("failed to show console") + cli.LogHelpError(cmd) + os.Exit(1) + } + }, + } + + showCmd.Flags().BoolVarP(&follow, "follow", "f", false, "follow the console output") + showCmd.Flags().IntVarP(&lines, "lines", "n", 100, "number of lines to show from history") + + return showCmd +} diff --git a/cmd/rcs/rcs.go b/cmd/rcs/rcs.go new file mode 100644 index 0000000..ca4b9bb --- /dev/null +++ b/cmd/rcs/rcs.go @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: © 2026 OpenCHAMI a Series of LF Projects, LLC +// +// SPDX-License-Identifier: MIT + +package rcs + +import ( + "os" + + "github.com/spf13/cobra" + + console_cmd "github.com/OpenCHAMI/ochami/cmd/rcs/console" + service_cmd "github.com/OpenCHAMI/ochami/cmd/rcs/service" + "github.com/OpenCHAMI/ochami/internal/cli" +) + +func NewCmd() *cobra.Command { + // rcsCmd represents the rcs command + var rcsCmd = &cobra.Command{ + Use: "rcs", + Args: cobra.NoArgs, + Short: "Manage remote consoles", + Long: `Manage remote consoles via the remote-console service. + +See ochami-rcs(1) for more details.`, + Run: func(cmd *cobra.Command, args []string) { + cli.PrintUsageHandleError(cmd) + os.Exit(0) + }, + } + + // Create flags + rcsCmd.PersistentFlags().String("uri", "", "absolute base URI or relative base path of remote-console") + + // Add subcommands + rcsCmd.AddCommand( + console_cmd.NewCmd(), + service_cmd.NewCmd(), + ) + + return rcsCmd +} diff --git a/cmd/rcs/service/service.go b/cmd/rcs/service/service.go new file mode 100644 index 0000000..4a2e2bb --- /dev/null +++ b/cmd/rcs/service/service.go @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: © 2026 OpenCHAMI a Series of LF Projects, LLC +// +// SPDX-License-Identifier: MIT + +package service + +import ( + "github.com/spf13/cobra" + + "github.com/OpenCHAMI/ochami/internal/cli" +) + +func NewCmd() *cobra.Command { + var serviceCmd = &cobra.Command{ + Use: "service", + Short: "Console service operations", + Long: `Console service operations for remote-console. + +See ochami-rcs(1) for more details.`, + Run: func(cmd *cobra.Command, args []string) { + cli.PrintUsageHandleError(cmd) + }, + } + + serviceCmd.AddCommand( + newStatusCmd(), + ) + + return serviceCmd +} diff --git a/cmd/rcs/service/status.go b/cmd/rcs/service/status.go new file mode 100644 index 0000000..54d7b32 --- /dev/null +++ b/cmd/rcs/service/status.go @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: © 2026 OpenCHAMI a Series of LF Projects, LLC +// +// SPDX-License-Identifier: MIT + +package service + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/OpenCHAMI/ochami/internal/cli" + "github.com/OpenCHAMI/ochami/internal/cli/rcs" + "github.com/OpenCHAMI/ochami/internal/log" + "github.com/OpenCHAMI/ochami/pkg/format" +) + +func newStatusCmd() *cobra.Command { + statusCmd := &cobra.Command{ + Use: "status", + Short: "Returns the status of the console service", + Long: `Returns the status of the console service. + +See ochami-rcs(1) for more details.`, + Example: ` # Get console service status + ochami rcs service status`, + Run: func(cmd *cobra.Command, args []string) { + cli.HandleToken(cmd) + + rcsClient := rcs.GetClient(cmd) + + status, err := rcsClient.GetStatus(cli.Token) + if err != nil { + log.Logger.Error().Err(err).Msg("failed to get console service status") + cli.LogHelpError(cmd) + os.Exit(1) + } + + if outBytes, err := format.MarshalData(status, cli.FormatOutput); err != nil { + log.Logger.Error().Err(err).Msg("failed to format output") + cli.LogHelpError(cmd) + os.Exit(1) + } else { + fmt.Println(string(outBytes)) + } + }, + } + + statusCmd.Flags().VarP(&cli.FormatOutput, "format-output", "F", "format of output printed to standard output (json,json-pretty,yaml)") + statusCmd.RegisterFlagCompletionFunc("format-output", cli.CompletionFormatData) + + return statusCmd +} diff --git a/cmd/root.go b/cmd/root.go index a8657bc..ebfd857 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -22,6 +22,7 @@ import ( config_cmd "github.com/OpenCHAMI/ochami/cmd/config" discover_cmd "github.com/OpenCHAMI/ochami/cmd/discover" pcs_cmd "github.com/OpenCHAMI/ochami/cmd/pcs" + rcs_cmd "github.com/OpenCHAMI/ochami/cmd/rcs" smd_cmd "github.com/OpenCHAMI/ochami/cmd/smd" version_cmd "github.com/OpenCHAMI/ochami/cmd/version" ) @@ -100,6 +101,7 @@ See ochami-config(5) for more details on configuring the ochami config file(s).` bss_cmd.NewCmd(), cloud_init_cmd.NewCmd(), config_cmd.NewCmd(), + rcs_cmd.NewCmd(), discover_cmd.NewCmd(), pcs_cmd.NewCmd(), version_cmd.NewCmd(), diff --git a/go.mod b/go.mod index 7bfb28e..e7b01ef 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/OpenCHAMI/smd/v2 v2.18.0 github.com/elliotchance/pie/v2 v2.9.1 github.com/google/uuid v1.6.0 + github.com/gorilla/websocket v1.5.3 github.com/knadh/koanf/parsers/yaml v1.1.0 github.com/knadh/koanf/providers/file v1.2.0 github.com/knadh/koanf/providers/rawbytes v1.0.0 @@ -26,6 +27,7 @@ require ( github.com/spf13/cobra v1.10.2 github.com/synackd/go-kargs v0.0.1-beta.1 github.com/vbauerster/mpb/v8 v8.10.2 + golang.org/x/term v0.42.0 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index 6e62468..997a92b 100644 --- a/go.sum +++ b/go.sum @@ -65,6 +65,8 @@ github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -156,12 +158,8 @@ github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8= github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y= github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0= -github.com/openchami/boot-service v0.1.5 h1:EqE+dFfU0N4fk2urDVw57aL6y2p07vhkP7gZzMAf31A= -github.com/openchami/boot-service v0.1.5/go.mod h1:SZCVOoQeQxHAYPqSRshjic1uwOOP5P+MbES8FTkGA/0= github.com/openchami/boot-service v0.1.6 h1:LzHm0mu1cP8yAr70WMG7KzdK/alKkhFjTkdpgEB/+0o= github.com/openchami/boot-service v0.1.6/go.mod h1:ftV0yxv5XexP0LQbFAfGVJe8hCfD2jNRFTh+59ueKzw= -github.com/openchami/fabrica v0.4.5 h1:ZokuHjWGXYHz3jq3mNStIopIBAhLp+NQdUxjUHwxKYM= -github.com/openchami/fabrica v0.4.5/go.mod h1:h/0CX1tDwqdmBxk4lm8EtxcMgI6ebaxbz65ic4uozfg= github.com/openchami/fabrica v0.4.7 h1:H63fRqSfiUud/jlPo3NS7tXEtSyXBlgWZ7MDhJuTRnM= github.com/openchami/fabrica v0.4.7/go.mod h1:h/0CX1tDwqdmBxk4lm8EtxcMgI6ebaxbz65ic4uozfg= github.com/openchami/schemas v0.0.0-20250625220233-9aad17a286c4 h1:89rudSw0TeedlHbGr5L9WEW9lJ3yMEtY2EgxoC7EGso= @@ -225,6 +223,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 7b853a1..06392c0 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -306,6 +306,10 @@ func GetBaseURISMD(cmd *cobra.Command) (string, error) { return GetBaseURI(cmd, config.ServiceSMD) } +func GetBaseURIRCS(cmd *cobra.Command) (string, error) { + return GetBaseURI(cmd, config.ServiceRCS) +} + func GetBaseURI(cmd *cobra.Command, serviceName config.ServiceName) (string, error) { // Precedence of getting base URI for requests (higher numbers override // all preceding numbers): @@ -373,6 +377,8 @@ func GetBaseURI(cmd *cobra.Command, serviceName config.ServiceName) (string, err ccc.PCS.URI = cmd.Flag("uri").Value.String() case config.ServiceSMD: ccc.SMD.URI = cmd.Flag("uri").Value.String() + case config.ServiceRCS: + ccc.RCS.URI = cmd.Flag("uri").Value.String() default: return "", fmt.Errorf("unknown service %q specified when generating base URI", serviceName) } diff --git a/internal/cli/rcs/console.go b/internal/cli/rcs/console.go new file mode 100644 index 0000000..50c4fbb --- /dev/null +++ b/internal/cli/rcs/console.go @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: © 2026 OpenCHAMI a Series of LF Projects, LLC +// +// SPDX-License-Identifier: MIT + +package rcs + +import ( + "os" + + "github.com/spf13/cobra" + + "github.com/OpenCHAMI/ochami/internal/cli" + "github.com/OpenCHAMI/ochami/internal/log" + "github.com/OpenCHAMI/ochami/pkg/client/rcs" +) + +// GetClient sets up the remote-console client with the base URI and certificates +// (if necessary) and returns it. This function is used by each subcommand. +func GetClient(cmd *cobra.Command) *rcs.RCSClient { + rcsBaseURI, err := cli.GetBaseURIRCS(cmd) + if err != nil { + log.Logger.Error().Err(err).Msg("failed to get base URI for remote-console") + cli.LogHelpError(cmd) + os.Exit(1) + } + + insecure, _ := cmd.Flags().GetBool("insecure") + + rcsClient, err := rcs.NewClient(rcsBaseURI, insecure) + if err != nil { + log.Logger.Error().Err(err).Msg("error creating new remote-console client") + cli.LogHelpError(cmd) + os.Exit(1) + } + + cli.UseCACert(rcsClient.OchamiClient) + + return rcsClient +} diff --git a/internal/config/config.go b/internal/config/config.go index 9360932..0eb14d5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -35,6 +35,7 @@ const ( ServiceCloudInit ServiceName = "cloud-init" ServicePCS ServiceName = "pcs" ServiceSMD ServiceName = "smd" + ServiceRCS ServiceName = "rcs" ) const ( @@ -43,6 +44,7 @@ const ( DefaultBasePathCloudInit = "/cloud-init" DefaultBasePathPCS = "/" DefaultBasePathSMD = "/hsm/v2" + DefaultBasePathRCS = "/remote-console" SystemConfigFile = "/etc/ochami/config.yaml" ) @@ -173,6 +175,7 @@ type ConfigClusterConfig struct { CloudInit ConfigClusterCloudInit `yaml:"cloud-init,omitempty"` PCS ConfigClusterPCS `yaml:"pcs,omitempty"` SMD ConfigClusterSMD `yaml:"smd,omitempty"` + RCS ConfigClusterRCS `yaml:"rcs,omitempty"` EnableAuth bool `yaml:"enable-auth"` } @@ -250,6 +253,10 @@ type ConfigClusterCloudInit struct { URI string `yaml:"uri,omitempty"` } +type ConfigClusterRCS struct { + URI string `yaml:"uri,omitempty"` +} + // ConfigClusterPCS represents configuration specifically for the Power Control // Service. type ConfigClusterPCS struct { @@ -294,6 +301,11 @@ func (ccc *ConfigClusterConfig) MergeURIConfig(c ConfigClusterConfig) ConfigClus } else { newCCC.SMD.URI = compare(ccc.SMD.URI, c.SMD.URI) } + if ccc.RCS == (ConfigClusterRCS{}) { + newCCC.RCS = ConfigClusterRCS{URI: c.RCS.URI} + } else { + newCCC.RCS.URI = compare(ccc.RCS.URI, c.RCS.URI) + } return newCCC } @@ -375,6 +387,15 @@ func (ccc *ConfigClusterConfig) GetServiceBaseURI(svcName ServiceName) (string, } else { svcURI, err = url.Parse(DefaultBasePathSMD) } + case ServiceRCS: + if ccc.URI == "" && ccc.RCS.URI == "" { + return "", ErrMissingURI{Service: svcName} + } + if ccc.RCS.URI != "" { + svcURI, err = url.Parse(ccc.RCS.URI) + } else { + svcURI, err = url.Parse(DefaultBasePathRCS) + } default: return "", ErrUnknownService{Service: string(svcName)} } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 8da4fef..d1df912 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -268,6 +268,7 @@ func TestConfigClusterConfig_MergeURIConfig(t *testing.T) { CloudInit ConfigClusterCloudInit PCS ConfigClusterPCS SMD ConfigClusterSMD + RCS ConfigClusterRCS } type args struct { c ConfigClusterConfig @@ -294,6 +295,9 @@ func TestConfigClusterConfig_MergeURIConfig(t *testing.T) { SMD: ConfigClusterSMD{ URI: "", }, + RCS: ConfigClusterRCS{ + URI: "", + }, }, args: args{ c: ConfigClusterConfig{ @@ -326,6 +330,9 @@ func TestConfigClusterConfig_MergeURIConfig(t *testing.T) { SMD: ConfigClusterSMD{ URI: "", }, + RCS: ConfigClusterRCS{ + URI: "", + }, }, }, { @@ -344,6 +351,9 @@ func TestConfigClusterConfig_MergeURIConfig(t *testing.T) { SMD: ConfigClusterSMD{ URI: "", }, + RCS: ConfigClusterRCS{ + URI: "", + }, }, args: args{ c: ConfigClusterConfig{ @@ -360,6 +370,9 @@ func TestConfigClusterConfig_MergeURIConfig(t *testing.T) { SMD: ConfigClusterSMD{ URI: "newSmd", }, + RCS: ConfigClusterRCS{ + URI: "newRcs", + }, }, }, want: ConfigClusterConfig{ @@ -376,6 +389,9 @@ func TestConfigClusterConfig_MergeURIConfig(t *testing.T) { SMD: ConfigClusterSMD{ URI: "newSmd", }, + RCS: ConfigClusterRCS{ + URI: "newRcs", + }, }, }, { @@ -394,6 +410,9 @@ func TestConfigClusterConfig_MergeURIConfig(t *testing.T) { SMD: ConfigClusterSMD{ URI: "oldSmd", }, + RCS: ConfigClusterRCS{ + URI: "oldRcs", + }, }, args: args{ c: ConfigClusterConfig{ @@ -410,6 +429,9 @@ func TestConfigClusterConfig_MergeURIConfig(t *testing.T) { SMD: ConfigClusterSMD{ URI: "", }, + RCS: ConfigClusterRCS{ + URI: "", + }, }, }, want: ConfigClusterConfig{ @@ -426,6 +448,9 @@ func TestConfigClusterConfig_MergeURIConfig(t *testing.T) { SMD: ConfigClusterSMD{ URI: "oldSmd", }, + RCS: ConfigClusterRCS{ + URI: "oldRcs", + }, }, }, { @@ -487,6 +512,7 @@ func TestConfigClusterConfig_MergeURIConfig(t *testing.T) { CloudInit: tt.fields.CloudInit, PCS: tt.fields.PCS, SMD: tt.fields.SMD, + RCS: tt.fields.RCS, } if got := ccc.MergeURIConfig(tt.args.c); !reflect.DeepEqual(got, tt.want) { t.Errorf("ConfigClusterConfig.MergeURIConfig() = %v, want %v", got, tt.want) @@ -502,6 +528,7 @@ func TestConfigClusterConfig_GetServiceBaseURI(t *testing.T) { CloudInit ConfigClusterCloudInit PCS ConfigClusterPCS SMD ConfigClusterSMD + RCS ConfigClusterRCS } type args struct { svcName ServiceName @@ -529,6 +556,9 @@ func TestConfigClusterConfig_GetServiceBaseURI(t *testing.T) { SMD: ConfigClusterSMD{ URI: "", }, + RCS: ConfigClusterRCS{ + URI: "", + }, }, args: args{ svcName: ServiceBSS, @@ -552,6 +582,9 @@ func TestConfigClusterConfig_GetServiceBaseURI(t *testing.T) { SMD: ConfigClusterSMD{ URI: "", }, + RCS: ConfigClusterRCS{ + URI: "", + }, }, args: args{ svcName: ServiceBSS, @@ -575,6 +608,9 @@ func TestConfigClusterConfig_GetServiceBaseURI(t *testing.T) { SMD: ConfigClusterSMD{ URI: "", }, + RCS: ConfigClusterRCS{ + URI: "", + }, }, args: args{ svcName: ServiceBSS, @@ -683,6 +719,7 @@ func TestConfigClusterConfig_GetServiceBaseURI(t *testing.T) { CloudInit: tt.fields.CloudInit, PCS: tt.fields.PCS, SMD: tt.fields.SMD, + RCS: tt.fields.RCS, } got, err := ccc.GetServiceBaseURI(tt.args.svcName) if (err != nil) != tt.wantErr { diff --git a/man/ochami-rcs.1.sc b/man/ochami-rcs.1.sc new file mode 100644 index 0000000..4e6f62f --- /dev/null +++ b/man/ochami-rcs.1.sc @@ -0,0 +1,102 @@ +OCHAMI-RCS(1) "OpenCHAMI" "Manual Page for ochami-rcs" + +# NAME + +ochami-rcs - Communicate with the remote-console service + +# SYNOPSIS + +ochami rcs [OPTIONS] COMMAND + +# GLOBAL FLAGS + +*--uri* _uri_ + Specify either the absolute base URI for the remote-console service (e.g. + _https://foobar.openchami.cluster:8443/remote-console_) or a relative base + path for the service (e.g. _/remote-console_). If an absolute URI is + specified, this completely overrides any value set with the *--cluster-uri* + flag or *cluster.rcs.uri* in the config file for the cluster. If using an + absolute URI, it should contain the desired service's base path. If a + relative path is specified (with or without the leading forward slash), then + this value overrides the service's default base path and is appended to the + cluster's base URI (set with the *--cluster-uri* flag or *cluster.uri* in the + config file), which is required to be set if a relative path is used here. + + See *ochami*(1) for *--cluster-uri* and *ochami-config*(5) for details on + cluster configuration options. + +# COMMANDS + +## service + +Check the remote-console service itself. + +Subcommands for this command are as follows: + +*status* [-F _format_] + Returns the status of the remote-console service. + + This command accepts the following options: + + *-F, --format-output* _format_ + Output response data in specified _format_. Supported values are: + + - _json_ (default) + - _json-pretty_ + - _yaml_ + +## console + +Manage remote console sessions. + +Subcommands for this command are as follows: + +*list* [-F _format_] + List available consoles. + + This command accepts the following options: + + *-F, --format-output* _format_ + Output response data in specified _format_. Supported values are: + + - _json_ (default) + - _json-pretty_ + - _yaml_ + +*show* [-F _format_] [--follow] [--lines _n_] _nodeID_ + Show console output for the specified node. + + This command accepts the following options: + + *-F, --format-output* _format_ + Output response data in specified _format_. Supported values are: + + - _json_ (default) + - _json-pretty_ + - _yaml_ + + *-f, --follow* + Follow the console output in real-time. + + *--lines* _n_ + Number of lines to show from history. Defaults to 100. + + *nodeID* + Node ID of the console to show. + +*connect* _nodeID_ + Start an interactive session with the console of the specified node. + + *nodeID* + Node ID of the console to connect to. + +# AUTHOR + +Written by Chris Harris and maintained by the OpenCHAMI developers. + +# SEE ALSO + +*ochami*(1) + +; Vim modeline settings +; vim: set tw=80 noet sts=4 ts=4 sw=4 syntax=scdoc: diff --git a/man/ochami-rcs.1.sc.license b/man/ochami-rcs.1.sc.license new file mode 100644 index 0000000..db315fb --- /dev/null +++ b/man/ochami-rcs.1.sc.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: © 2026 OpenCHAMI a Series of LF Projects, LLC + +SPDX-License-Identifier: MIT diff --git a/man/ochami.1.sc b/man/ochami.1.sc index ed72fe1..eb312d1 100644 --- a/man/ochami.1.sc +++ b/man/ochami.1.sc @@ -32,6 +32,8 @@ List of available commands: : Communicate with the State Management Database (SMD) | *config* : Manage ochami CLI configuration, including cluster configuration +| *rcs* +: Manage remote consoles ## Top-Level Commands @@ -184,7 +186,7 @@ Written by Devon T. Bautista and maintained by the OpenCHAMI developers. # SEE ALSO *ochami-boot*(1), *ochami-bss*(1), *ochami-cloud-init*(1), *ochami-config*(1), -*ochami-discover*(1), *ochami-pcs*(1), *ochami-smd*(1), *ochami-config*(5) +*ochami-discover*(1), *ochami-pcs*(1), *ochami-smd*(1), *ochami-rcs*(1), *ochami-config*(5) ; Vim modeline settings ; vim: set tw=80 noet sts=4 ts=4 sw=4 syntax=scdoc: diff --git a/pkg/client/rcs/client.go b/pkg/client/rcs/client.go new file mode 100644 index 0000000..1c9eeb9 --- /dev/null +++ b/pkg/client/rcs/client.go @@ -0,0 +1,400 @@ +// SPDX-FileCopyrightText: © 2026 OpenCHAMI a Series of LF Projects, LLC +// +// SPDX-License-Identifier: MIT + +package rcs + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/gorilla/websocket" + "golang.org/x/term" + + "github.com/OpenCHAMI/ochami/pkg/client" +) + +// ctrlCByte is the byte value for Ctrl+C in raw terminal mode. +const ctrlCByte = byte(0x03) + +// HealthResponse represents the response from the /health endpoint of the Remote Console Service. +type HealthResponse struct { + NumberConsoles string `json:"consoles"` + LastHardwareUpdate string `json:"hardwareupdate"` +} + +// ConsolesResponse represents the response from the /consoles endpoint, containing a list of available consoles. +type ConsolesResponse struct { + Consoles []NodeConsoleInfo `json:"consoles"` +} + +// NodeConsoleInfo represents the information about a single console for a node, including connection details. +type NodeConsoleInfo struct { + ID string `json:"id" yaml:"id"` + ConnectionType string `json:"connectionType" yaml:"connectionType"` + ConnectionHost string `json:"connectionHost" yaml:"connectionHost"` + ConnectionPort int `json:"connectionPort,omitempty" yaml:"connectionPort,omitempty"` + ConsoleEntryCommand string `json:"consoleEntryCommand,omitempty" yaml:"consoleEntryCommand,omitempty"` +} + +type RCSClient struct { + *client.OchamiClient +} + +// NewClient creates a new RCSClient with the given base URI and TLS settings. +func NewClient(baseURI string, insecure bool) (*RCSClient, error) { + oc, err := client.NewOchamiClient("Remote Console", baseURI, insecure) + if err != nil { + return nil, err + } + return &RCSClient{oc}, nil +} + +// headersForToken creates HTTP headers with the given token for authentication. +func headersForToken(token string) (*client.HTTPHeaders, error) { + headers := client.NewHTTPHeaders() + if token != "" { + if err := headers.SetAuthorization(token); err != nil { + return nil, fmt.Errorf("failed to set token in HTTP headers: %w", err) + } + } + + return headers, nil +} + +// dialWebSocket constructs the websocket URL for the console endpoint and attempts to establish a connection with the appropriate headers. +func (c *RCSClient) dialWebSocket(ctx context.Context, nodeID string, query string, headers *client.HTTPHeaders) (*websocket.Conn, error) { + endpoint := fmt.Sprintf("/consoles/%s", nodeID) + uriStr, err := c.GetURI(endpoint, query) + if err != nil { + return nil, err + } + + u, _ := url.Parse(uriStr) + if u.Scheme == "https" { + u.Scheme = "wss" + } else if u.Scheme == "http" { + u.Scheme = "ws" + } + + dialer := websocket.DefaultDialer + var requestHeaders http.Header + if headers != nil { + requestHeaders = http.Header(*headers) + } + + conn, resp, err := dialer.DialContext(ctx, u.String(), requestHeaders) + if err != nil { + return nil, websocketDialError(nodeID, resp, err) + } + + return conn, nil +} + +// websocketDialError constructs an error message based on the HTTP response from a failed websocket dial attempt. +func websocketDialError(nodeID string, resp *http.Response, err error) error { + if resp == nil { + return fmt.Errorf("failed to dial websocket: %w", err) + } + defer func() { + if resp.Body != nil { + _ = resp.Body.Close() + } + }() + + body, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return fmt.Errorf("failed to dial websocket: %s", resp.Status) + } + + msg := strings.TrimSpace(string(body)) + if resp.StatusCode == http.StatusConflict { + if msg != "" { + return fmt.Errorf("%s", msg) + } + return fmt.Errorf("interactive console for %s is already in use", nodeID) + } + + if msg != "" { + return fmt.Errorf("failed to dial websocket: %s: %s", resp.Status, msg) + } + + return fmt.Errorf("failed to dial websocket: %s", resp.Status) +} + +// GetStatus retrieves the health status of the Remote Console Service using the /health endpoint. +func (c *RCSClient) GetStatus(token string) (*HealthResponse, error) { + headers, err := headersForToken(token) + if err != nil { + return nil, err + } + + he, err := c.GetData("/health", "", headers) + if err != nil { + return nil, err + } + + var resp HealthResponse + if err := json.Unmarshal(he.Body, &resp); err != nil { + return nil, fmt.Errorf("failed to unmarshal health response: %w", err) + } + return &resp, nil +} + +// ListConsoles retrieves the list of available consoles from the Remote Console Service using the /consoles endpoint. +func (c *RCSClient) ListConsoles(token string) ([]NodeConsoleInfo, error) { + headers, err := headersForToken(token) + if err != nil { + return nil, err + } + + he, err := c.GetData("/consoles", "", headers) + if err != nil { + return nil, err + } + + var resp ConsolesResponse + if err := json.Unmarshal(he.Body, &resp); err != nil { + return nil, fmt.Errorf("failed to unmarshal consoles response: %w", err) + } + return resp.Consoles, nil +} + +// ShowConsole connects to the console for the specified node and streams its output to the provided writer. +func (c *RCSClient) ShowConsole(ctx context.Context, nodeID string, follow bool, lines int, token string, output io.Writer) error { + headers, err := headersForToken(token) + if err != nil { + return err + } + + conn, err := c.dialWebSocket(ctx, nodeID, fmt.Sprintf("mode=tail&follow=%t&lines=%d", follow, lines), headers) + if err != nil { + return err + } + defer conn.Close() + + for { + _, message, err := conn.ReadMessage() + if err != nil { + if isNormalWebSocketClose(err) { + return nil + } + return err + } + output.Write(message) + } +} + +// isNormalWebSocketClose reports whether err is a clean websocket close. +func isNormalWebSocketClose(err error) bool { + var closeErr *websocket.CloseError + if !errors.As(err, &closeErr) { + return false + } + + return closeErr.Code == websocket.CloseNormalClosure +} + +// terminalInputState restores stdin after raw terminal mode has been enabled. +type terminalInputState struct { + file *os.File + state *term.State +} + +func (t terminalInputState) Restore() error { + if t.file == nil || t.state == nil { + return nil + } + + return term.Restore(int(t.file.Fd()), t.state) +} + +// terminalInputFile checks if stdin is a terminal and returns the file if so. +func terminalInputFile(stdin io.Reader) (*os.File, bool) { + stdinFile, ok := stdin.(*os.File) + if !ok { + return nil, false + } + + if !term.IsTerminal(int(stdinFile.Fd())) { + return nil, false + } + + return stdinFile, true +} + +func enableRawTerminalMode(stdinFile *os.File) (*term.State, error) { + oldState, err := term.GetState(int(stdinFile.Fd())) + if err != nil { + return nil, fmt.Errorf("failed to get terminal state: %w", err) + } + + if _, err := term.MakeRaw(int(stdinFile.Fd())); err != nil { + return nil, fmt.Errorf("failed to set terminal raw mode: %w", err) + } + + return oldState, nil +} + +// streamRawConsoleInput reads from stdin in raw mode and forwards keystrokes to the websocket connection, translating Ctrl+C into an interrupt signal. +func streamRawConsoleInput(stdin io.Reader, conn *websocket.Conn, interrupt chan os.Signal, errChan chan error) { + buf := make([]byte, 1) + for { + bytesRead, err := stdin.Read(buf) + if err != nil { + if err != io.EOF { + errChan <- err + } + return + } + + if bytesRead == 0 { + continue + } + + // In raw mode, Ctrl+C arrives as the ETX byte instead of a signal. + if buf[0] == ctrlCByte { + interrupt <- syscall.SIGINT + return + } + + if err := conn.WriteMessage(websocket.TextMessage, buf[:bytesRead]); err != nil { + errChan <- err + return + } + } +} + +func streamBufferedConsoleInput(stdin io.Reader, conn *websocket.Conn, errChan chan error) { + buf := make([]byte, 1024) + for { + bytesRead, err := stdin.Read(buf) + if err != nil { + if err != io.EOF { + errChan <- err + } + return + } + + if bytesRead == 0 { + continue + } + + if err := conn.WriteMessage(websocket.TextMessage, buf[:bytesRead]); err != nil { + errChan <- err + return + } + } +} + +// startConsoleInputStream starts stdin forwarding and returns terminal state for cleanup. +func startConsoleInputStream(stdin io.Reader, conn *websocket.Conn, interrupt chan os.Signal, errChan chan error) (terminalInputState, error) { + + // If stdin is a terminal, enable raw mode for immediate keystroke forwarding and interrupt handling. Otherwise, stream input in buffered mode. + stdinFile, ok := terminalInputFile(stdin) + if !ok { + // Piped or redirected input should stay buffered so non-interactive input still works. + go streamBufferedConsoleInput(stdin, conn, errChan) + + return terminalInputState{}, nil + } + + // Raw mode lets us forward keystrokes immediately instead of waiting for line buffering. + oldState, err := enableRawTerminalMode(stdinFile) + if err != nil { + return terminalInputState{}, err + } + + go streamRawConsoleInput(stdin, conn, interrupt, errChan) + + return terminalInputState{file: stdinFile, state: oldState}, nil +} + +func streamConsoleOutput(stdout io.Writer, conn *websocket.Conn, errChan chan error, done chan struct{}) { + defer close(done) + for { + messageType, message, err := conn.ReadMessage() + if err != nil { + errChan <- err + return + } + if messageType == websocket.TextMessage || messageType == websocket.BinaryMessage { + if _, err := stdout.Write(message); err != nil { + errChan <- err + return + } + } + } +} + +// startConsoleOutputStream starts websocket output forwarding to stdout. +func startConsoleOutputStream(stdout io.Writer, conn *websocket.Conn, errChan chan error, done chan struct{}) { + go streamConsoleOutput(stdout, conn, errChan, done) +} + +// waitForConsoleExit waits for shutdown, an interrupt, or an I/O error. +func waitForConsoleExit(ctx context.Context, conn *websocket.Conn, interrupt chan os.Signal, done chan struct{}, errChan chan error) error { + select { + case <-ctx.Done(): + return ctx.Err() + case <-interrupt: + // Translate local interrupt into a clean websocket close so the remote side can shut down cleanly. + if err := conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")); err != nil { + return nil + } + // Give the read goroutine a moment to observe the close before returning. + select { + case <-done: + case <-time.After(time.Second): + } + return nil + case err := <-errChan: + return err + } +} + +func (c *RCSClient) ConnectConsole(ctx context.Context, nodeID string, token string, stdin io.Reader, stdout io.Writer) error { + headers, err := headersForToken(token) + if err != nil { + return err + } + + conn, err := c.dialWebSocket(ctx, nodeID, "mode=interactive", headers) + if err != nil { + return err + } + defer conn.Close() + + // Set up interrupt handling to allow Ctrl+C to cleanly close the console connection. + interrupt := make(chan os.Signal, 1) + signal.Notify(interrupt, os.Interrupt, syscall.SIGTERM) + defer signal.Stop(interrupt) + + errChan := make(chan error, 2) + done := make(chan struct{}) + + restoreTerminal, err := startConsoleInputStream(stdin, conn, interrupt, errChan) + if err != nil { + return err + } + + // Restore the terminal when the console session ends, even if there are errors or interrupts. + defer func() { + _ = restoreTerminal.Restore() + }() + + startConsoleOutputStream(stdout, conn, errChan, done) + + /// Wait for the console session to end due to shutdown, interrupt, or an I/O error. + return waitForConsoleExit(ctx, conn, interrupt, done, errChan) +}