diff --git a/cmd/agent/agent.go b/cmd/agent/agent.go index b2e8bdd..b2ee990 100644 --- a/cmd/agent/agent.go +++ b/cmd/agent/agent.go @@ -21,4 +21,5 @@ func init() { Cmd.AddCommand(updateCmd) Cmd.AddCommand(describeCmd) Cmd.AddCommand(editCmd) + Cmd.AddCommand(doctorCmd) } diff --git a/cmd/agent/doctor.go b/cmd/agent/doctor.go new file mode 100644 index 0000000..5594441 --- /dev/null +++ b/cmd/agent/doctor.go @@ -0,0 +1,73 @@ +/* +Copyright (c) Tobias Schäfer. All rights reserved. +Licensed under the MIT license, see LICENSE in the project root for details. +*/ +package agent + +import ( + "encoding/json" + "fmt" + "os" + "strconv" + "time" + + "github.com/spf13/cobra" + "github.com/tschaefer/finchctl/cmd/completion" + "github.com/tschaefer/finchctl/cmd/errors" + "github.com/tschaefer/finchctl/internal/agent" + "github.com/tschaefer/finchctl/internal/target" + + "github.com/olekukonko/tablewriter" +) + +var doctorCmd = &cobra.Command{ + Use: "doctor [user@]host[:port]", + Short: "Verify target is healthy", + Args: cobra.ExactArgs(1), + Run: runDoctorCmd, + ValidArgsFunction: completion.CompleteHostName, +} + +func init() { + doctorCmd.Flags().Bool("output.json", false, "output in JSON format") + doctorCmd.Flags().Bool("check.ports", false, "check agent listen ports") + doctorCmd.Flags().Bool("check.optionals", false, "check agent optional tools (remote setup)") +} + +func runDoctorCmd(cmd *cobra.Command, args []string) { + targetUrl := args[0] + + jsonOutput, _ := cmd.Flags().GetBool("output.json") + if jsonOutput { + _ = os.Setenv("NO_COLOR", "1") + } + + timeout, _ := cmd.Flags().GetUint("run.cmd-timeout") + a, err := agent.New(cmd.Context(), agent.Options{ + TargetURL: targetUrl, + Format: target.FormatQuiet, + CmdTimeout: time.Duration(timeout) * time.Second, + }) + errors.CheckErr(err, target.FormatQuiet) + + checkPorts, _ := cmd.Flags().GetBool("check.ports") + checkOptionals, _ := cmd.Flags().GetBool("check.optionals") + list, ok := a.Doctor(checkOptionals, checkPorts) + + if jsonOutput { + out, e := json.MarshalIndent(list, "", " ") + errors.CheckErr(e, target.FormatJSON) + fmt.Println(string(out)) + } else { + t := tablewriter.NewWriter(os.Stdout) + t.Header([]string{"Requirement", "Status", "Optional"}) + for _, item := range *list { + _ = t.Append([]string{item.Requirement, item.Status, strconv.FormatBool(item.Optional)}) + } + _ = t.Render() + } + + if !ok { + os.Exit(1) + } +} diff --git a/cmd/errors/errors.go b/cmd/errors/errors.go index ae0975a..622f41a 100644 --- a/cmd/errors/errors.go +++ b/cmd/errors/errors.go @@ -31,7 +31,7 @@ func CheckErr(msg any, format target.Format) { } fmt.Println(string(jsonData)) default: - fmt.Println(msg) + fmt.Fprintf(os.Stderr, "%s\n", msg) } os.Exit(1) diff --git a/cmd/service/doctor.go b/cmd/service/doctor.go new file mode 100644 index 0000000..c70593e --- /dev/null +++ b/cmd/service/doctor.go @@ -0,0 +1,98 @@ +/* +Copyright (c) Tobias Schäfer. All rights reserved. +Licensed under the MIT license, see LICENSE in the project root for details. +*/ +package service + +import ( + "encoding/json" + "fmt" + "net/url" + "os" + "strconv" + "strings" + "time" + + "github.com/spf13/cobra" + "github.com/tschaefer/finchctl/cmd/completion" + "github.com/tschaefer/finchctl/cmd/errors" + "github.com/tschaefer/finchctl/internal/service" + "github.com/tschaefer/finchctl/internal/target" + + "github.com/olekukonko/tablewriter" +) + +var doctorCmd = &cobra.Command{ + Use: "doctor [user@]host[:port]", + Short: "Verify target is healthy", + Args: cobra.ExactArgs(1), + Run: runDoctorCmd, + ValidArgsFunction: completion.CompleteHostName, +} + +func init() { + doctorCmd.Flags().Bool("output.json", false, "output in JSON format") +} + +func runDoctorCmd(cmd *cobra.Command, args []string) { + targetUrl := args[0] + + jsonOutput, _ := cmd.Flags().GetBool("output.json") + if jsonOutput { + _ = os.Setenv("NO_COLOR", "1") + } + + cfg, err := doctorConfig(cmd, args, target.FormatQuiet) + errors.CheckErr(err, target.FormatQuiet) + + timeout, _ := cmd.Flags().GetUint("run.cmd-timeout") + s, err := service.New(cmd.Context(), service.Options{ + Config: cfg, + TargetURL: targetUrl, + Format: target.FormatQuiet, + DryRun: false, + CmdTimeout: time.Duration(timeout) * time.Second, + }) + errors.CheckErr(err, target.FormatQuiet) + + + list, ok := s.Doctor() + + if jsonOutput { + out, e := json.MarshalIndent(list, "", " ") + errors.CheckErr(e, target.FormatJSON) + fmt.Println(string(out)) + } else { + t := tablewriter.NewWriter(os.Stdout) + t.Header([]string{"Requirement", "Status", "Optional"}) + for _, item := range *list { + _ = t.Append([]string{item.Requirement, item.Status, strconv.FormatBool(item.Optional)}) + } + _ = t.Render() + } + + if !ok { + os.Exit(1) + } +} + +func doctorConfig(cmd *cobra.Command, args []string, formatType target.Format) (*service.ServiceConfig, error) { + config := &service.ServiceConfig{} + targetUrl := args[0] + + if !strings.HasPrefix(targetUrl, "ssh://") { + targetUrl = "ssh://" + targetUrl + } + target, err := url.Parse(targetUrl) + if err != nil { + errors.CheckErr(fmt.Errorf("invalid target: %w", err), formatType) + } + + hostname, _ := cmd.Flags().GetString("service.host") + if hostname == "" { + hostname = target.Hostname() + } + config.Hostname = hostname + + return config, nil +} diff --git a/cmd/service/service.go b/cmd/service/service.go index 1672324..c1ef6a6 100644 --- a/cmd/service/service.go +++ b/cmd/service/service.go @@ -21,4 +21,5 @@ func init() { Cmd.AddCommand(rotateCertificateCmd) Cmd.AddCommand(registerCmd) Cmd.AddCommand(deregisterCmd) + Cmd.AddCommand(doctorCmd) } diff --git a/go.mod b/go.mod index 5eb293f..882ecc9 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.26.3 require ( github.com/brianvoe/gofakeit/v7 v7.5.1 github.com/denisbrodbeck/machineid v1.0.1 + github.com/fatih/color v1.15.0 github.com/goccy/go-yaml v1.19.2 github.com/melbahja/goph v1.4.0 github.com/olekukonko/tablewriter v1.1.0 @@ -19,7 +20,6 @@ require ( require ( github.com/davecgh/go-spew v1.1.1 // indirect - github.com/fatih/color v1.15.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 5598699..6646d4e 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -136,3 +136,7 @@ func (a *Agent) Describe(service, resourceID string) (*DescribeData, error) { func (a *Agent) Edit(service string, data *EditData) error { return a.editAgent(service, data) } + +func (a *Agent) Doctor(checkOptionals, checkPorts bool) (*[]Health, bool) { + return a.examineTarget(checkOptionals, checkPorts) +} diff --git a/internal/agent/doctor.go b/internal/agent/doctor.go new file mode 100644 index 0000000..206a6e1 --- /dev/null +++ b/internal/agent/doctor.go @@ -0,0 +1,157 @@ +/* +Copyright (c) Tobias Schäfer. All rights reserved. +Licensed under the MIT license, see LICENSE in the project root for details. +*/ +package agent + +import ( + "github.com/fatih/color" +) + +type Health struct { + Requirement string + Status string + Optional bool + Ok bool +} + +const ( + ALLOY_HTTP_PORT = "12345" + ALLOY_LOKI_PORT = "3100" + ALLOY_MIMIR_PORT = "9091" + ALLOY_PYROSCOPE_PORT = "4040" +) + +func (a *Agent) __examineRequirements() (*[]Health, bool) { + var list []Health + ok := true + + verify := func(f func() error, r, s, d string) { + t := color.GreenString(s) + o := true + + if err := f(); err != nil { + t = color.RedString(d) + o = false + } + + ok = ok && o + list = append(list, Health{r, t, false, o}) + } + + verify(a.__requirementsHasSudo, "sudo", "available", "not available") + verify(a.__requirementsHasSudoPermission, "superuser permission", "sufficient", "insufficient") + + return &list, ok +} + +func (a *Agent) __examineOptionals() (*[]Health, bool) { + var list []Health + ok := true + + verify := func(f func() bool, r, s, d string) { + t := color.GreenString(s) + o := true + + if !f() { + t = color.RedString(d) + o = false + } + + ok = ok && o + list = append(list, Health{r, t, true, o}) + } + + verify(a.__additionsHasCurl, "curl", "available", "not available") + verify(a.__additionsHasUnzip, "unzip", "available", "not available") + verify(a.__additionsGitHubConnection, "GitHub connection", "established", "not established") + + return &list, ok +} + +func (a *Agent) __examinePorts(machine *MachineInfo) (*[]Health, bool) { + var list []Health + + var cmd string + var exec string + var port string + var status string + + switch machine.Kernel { + case "linux": + cmd = "ss" + exec = "sudo ss -H -tlpn sport = :" + port + " | grep -q ''" + case "freebsd": + cmd = "sockstat" + exec = "sudo sockstat -q -P tcp -p " + port + " -l | grep -q ''" + case "darwin": + cmd = "lsof" + exec = "sudo lsof -i :" + port + " | grep -q LISTEN" + default: + // pass + } + + if _, err := a.target.Run(a.ctx, "command -v "+cmd); err != nil { + list = append(list, Health{"port check", color.RedString(cmd + " not found"), false, false}) + return &list, false + } + + ports := map[string]bool{ + ALLOY_HTTP_PORT: false, + ALLOY_LOKI_PORT: false, + ALLOY_MIMIR_PORT: true, + ALLOY_PYROSCOPE_PORT: true, + } + ok := true + + for port, optional := range ports { + o := true + _, err := a.target.Run(a.ctx, exec) + if err == nil { + o = false + status = color.RedString("bound") + } else { + status = color.GreenString("unbound") + } + + ok = ok && o + list = append(list, Health{"port " + port, status, optional, o}) + } + + return &list, ok +} + +func (a *Agent) examineTarget(checkOptionals, checkPorts bool) (*[]Health, bool) { + var list []Health + + machine, err := a.machineInfo() + if err != nil { + list = append(list, Health{"os", color.RedString("unsupported"), false, false}) + return &list, false + } + list = append(list, Health{"os", color.GreenString("supported"), false, true}) + + requirements, ok := a.__examineRequirements() + list = append(list, *requirements...) + if !ok { + return &list, false + } + + if checkOptionals { + optionals, ok := a.__examineOptionals() + list = append(list, *optionals...) + if !ok { + return &list, false + } + } + + if checkPorts { + ports, ok := a.__examinePorts(machine) + list = append(list, *ports...) + if !ok { + return &list, false + } + } + + return &list, true +} diff --git a/internal/agent/errors.go b/internal/agent/errors.go index 2232dee..2be0386 100644 --- a/internal/agent/errors.go +++ b/internal/agent/errors.go @@ -91,6 +91,15 @@ func (e *EditAgentError) Error() string { return strings.TrimSpace(fmt.Sprintf("Failed to edit agent: %s %s", e.Message, e.Reason)) } +type DoctorAgentError struct { + Message string + Reason string +} + +func (e *DoctorAgentError) Error() string { + return strings.TrimSpace(fmt.Sprintf("Target not healthy: %s %s", e.Message, e.Reason)) +} + func convertError(err error, to any) error { if err == nil { return nil diff --git a/internal/agent/requirements.go b/internal/agent/requirements.go index 131ff3f..9f0064c 100644 --- a/internal/agent/requirements.go +++ b/internal/agent/requirements.go @@ -44,11 +44,11 @@ func (a *Agent) __additionsHasUnzip() bool { return true } -func (a *Agent) additionsAgent() bool { - if !a.__additionsHasCurl() || !a.__additionsHasUnzip() { - return false - } - - _, err := a.target.Run(a.ctx, "curl -sfL -o /dev/null https://github.com") +func (a *Agent) __additionsGitHubConnection() bool { + _, err := a.target.Run(a.ctx, "curl --connect-timeout 3 -sfL -o /dev/null https://github.com") return err == nil } + +func (a *Agent) additionsAgent() bool { + return a.__additionsHasCurl() && a.__additionsHasUnzip() && a.__additionsGitHubConnection() +} diff --git a/internal/service/doctor.go b/internal/service/doctor.go new file mode 100644 index 0000000..f78729b --- /dev/null +++ b/internal/service/doctor.go @@ -0,0 +1,108 @@ +/* +Copyright (c) Tobias Schäfer. All rights reserved. +Licensed under the MIT license, see LICENSE in the project root for details. +*/ +package service + +import ( + "github.com/fatih/color" +) + +type Health struct { + Requirement string + Status string + Optional bool + Ok bool +} + +const ( + TRAEFIK_HTTP_PORT = "80" + TRAEFIK_HTTPS_PORT = "443" +) + +func (s *Service) __examineRequirements() (*[]Health, bool) { + var list []Health + ok := true + + verify := func(f func() error, r, s, d string) { + t := color.GreenString(s) + o := true + + if err := f(); err != nil { + t = color.RedString(d) + o = false + } + + ok = ok && o + list = append(list, Health{r, t, false, o}) + } + + verify(s.__requirementsHasSudo, "sudo", "available", "not available") + verify(s.__requirementsHasSudoPermission, "superuser permission", "sufficient", "insufficient") + verify(s.__requirementsHasCurl, "curl", "available", "not available") + verify(s.__requirementsGitHubConnection, "GitHub connection", "established", "not established") + + return &list, ok +} + +func (s *Service) __examinePorts() (*[]Health, bool) { + var list []Health + + var port string + var status string + + cmd := "ss" + exec := "sudo ss -H -tlpn sport = :" + port + " | grep -q ''" + + if _, err := s.target.Run(s.ctx, "command -v "+cmd); err != nil { + list = append(list, Health{"port check", color.RedString(cmd + " not found"), false, false}) + return &list, false + } + + ports := map[string]bool{ + TRAEFIK_HTTP_PORT: false, + TRAEFIK_HTTPS_PORT: false, + } + ok := true + + for port, optional := range ports { + o := true + _, err := s.target.Run(s.ctx, exec) + if err == nil { + o = false + status = color.RedString("bound") + } else { + status = color.GreenString("unbound") + } + + ok = ok && o + list = append(list, Health{"port " + port, status, optional, o}) + } + + return &list, ok +} + +func (s *Service) examineTarget() (*[]Health, bool) { + var list []Health + + _, err := s.target.Run(s.ctx, "uname -sm | grep -qE 'Linux (x86_64|aarch64)'") + if err != nil { + list = append(list, Health{"os", color.RedString("unsupported"), false, false}) + return &list, false + } + list = append(list, Health{"os", color.GreenString("supported"), false, true}) + + requirements, ok := s.__examineRequirements() + list = append(list, *requirements...) + if !ok { + return &list, false + } + + ports, ok := s.__examinePorts() + list = append(list, *ports...) + if !ok { + return &list, false + } + + return &list, true +} diff --git a/internal/service/requirements.go b/internal/service/requirements.go index 4c29fa9..a50840c 100644 --- a/internal/service/requirements.go +++ b/internal/service/requirements.go @@ -25,6 +25,13 @@ func (s *Service) __requirementsHasSudoPermission() error { return nil } +func (s *Service) __requirementsGitHubConnection() error { + if _, err := s.target.Run(s.ctx, "curl --connect-timeout 3 -sfL -o /dev/null https://github.com"); err != nil { + return &DeployServiceError{Message: "GitHub connection not established", Reason: err.Error()} + } + return nil +} + func (s *Service) requirementsService() error { if err := s.__requirementsHasSudo(); err != nil { return err @@ -38,5 +45,9 @@ func (s *Service) requirementsService() error { return err } + if err := s.__requirementsGitHubConnection(); err != nil { + return err + } + return nil } diff --git a/internal/service/service.go b/internal/service/service.go index e02dda1..82bbb7f 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -234,3 +234,7 @@ func (s *Service) RotateSecret() error { return nil } + +func (s *Service) Doctor() (*[]Health, bool) { + return s.examineTarget() +} diff --git a/internal/service/service_test.go b/internal/service/service_test.go index 677c81a..4964596 100644 --- a/internal/service/service_test.go +++ b/internal/service/service_test.go @@ -40,7 +40,7 @@ func Test_Deploy(t *testing.T) { assert.NoError(t, err, "deploy service") tracks := strings.Split(record, "\n") - assert.Len(t, tracks, 57, "number of log lines") + assert.Len(t, tracks, 58, "number of log lines") wanted := "Running 'command -v sudo' as .+@localhost" assert.Regexp(t, wanted, tracks[0], "first log line") @@ -92,7 +92,7 @@ func Test_Teardown(t *testing.T) { assert.NoError(t, err, "teardown service") tracks := strings.Split(record, "\n") - assert.Len(t, tracks, 8, "number of log lines mismatch") + assert.Len(t, tracks, 9, "number of log lines mismatch") wanted := "Running 'command -v sudo' as .+@localhost" assert.Regexp(t, wanted, tracks[0], "first log line") @@ -141,7 +141,7 @@ func Test_Update(t *testing.T) { assert.NoError(t, err, "update service") tracks := strings.Split(record, "\n") - assert.Len(t, tracks, 54, "number of log lines") + assert.Len(t, tracks, 55, "number of log lines") wanted := "Running 'command -v sudo' as .+@localhost" assert.Regexp(t, wanted, tracks[0], "first log line") @@ -190,7 +190,7 @@ func Test_RotateSecret(t *testing.T) { assert.NoError(t, err, "rotate secret") tracks := strings.Split(record, "\n") - assert.Len(t, tracks, 9, "number of log lines mismatch") + assert.Len(t, tracks, 10, "number of log lines mismatch") wanted := "Running 'command -v sudo' as .+@localhost" assert.Regexp(t, wanted, tracks[0], "first log line") @@ -238,7 +238,7 @@ func Test_RotateCertificate(t *testing.T) { assert.NoError(t, err, "rotate certificate") tracks := strings.Split(record, "\n") - assert.Len(t, tracks, 8, "number of log lines mismatch") + assert.Len(t, tracks, 9, "number of log lines mismatch") wanted := "Running 'command -v sudo' as .+@localhost" assert.Regexp(t, wanted, tracks[0], "first log line") @@ -294,7 +294,7 @@ func Test_Register(t *testing.T) { assert.NoError(t, err, "register service") tracks := strings.Split(record, "\n") - assert.Len(t, tracks, 7, "number of log lines mismatch") + assert.Len(t, tracks, 8, "number of log lines mismatch") wanted := "Running 'command -v sudo' as .+@localhost" assert.Regexp(t, wanted, tracks[0], "first log line") @@ -343,7 +343,7 @@ func Test_Deregister(t *testing.T) { assert.NoError(t, err, "deregister service") tracks := strings.Split(record, "\n") - assert.Len(t, tracks, 7, "number of log lines mismatch") + assert.Len(t, tracks, 8, "number of log lines mismatch") wanted := "Running 'command -v sudo' as .+@localhost" assert.Regexp(t, wanted, tracks[0], "first log line")