Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 138 additions & 0 deletions cmd/healthcheck.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package cmd

import (
"fmt"
"strings"
"time"

"github.com/libops/sitectl/pkg/config"
"github.com/libops/sitectl/pkg/healthcheck"
"github.com/libops/sitectl/pkg/plugin"
sitevalidate "github.com/libops/sitectl/pkg/validate"
"github.com/spf13/cobra"
)

var healthcheckCmd = &cobra.Command{
Use: "healthcheck [flags]",
Short: "Check whether the active site is online",
Long: `Check whether the active site is online.

Core checks verify Docker Compose service containers are running and healthy.
If the active context's plugin registers a healthcheck handler, plugin-specific
runtime checks are also run and merged into the report.

All flags not consumed by sitectl itself are forwarded to the plugin's
healthcheck handler, allowing plugin-specific flags such as --codebase-rootfs.

Exits non-zero if any check fails after the timeout.

Examples:
sitectl healthcheck
sitectl healthcheck --timeout 10m --interval 15s
sitectl healthcheck --format table`,
DisableFlagParsing: true,
RunE: func(cmd *cobra.Command, args []string) error {
filteredArgs, contextName, err := getContextFromArgs(cmd, args)
if err != nil {
return err
}

hostParams, healthcheckParams, pluginArgs, err := extractHealthcheckRPCParams(filteredArgs)
if err != nil {
return err
}
if hostParams.Interval <= 0 {
return fmt.Errorf("--interval must be greater than zero")
}

ctxVal, err := config.GetContext(contextName)
if err != nil {
return err
}
ctx := &ctxVal

report, err := runHealthcheckUntilHealthy(cmd, ctx, contextName, hostParams, healthcheckParams, pluginArgs)
if err != nil {
return err
}
if err := sitevalidate.WriteReports(cmd.OutOrStdout(), []sitevalidate.Report{report}, hostParams.Format); err != nil {
return err
}
if !report.Valid {
return fmt.Errorf("healthcheck failed")
}
return nil
},
}

func init() {
healthcheckCmd.GroupID = "workflow"
RootCmd.AddCommand(healthcheckCmd)
}

func runHealthcheckUntilHealthy(cmd *cobra.Command, ctx *config.Context, contextName string, hostParams healthcheckHostParams, healthcheckParams plugin.HealthcheckRunParams, pluginArgs []string) (sitevalidate.Report, error) {
deadline := time.Now().Add(hostParams.Timeout)
var last sitevalidate.Report
for {
results, err := runHealthcheckOnce(cmd, ctx, contextName, healthcheckParams, pluginArgs)
if err != nil {
return sitevalidate.Report{}, err
}
sitevalidate.SortResults(results)
last = sitevalidate.NewReport(ctx, results)
if last.Valid || hostParams.Timeout <= 0 || time.Now().Add(hostParams.Interval).After(deadline) {
return last, nil
}
timer := time.NewTimer(hostParams.Interval)
select {
case <-cmd.Context().Done():
timer.Stop()
return sitevalidate.Report{}, cmd.Context().Err()
case <-timer.C:
}
}
}

func runHealthcheckOnce(cmd *cobra.Command, ctx *config.Context, contextName string, healthcheckParams plugin.HealthcheckRunParams, pluginArgs []string) ([]sitevalidate.Result, error) {
checker, err := healthcheck.NewDockerChecker(ctx)
if err != nil {
return nil, err
}
defer checker.Close()

results, err := checker.CheckComposeServices(cmd.Context())
if err != nil {
return nil, err
}

pluginName := strings.TrimSpace(ctx.Plugin)
if pluginName == "" || pluginName == "core" {
return results, nil
}
hasHealthcheck, err := pluginSupportsHealthcheck(pluginName)
if err != nil {
return nil, err
}
if !hasHealthcheck {
return results, nil
}
req, err := plugin.NewHealthcheckRunRequest(healthcheckParams, pluginArgs...)
if err != nil {
return nil, err
}
req.Context = contextName
resp, invokeErr := pluginSDK.InvokePluginRPC(pluginName, req, plugin.CommandExecOptions{
Context: cmd.Context(),
})
if invokeErr != nil {
return nil, fmt.Errorf("plugin healthcheck failed: %w", invokeErr)
}
if len(resp.Result) == 0 {
return results, nil
}
pluginResults, err := plugin.DecodeRPCResult[[]sitevalidate.Result](resp)
if err != nil {
return nil, fmt.Errorf("parse plugin healthcheck results: %w", err)
}
return append(results, pluginResults...), nil
}
8 changes: 8 additions & 0 deletions cmd/plugins.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ func pluginSupportsValidate(pluginName string) (bool, error) {
return installed.CanValidate, nil
}

