From c04626203e18e057582ef99f81ad9f386a8cc5c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Sch=C3=A4fer?= Date: Fri, 24 Jul 2026 15:47:49 +0200 Subject: [PATCH 1/8] feature: add action doctor --- cmd/agent/agent.go | 1 + cmd/agent/doctor.go | 67 ++++++++++++++++++++++++++++++++++ internal/agent/agent.go | 4 ++ internal/agent/doctor.go | 65 +++++++++++++++++++++++++++++++++ internal/agent/requirements.go | 12 +++--- 5 files changed, 143 insertions(+), 6 deletions(-) create mode 100644 cmd/agent/doctor.go create mode 100644 internal/agent/doctor.go 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..6e588f5 --- /dev/null +++ b/cmd/agent/doctor.go @@ -0,0 +1,67 @@ +/* +Copyright (c) Tobias Schäfer. All rights reserved. +Licensed under the MIT license, see LICENSE in the project root for details. +*/ +package agent + +import ( + "fmt" + "encoding/json" + "os" + "strconv" + "time" + + "github.com/spf13/cobra" + "github.com/tschaefer/finchctl/cmd/completion" + "github.com/tschaefer/finchctl/cmd/errors" + "github.com/tschaefer/finchctl/cmd/format" + "github.com/tschaefer/finchctl/internal/agent" + + "github.com/olekukonko/tablewriter" +) + +var doctorCmd = &cobra.Command{ + Use: "doctor [user@]host[:port]", + Short: "Verify target is ready", + Args: cobra.ExactArgs(1), + Run: runDoctorCmd, + ValidArgsFunction: completion.CompleteHostName, +} + +func init() { + doctorCmd.Flags().Bool("output.json", false, "output in JSON format (not implemented yet)") +} + +func runDoctorCmd(cmd *cobra.Command, args []string) { + targetUrl := args[0] + + formatType, err := format.GetRunFormat("quiet") + cobra.CheckErr(err) + + timeout, _ := cmd.Flags().GetUint("run.cmd-timeout") + a, err := agent.New(cmd.Context(), agent.Options{ + TargetURL: targetUrl, + Format: formatType, + CmdTimeout: time.Duration(timeout) * time.Second, + }) + errors.CheckErr(err, formatType) + + healthy, list := a.Doctor() + jsonOutput, _ := cmd.Flags().GetBool("output.json") + if jsonOutput { + out, err := json.MarshalIndent(list, "", " ") + errors.CheckErr(err, formatType) + 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 !healthy { + os.Exit(1) + } +} diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 5598699..047d961 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() (bool, *[]Health) { + return a.__doctor() +} diff --git a/internal/agent/doctor.go b/internal/agent/doctor.go new file mode 100644 index 0000000..ed2d8e9 --- /dev/null +++ b/internal/agent/doctor.go @@ -0,0 +1,65 @@ +/* +Copyright (c) Tobias Schäfer. All rights reserved. +Licensed under the MIT license, see LICENSE in the project root for details. +*/ +package agent + +type Health struct { + Requirement string + Status string + Optional bool +} + +func (a *Agent) __doctorRequirements() (bool, *[]Health) { + var list []Health + + ok := true + verify := func(f func() error, r, s, d string) { + h := true + t := s + + if err := f(); err != nil { + t = d + h = false + } + + list = append(list, Health{Requirement: r, Status: t, Optional: false}) + ok = ok && h + } + + verify(a.__requirementsHasSudo, "sudo", "available", "not available") + verify(a.__requirementsHasSudoPermission, "superuser permission", "sufficient", "insufficient") + + return ok, &list +} + +func (a *Agent) __doctorOptionals() *[]Health { + var list []Health + + verify := func(f func() bool, r, s, d string) { + t := s + + if !f() { + t = d + } + + list = append(list, Health{Requirement: r, Status: t, Optional: true}) + } + + verify(a.__additionsHasCurl, "curl", "available", "not available") + verify(a.__additionsHasUnzip, "unzip", "available", "not available") + verify(a.__additionsGitHubConnection, "GitHub connection", "established", "not established") + + return &list +} + +func (a *Agent) __doctor() (bool, *[]Health) { + var list []Health + + ok, requirements := a.__doctorRequirements() + list = append(list, *requirements...) + optionals := a.__doctorOptionals() + list = append(list, *optionals...) + + return ok, &list +} 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() +} From fb276385979979c9a22431a292dd486b4371b40a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Sch=C3=A4fer?= Date: Sat, 25 Jul 2026 19:28:10 +0200 Subject: [PATCH 2/8] feat(doctor): check optionals, ports --- cmd/agent/doctor.go | 39 +++++++------- cmd/errors/errors.go | 2 +- internal/agent/agent.go | 5 +- internal/agent/doctor.go | 109 ++++++++++++++++++++++++++++++++++----- internal/agent/errors.go | 9 ++++ 5 files changed, 129 insertions(+), 35 deletions(-) diff --git a/cmd/agent/doctor.go b/cmd/agent/doctor.go index 6e588f5..0b78a51 100644 --- a/cmd/agent/doctor.go +++ b/cmd/agent/doctor.go @@ -5,8 +5,8 @@ Licensed under the MIT license, see LICENSE in the project root for details. package agent import ( - "fmt" "encoding/json" + "fmt" "os" "strconv" "time" @@ -14,8 +14,8 @@ import ( "github.com/spf13/cobra" "github.com/tschaefer/finchctl/cmd/completion" "github.com/tschaefer/finchctl/cmd/errors" - "github.com/tschaefer/finchctl/cmd/format" "github.com/tschaefer/finchctl/internal/agent" + "github.com/tschaefer/finchctl/internal/target" "github.com/olekukonko/tablewriter" ) @@ -30,38 +30,39 @@ var doctorCmd = &cobra.Command{ func init() { doctorCmd.Flags().Bool("output.json", false, "output in JSON format (not implemented yet)") + doctorCmd.Flags().Bool("check.ports", false, "check alloy listen ports") + doctorCmd.Flags().Bool("check.optionals", false, "check alloy optional tools") } func runDoctorCmd(cmd *cobra.Command, args []string) { targetUrl := args[0] - formatType, err := format.GetRunFormat("quiet") - cobra.CheckErr(err) - timeout, _ := cmd.Flags().GetUint("run.cmd-timeout") a, err := agent.New(cmd.Context(), agent.Options{ TargetURL: targetUrl, - Format: formatType, + Format: target.FormatQuiet, CmdTimeout: time.Duration(timeout) * time.Second, }) - errors.CheckErr(err, formatType) + errors.CheckErr(err, target.FormatQuiet) + + checkPorts, _ := cmd.Flags().GetBool("check.ports") + checkOptionals, _ := cmd.Flags().GetBool("check.optionals") + list, err := a.Doctor(checkOptionals, checkPorts) - healthy, list := a.Doctor() jsonOutput, _ := cmd.Flags().GetBool("output.json") if jsonOutput { - out, err := json.MarshalIndent(list, "", " ") - errors.CheckErr(err, formatType) + 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() + errors.CheckErr(err, target.FormatJSON) + return } - if !healthy { - os.Exit(1) + 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() + errors.CheckErr(err, target.FormatDocumentation) } 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/internal/agent/agent.go b/internal/agent/agent.go index 047d961..189801d 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -137,6 +137,7 @@ func (a *Agent) Edit(service string, data *EditData) error { return a.editAgent(service, data) } -func (a *Agent) Doctor() (bool, *[]Health) { - return a.__doctor() +func (a *Agent) Doctor(checkOptionals, checkPorts bool) (*[]Health, error) { + list, err := a.__doctor(checkOptionals, checkPorts) + return list, err } diff --git a/internal/agent/doctor.go b/internal/agent/doctor.go index ed2d8e9..dbc7b3f 100644 --- a/internal/agent/doctor.go +++ b/internal/agent/doctor.go @@ -4,43 +4,54 @@ Licensed under the MIT license, see LICENSE in the project root for details. */ package agent +import ( + "errors" +) + type Health struct { Requirement string Status string Optional bool } -func (a *Agent) __doctorRequirements() (bool, *[]Health) { +const ( + ALLOY_HTTP_PORT = "12345" + ALLOY_LOKI_PORT = "3100" + ALLOY_MIMIR_PORT = "9091" + ALLOY_PYROSCOPE_PORT = "4040" +) + +func (a *Agent) __doctorRequirements() (*[]Health, error) { var list []Health + var errs error - ok := true verify := func(f func() error, r, s, d string) { - h := true t := s if err := f(); err != nil { t = d - h = false + errs = errors.Join(errs, convertError(err, &DoctorAgentError{})) } list = append(list, Health{Requirement: r, Status: t, Optional: false}) - ok = ok && h } verify(a.__requirementsHasSudo, "sudo", "available", "not available") verify(a.__requirementsHasSudoPermission, "superuser permission", "sufficient", "insufficient") - return ok, &list + return &list, errs } -func (a *Agent) __doctorOptionals() *[]Health { +func (a *Agent) __doctorOptionals() (*[]Health, error) { var list []Health + var errs error verify := func(f func() bool, r, s, d string) { t := s if !f() { t = d + errs = errors.Join(errs, &DoctorAgentError{Message: r + " " + d, Reason: ""}) } list = append(list, Health{Requirement: r, Status: t, Optional: true}) @@ -50,16 +61,88 @@ func (a *Agent) __doctorOptionals() *[]Health { verify(a.__additionsHasUnzip, "unzip", "available", "not available") verify(a.__additionsGitHubConnection, "GitHub connection", "established", "not established") - return &list + return &list, errs +} + +func (a *Agent) __doctorPorts(machine *MachineInfo) (*[]Health, error) { + var list []Health + + var cmd string + var err error + var errs error + 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", cmd + " not found", false}) + return &list, err + } + + ports := map[string]bool{ + ALLOY_HTTP_PORT: false, + ALLOY_LOKI_PORT: false, + ALLOY_MIMIR_PORT: true, + ALLOY_PYROSCOPE_PORT: true, + } + + for port, optional := range ports { + _, err = a.target.Run(a.ctx, exec) + if err == nil { + errs = errors.Join(errs, &DoctorAgentError{Message: "port " + port + " already bound", Reason: ""}) + status = "bound" + } else { + status = "unbound" + } + + list = append(list, Health{"port " + port, status, optional}) + } + + return &list, errs } -func (a *Agent) __doctor() (bool, *[]Health) { +func (a *Agent) __doctor(checkOptionals, checkPorts bool) (*[]Health, error) { var list []Health + var err error + + machine, err := a.machineInfo() + if err != nil { + return &list, convertError(err, &DoctorAgentError{}) + } - ok, requirements := a.__doctorRequirements() + requirements, err := a.__doctorRequirements() list = append(list, *requirements...) - optionals := a.__doctorOptionals() - list = append(list, *optionals...) + if err != nil { + return &list, err + } + + if checkOptionals { + optionals, err := a.__doctorOptionals() + list = append(list, *optionals...) + if err != nil { + return &list, err + } + } + + if checkPorts { + ports, e := a.__doctorPorts(machine) + err = e + list = append(list, *ports...) + } - return ok, &list + return &list, err } 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 From 571d0ccadabf0a43b907016567e653d4c5aa7505 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Sch=C3=A4fer?= Date: Sun, 26 Jul 2026 08:43:27 +0200 Subject: [PATCH 3/8] feat(doctor): remove verbose error msgs --- cmd/agent/doctor.go | 25 ++++++++----- go.mod | 2 +- internal/agent/agent.go | 5 +-- internal/agent/doctor.go | 80 +++++++++++++++++++++++----------------- 4 files changed, 64 insertions(+), 48 deletions(-) diff --git a/cmd/agent/doctor.go b/cmd/agent/doctor.go index 0b78a51..282139d 100644 --- a/cmd/agent/doctor.go +++ b/cmd/agent/doctor.go @@ -37,6 +37,11 @@ func init() { 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, @@ -47,22 +52,22 @@ func runDoctorCmd(cmd *cobra.Command, args []string) { checkPorts, _ := cmd.Flags().GetBool("check.ports") checkOptionals, _ := cmd.Flags().GetBool("check.optionals") - list, err := a.Doctor(checkOptionals, checkPorts) + list, ok := a.Doctor(checkOptionals, checkPorts) - jsonOutput, _ := cmd.Flags().GetBool("output.json") if jsonOutput { out, e := json.MarshalIndent(list, "", " ") errors.CheckErr(e, target.FormatJSON) fmt.Println(string(out)) - errors.CheckErr(err, target.FormatJSON) - return + } 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() } - 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)}) + if !ok { + os.Exit(1) } - _ = t.Render() - errors.CheckErr(err, target.FormatDocumentation) } 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 189801d..d2e971f 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -137,7 +137,6 @@ func (a *Agent) Edit(service string, data *EditData) error { return a.editAgent(service, data) } -func (a *Agent) Doctor(checkOptionals, checkPorts bool) (*[]Health, error) { - list, err := a.__doctor(checkOptionals, checkPorts) - return list, err +func (a *Agent) Doctor(checkOptionals, checkPorts bool) (*[]Health, bool) { + return a.__doctor(checkOptionals, checkPorts) } diff --git a/internal/agent/doctor.go b/internal/agent/doctor.go index dbc7b3f..9409cb9 100644 --- a/internal/agent/doctor.go +++ b/internal/agent/doctor.go @@ -6,12 +6,15 @@ package agent import ( "errors" + + "github.com/fatih/color" ) type Health struct { Requirement string Status string Optional bool + Ok bool } const ( @@ -21,50 +24,54 @@ const ( ALLOY_PYROSCOPE_PORT = "4040" ) -func (a *Agent) __doctorRequirements() (*[]Health, error) { +func (a *Agent) __doctorRequirements() (*[]Health, bool) { var list []Health - var errs error + ok := true verify := func(f func() error, r, s, d string) { - t := s + t := color.GreenString(s) + o := true if err := f(); err != nil { - t = d - errs = errors.Join(errs, convertError(err, &DoctorAgentError{})) + t = color.RedString(d) + o = false } - list = append(list, Health{Requirement: r, Status: t, Optional: false}) + ok = ok && o + list = append(list, Health{Requirement: r, Status: t, Optional: false, Ok: o}) } verify(a.__requirementsHasSudo, "sudo", "available", "not available") verify(a.__requirementsHasSudoPermission, "superuser permission", "sufficient", "insufficient") - return &list, errs + return &list, ok } -func (a *Agent) __doctorOptionals() (*[]Health, error) { +func (a *Agent) __doctorOptionals() (*[]Health, bool) { var list []Health - var errs error + ok := true verify := func(f func() bool, r, s, d string) { - t := s + t := color.GreenString(s) + o := true if !f() { - t = d - errs = errors.Join(errs, &DoctorAgentError{Message: r + " " + d, Reason: ""}) + t = color.RedString(d) + o = false } - list = append(list, Health{Requirement: r, Status: t, Optional: true}) + ok = ok && o + list = append(list, Health{Requirement: r, Status: t, Optional: true, Ok: 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, errs + return &list, ok } -func (a *Agent) __doctorPorts(machine *MachineInfo) (*[]Health, error) { +func (a *Agent) __doctorPorts(machine *MachineInfo) (*[]Health, bool) { var list []Health var cmd string @@ -89,8 +96,8 @@ func (a *Agent) __doctorPorts(machine *MachineInfo) (*[]Health, error) { } if _, err = a.target.Run(a.ctx, "command -v "+cmd); err != nil { - list = append(list, Health{"port check", cmd + " not found", false}) - return &list, err + list = append(list, Health{"port check", color.RedString(cmd + " not found"), false, false}) + return &list, false } ports := map[string]bool{ @@ -99,50 +106,55 @@ func (a *Agent) __doctorPorts(machine *MachineInfo) (*[]Health, error) { 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 { - errs = errors.Join(errs, &DoctorAgentError{Message: "port " + port + " already bound", Reason: ""}) - status = "bound" + o = false + errs = errors.Join(errs, &DoctorAgentError{Message: "port " + port + " bound", Reason: ""}) + status = color.RedString("bound") } else { - status = "unbound" + status = color.GreenString("unbound") } - list = append(list, Health{"port " + port, status, optional}) + ok = ok && o + list = append(list, Health{"port " + port, status, optional, o}) } - return &list, errs + return &list, ok } -func (a *Agent) __doctor(checkOptionals, checkPorts bool) (*[]Health, error) { +func (a *Agent) __doctor(checkOptionals, checkPorts bool) (*[]Health, bool) { var list []Health - var err error machine, err := a.machineInfo() if err != nil { - return &list, convertError(err, &DoctorAgentError{}) + return &list, false } - requirements, err := a.__doctorRequirements() + requirements, ok := a.__doctorRequirements() list = append(list, *requirements...) - if err != nil { - return &list, err + if !ok { + return &list, false } if checkOptionals { - optionals, err := a.__doctorOptionals() + optionals, ok := a.__doctorOptionals() list = append(list, *optionals...) - if err != nil { - return &list, err + if !ok { + return &list, false } } if checkPorts { - ports, e := a.__doctorPorts(machine) - err = e + ports, ok := a.__doctorPorts(machine) list = append(list, *ports...) + if !ok { + return &list, false + } } - return &list, err + return &list, true } From 725ba3daee5af36c0f5e1617977998b5bec64682 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Sch=C3=A4fer?= Date: Sun, 26 Jul 2026 10:05:14 +0200 Subject: [PATCH 4/8] feat(doctor): add OS check --- internal/agent/doctor.go | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/internal/agent/doctor.go b/internal/agent/doctor.go index 9409cb9..42135b5 100644 --- a/internal/agent/doctor.go +++ b/internal/agent/doctor.go @@ -5,8 +5,6 @@ Licensed under the MIT license, see LICENSE in the project root for details. package agent import ( - "errors" - "github.com/fatih/color" ) @@ -75,8 +73,6 @@ func (a *Agent) __doctorPorts(machine *MachineInfo) (*[]Health, bool) { var list []Health var cmd string - var err error - var errs error var exec string var port string var status string @@ -95,7 +91,7 @@ func (a *Agent) __doctorPorts(machine *MachineInfo) (*[]Health, bool) { // pass } - if _, err = a.target.Run(a.ctx, "command -v "+cmd); err != nil { + 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 } @@ -110,10 +106,9 @@ func (a *Agent) __doctorPorts(machine *MachineInfo) (*[]Health, bool) { for port, optional := range ports { o := true - _, err = a.target.Run(a.ctx, exec) + _, err := a.target.Run(a.ctx, exec) if err == nil { o = false - errs = errors.Join(errs, &DoctorAgentError{Message: "port " + port + " bound", Reason: ""}) status = color.RedString("bound") } else { status = color.GreenString("unbound") @@ -131,8 +126,10 @@ func (a *Agent) __doctor(checkOptionals, checkPorts bool) (*[]Health, bool) { 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.__doctorRequirements() list = append(list, *requirements...) From de5215ea8d204c7614619b7463f7224d3a48749e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Sch=C3=A4fer?= Date: Sun, 26 Jul 2026 15:57:44 +0200 Subject: [PATCH 5/8] feat(doctor): furbish --- cmd/agent/doctor.go | 8 ++++---- internal/agent/doctor.go | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/cmd/agent/doctor.go b/cmd/agent/doctor.go index 282139d..5594441 100644 --- a/cmd/agent/doctor.go +++ b/cmd/agent/doctor.go @@ -22,16 +22,16 @@ import ( var doctorCmd = &cobra.Command{ Use: "doctor [user@]host[:port]", - Short: "Verify target is ready", + 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 (not implemented yet)") - doctorCmd.Flags().Bool("check.ports", false, "check alloy listen ports") - doctorCmd.Flags().Bool("check.optionals", false, "check alloy optional tools") + 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) { diff --git a/internal/agent/doctor.go b/internal/agent/doctor.go index 42135b5..0e6a09a 100644 --- a/internal/agent/doctor.go +++ b/internal/agent/doctor.go @@ -36,7 +36,7 @@ func (a *Agent) __doctorRequirements() (*[]Health, bool) { } ok = ok && o - list = append(list, Health{Requirement: r, Status: t, Optional: false, Ok: o}) + list = append(list, Health{r, t, false, o}) } verify(a.__requirementsHasSudo, "sudo", "available", "not available") @@ -59,7 +59,7 @@ func (a *Agent) __doctorOptionals() (*[]Health, bool) { } ok = ok && o - list = append(list, Health{Requirement: r, Status: t, Optional: true, Ok: o}) + list = append(list, Health{r, t, true, o}) } verify(a.__additionsHasCurl, "curl", "available", "not available") From 333052691c2f3faf18d6ccb6832e4e33ed7f67d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Sch=C3=A4fer?= Date: Sun, 26 Jul 2026 17:22:52 +0200 Subject: [PATCH 6/8] feat(doctor): add service doctor --- cmd/service/doctor.go | 98 ++++++++++++++++++++++++++++ cmd/service/service.go | 1 + internal/service/doctor.go | 108 +++++++++++++++++++++++++++++++ internal/service/requirements.go | 11 ++++ internal/service/service.go | 4 ++ 5 files changed, 222 insertions(+) create mode 100644 cmd/service/doctor.go create mode 100644 internal/service/doctor.go 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/internal/service/doctor.go b/internal/service/doctor.go new file mode 100644 index 0000000..b8177c9 --- /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) __doctorRequirements() (*[]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) __doctorPorts() (*[]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) __doctor() (*[]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.__doctorRequirements() + list = append(list, *requirements...) + if !ok { + return &list, false + } + + ports, ok := s.__doctorPorts() + 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..e910205 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.__doctor() +} From bb15047d2481b50b69d30971794e8940b9309333 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Sch=C3=A4fer?= Date: Sun, 26 Jul 2026 21:24:07 +0200 Subject: [PATCH 7/8] feat(doctor): rename methods --- internal/agent/agent.go | 2 +- internal/agent/doctor.go | 14 +++++++------- internal/service/doctor.go | 10 +++++----- internal/service/service.go | 2 +- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/internal/agent/agent.go b/internal/agent/agent.go index d2e971f..6646d4e 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -138,5 +138,5 @@ func (a *Agent) Edit(service string, data *EditData) error { } func (a *Agent) Doctor(checkOptionals, checkPorts bool) (*[]Health, bool) { - return a.__doctor(checkOptionals, checkPorts) + return a.examineTarget(checkOptionals, checkPorts) } diff --git a/internal/agent/doctor.go b/internal/agent/doctor.go index 0e6a09a..206a6e1 100644 --- a/internal/agent/doctor.go +++ b/internal/agent/doctor.go @@ -22,7 +22,7 @@ const ( ALLOY_PYROSCOPE_PORT = "4040" ) -func (a *Agent) __doctorRequirements() (*[]Health, bool) { +func (a *Agent) __examineRequirements() (*[]Health, bool) { var list []Health ok := true @@ -45,7 +45,7 @@ func (a *Agent) __doctorRequirements() (*[]Health, bool) { return &list, ok } -func (a *Agent) __doctorOptionals() (*[]Health, bool) { +func (a *Agent) __examineOptionals() (*[]Health, bool) { var list []Health ok := true @@ -69,7 +69,7 @@ func (a *Agent) __doctorOptionals() (*[]Health, bool) { return &list, ok } -func (a *Agent) __doctorPorts(machine *MachineInfo) (*[]Health, bool) { +func (a *Agent) __examinePorts(machine *MachineInfo) (*[]Health, bool) { var list []Health var cmd string @@ -121,7 +121,7 @@ func (a *Agent) __doctorPorts(machine *MachineInfo) (*[]Health, bool) { return &list, ok } -func (a *Agent) __doctor(checkOptionals, checkPorts bool) (*[]Health, bool) { +func (a *Agent) examineTarget(checkOptionals, checkPorts bool) (*[]Health, bool) { var list []Health machine, err := a.machineInfo() @@ -131,14 +131,14 @@ func (a *Agent) __doctor(checkOptionals, checkPorts bool) (*[]Health, bool) { } list = append(list, Health{"os", color.GreenString("supported"), false, true}) - requirements, ok := a.__doctorRequirements() + requirements, ok := a.__examineRequirements() list = append(list, *requirements...) if !ok { return &list, false } if checkOptionals { - optionals, ok := a.__doctorOptionals() + optionals, ok := a.__examineOptionals() list = append(list, *optionals...) if !ok { return &list, false @@ -146,7 +146,7 @@ func (a *Agent) __doctor(checkOptionals, checkPorts bool) (*[]Health, bool) { } if checkPorts { - ports, ok := a.__doctorPorts(machine) + ports, ok := a.__examinePorts(machine) list = append(list, *ports...) if !ok { return &list, false diff --git a/internal/service/doctor.go b/internal/service/doctor.go index b8177c9..f78729b 100644 --- a/internal/service/doctor.go +++ b/internal/service/doctor.go @@ -20,7 +20,7 @@ const ( TRAEFIK_HTTPS_PORT = "443" ) -func (s *Service) __doctorRequirements() (*[]Health, bool) { +func (s *Service) __examineRequirements() (*[]Health, bool) { var list []Health ok := true @@ -45,7 +45,7 @@ func (s *Service) __doctorRequirements() (*[]Health, bool) { return &list, ok } -func (s *Service) __doctorPorts() (*[]Health, bool) { +func (s *Service) __examinePorts() (*[]Health, bool) { var list []Health var port string @@ -82,7 +82,7 @@ func (s *Service) __doctorPorts() (*[]Health, bool) { return &list, ok } -func (s *Service) __doctor() (*[]Health, bool) { +func (s *Service) examineTarget() (*[]Health, bool) { var list []Health _, err := s.target.Run(s.ctx, "uname -sm | grep -qE 'Linux (x86_64|aarch64)'") @@ -92,13 +92,13 @@ func (s *Service) __doctor() (*[]Health, bool) { } list = append(list, Health{"os", color.GreenString("supported"), false, true}) - requirements, ok := s.__doctorRequirements() + requirements, ok := s.__examineRequirements() list = append(list, *requirements...) if !ok { return &list, false } - ports, ok := s.__doctorPorts() + ports, ok := s.__examinePorts() list = append(list, *ports...) if !ok { return &list, false diff --git a/internal/service/service.go b/internal/service/service.go index e910205..82bbb7f 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -236,5 +236,5 @@ func (s *Service) RotateSecret() error { } func (s *Service) Doctor() (*[]Health, bool) { - return s.__doctor() + return s.examineTarget() } From 376cfc06c4477a0549c548f761c4befd912c53a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Sch=C3=A4fer?= Date: Mon, 27 Jul 2026 08:09:56 +0200 Subject: [PATCH 8/8] feat(doctor): adapt tests --- internal/service/service_test.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) 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")