func pluginSupportsHealthcheck(pluginName string) (bool, error) {
installed, err := installedPluginWithMetadata(pluginName)
if err != nil {
return false, err
}
return installed.CanHealthcheck, nil
}

func installedPluginWithMetadata(pluginName string) (plugin.InstalledPlugin, error) {
installed, ok := plugin.FindInstalled(pluginName)
if !ok {
Expand Down
69 changes: 69 additions & 0 deletions cmd/rpc_params.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cmd
import (
"fmt"
"strings"
"time"

"github.com/libops/sitectl/pkg/plugin"
)
Expand All @@ -27,6 +28,21 @@ func extractValidateRPCParams(args []string) (string, plugin.ValidateRunParams,
return format, params, passthrough, err
}

type healthcheckHostParams struct {
Format string
Timeout time.Duration
Interval time.Duration
}

func extractHealthcheckRPCParams(args []string) (healthcheckHostParams, plugin.HealthcheckRunParams, []string, error) {
hostParams, remaining, err := extractHealthcheckHostParams(args)
if err != nil {
return healthcheckHostParams{}, plugin.HealthcheckRunParams{}, nil, err
}
params, passthrough, err := plugin.ExtractRPCParamsFromArgs[plugin.HealthcheckRunParams](remaining)
return hostParams, params, passthrough, err
}

func extractComponentSetRPCParams(args []string) (plugin.ComponentSetParams, []string, error) {
params, passthrough, err := plugin.ExtractRPCParamsFromArgs[plugin.ComponentSetParams](args)
if err != nil {
Expand All @@ -38,6 +54,59 @@ func extractComponentSetRPCParams(args []string) (plugin.ComponentSetParams, []s
return params, passthrough, nil
}

func extractHealthcheckHostParams(args []string) (healthcheckHostParams, []string, error) {
passthrough := make([]string, 0, len(args))
params := healthcheckHostParams{
Timeout: 5 * time.Minute,
Interval: 10 * time.Second,
}
for i := 0; i < len(args); i++ {
arg := args[i]
if arg == "--" {
passthrough = append(passthrough, args[i:]...)
break
}
if !strings.HasPrefix(arg, "--") {
passthrough = append(passthrough, arg)
continue
}

raw := strings.TrimPrefix(arg, "--")
name, value, hasValue := strings.Cut(raw, "=")
switch name {
case "format", "timeout", "interval":
if !hasValue {
if i+1 >= len(args) {
return healthcheckHostParams{}, nil, fmt.Errorf("--%s requires a value", name)
}
i++
value = args[i]
}
default:
passthrough = append(passthrough, arg)
continue
}

switch name {
case "format":
params.Format = value
case "timeout":
timeout, err := time.ParseDuration(value)
if err != nil {
return healthcheckHostParams{}, nil, fmt.Errorf("parse --timeout: %w", err)
}
params.Timeout = timeout
case "interval":
interval, err := time.ParseDuration(value)
if err != nil {
return healthcheckHostParams{}, nil, fmt.Errorf("parse --interval: %w", err)
}
params.Interval = interval
}
}
return params, passthrough, nil
}

func extractValidateFormat(args []string) (string, []string, error) {
passthrough := make([]string, 0, len(args))
var format string
Expand Down
Loading