From 01988b3e17af73aca73fb70ac56b5d705af55ecd Mon Sep 17 00:00:00 2001 From: Joe Corall Date: Tue, 9 Jun 2026 09:11:15 +0000 Subject: [PATCH] [minor] Add sitectl healthcheck command Adds a core healthcheck command that waits for the active Docker Compose project to become healthy, reports results in the existing validation report formats, and invokes plugin-specific healthcheck runners when available. Adds shared healthcheck helpers for compose service state, host HTTP checks, container HTTP checks, MariaDB ping checks, Solr core ping checks, and project .env URL resolution. Extends plugin discovery and RPC with healthcheck.run, can_healthcheck metadata, and SDK registration for plugin-specific runtime checks. Tests: PATH=/usr/local/go/bin:/usr/local/bin:/usr/bin:/bin:/usr/local/games:/usr/games GOCACHE=/tmp/go-build GOMODCACHE=/home/node/go/pkg/mod go test ./pkg/plugin ./cmd ./pkg/healthcheck --- cmd/healthcheck.go | 138 +++++++++++++ cmd/plugins.go | 8 + cmd/rpc_params.go | 69 +++++++ pkg/healthcheck/healthcheck.go | 347 +++++++++++++++++++++++++++++++++ pkg/plugin/discovery.go | 5 +- pkg/plugin/discovery_test.go | 3 +- pkg/plugin/healthcheck.go | 45 +++++ pkg/plugin/rpc.go | 12 ++ pkg/plugin/rpc_command.go | 37 ++++ pkg/plugin/rpc_command_test.go | 21 +- pkg/plugin/sdk.go | 3 + 11 files changed, 685 insertions(+), 3 deletions(-) create mode 100644 cmd/healthcheck.go create mode 100644 pkg/healthcheck/healthcheck.go create mode 100644 pkg/plugin/healthcheck.go diff --git a/cmd/healthcheck.go b/cmd/healthcheck.go new file mode 100644 index 0000000..896a3f7 --- /dev/null +++ b/cmd/healthcheck.go @@ -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 +} diff --git a/cmd/plugins.go b/cmd/plugins.go index aca9fc3..eb9458a 100644 --- a/cmd/plugins.go +++ b/cmd/plugins.go @@ -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 { diff --git a/cmd/rpc_params.go b/cmd/rpc_params.go index d5738cb..bc76eda 100644 --- a/cmd/rpc_params.go +++ b/cmd/rpc_params.go @@ -3,6 +3,7 @@ package cmd import ( "fmt" "strings" + "time" "github.com/libops/sitectl/pkg/plugin" ) @@ -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 { @@ -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 diff --git a/pkg/healthcheck/healthcheck.go b/pkg/healthcheck/healthcheck.go new file mode 100644 index 0000000..0ab93d0 --- /dev/null +++ b/pkg/healthcheck/healthcheck.go @@ -0,0 +1,347 @@ +package healthcheck + +import ( + "context" + "fmt" + "net/http" + "net/url" + "path/filepath" + "sort" + "strings" + "time" + + dockercontainer "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/filters" + "github.com/joho/godotenv" + "github.com/libops/sitectl/pkg/config" + sitectldocker "github.com/libops/sitectl/pkg/docker" + sitevalidate "github.com/libops/sitectl/pkg/validate" +) + +const ( + defaultHTTPTimeout = 10 * time.Second +) + +// DockerChecker runs health checks against the Docker Compose project attached +// to a sitectl context. +type DockerChecker struct { + Context *config.Context + Client *sitectldocker.DockerClient +} + +// NewDockerChecker creates a Docker-backed checker for the given context. +func NewDockerChecker(ctx *config.Context) (*DockerChecker, error) { + if ctx == nil { + return nil, fmt.Errorf("context is nil") + } + cli, err := sitectldocker.GetDockerCli(ctx) + if err != nil { + return nil, fmt.Errorf("create docker client: %w", err) + } + return &DockerChecker{Context: ctx, Client: cli}, nil +} + +// Close releases resources owned by the checker. +func (c *DockerChecker) Close() error { + if c == nil || c.Client == nil { + return nil + } + return c.Client.Close() +} + +// CheckComposeServices verifies compose service containers are present, +// running, and either healthy or without a Docker healthcheck. +func (c *DockerChecker) CheckComposeServices(ctx context.Context, services ...string) ([]sitevalidate.Result, error) { + if c == nil || c.Context == nil || c.Client == nil { + return nil, fmt.Errorf("docker checker is not initialized") + } + containers, err := c.composeContainers(ctx) + if err != nil { + return nil, err + } + byService := containersByService(containers) + if len(services) == 0 { + services = sortedServiceNames(byService) + } + + results := make([]sitevalidate.Result, 0, len(services)) + for _, service := range services { + service = strings.TrimSpace(service) + if service == "" { + continue + } + results = append(results, c.checkComposeService(ctx, service, byService[service])) + } + return results, nil +} + +// ServiceExists reports whether the current compose project has at least one +// container for service. +func (c *DockerChecker) ServiceExists(ctx context.Context, service string) (bool, error) { + containers, err := c.composeContainers(ctx) + if err != nil { + return false, err + } + service = strings.TrimSpace(service) + for _, container := range containers { + if container.Labels["com.docker.compose.service"] == service { + return true, nil + } + } + return false, nil +} + +// CheckMariaDB verifies that a MariaDB/MySQL service is accepting local +// connections from inside its own container. +func (c *DockerChecker) CheckMariaDB(ctx context.Context, service string) sitevalidate.Result { + service = firstNonEmpty(service, "mariadb") + return c.checkExec(ctx, "mariadb:"+service, service, []string{ + "sh", + "-lc", + `if command -v mariadb-admin >/dev/null 2>&1; then mariadb-admin ping -h 127.0.0.1 --silent; elif command -v mysqladmin >/dev/null 2>&1; then mysqladmin ping -h 127.0.0.1 --silent; else test -S /run/mysqld/mysqld.sock || test -S /var/run/mysqld/mysqld.sock; fi`, + }) +} + +// CheckSolrCore verifies that a Solr core answers its admin ping endpoint from +// inside the Solr container. +func (c *DockerChecker) CheckSolrCore(ctx context.Context, service, core string) sitevalidate.Result { + service = firstNonEmpty(service, "solr") + core = firstNonEmpty(core, "default") + endpoint := fmt.Sprintf("http://127.0.0.1:8983/solr/%s/admin/ping?wt=json", url.PathEscape(core)) + command := fmt.Sprintf(`if command -v curl >/dev/null 2>&1; then curl -fsS %q; elif command -v wget >/dev/null 2>&1; then wget -q -O- %q; else solr status >/dev/null; fi`, endpoint, endpoint) + result := c.checkExec(ctx, "solr:"+service, service, []string{"sh", "-lc", command}) + if result.Status == sitevalidate.StatusOK && strings.TrimSpace(result.Detail) == "" { + result.Detail = "Solr core " + core + " answered ping" + } + return result +} + +// CheckHTTPFromContainer verifies that url is reachable from inside service. +func (c *DockerChecker) CheckHTTPFromContainer(ctx context.Context, name, service, targetURL string) sitevalidate.Result { + command := fmt.Sprintf(`if command -v curl >/dev/null 2>&1; then curl -fsS --max-time 10 %q >/dev/null; else wget -q --timeout=10 --spider %q; fi`, targetURL, targetURL) + return c.checkExec(ctx, name, service, []string{"sh", "-lc", command}) +} + +// CheckHTTP verifies that url returns a 2xx or 3xx response from the host +// running sitectl. +func CheckHTTP(ctx context.Context, name, targetURL string) sitevalidate.Result { + targetURL = strings.TrimSpace(targetURL) + if targetURL == "" { + return failed(name, "URL is empty") + } + reqCtx, cancel := context.WithTimeout(ctx, defaultHTTPTimeout) + defer cancel() + req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, targetURL, nil) + if err != nil { + return failed(name, err.Error()) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return failed(name, err.Error()) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 400 { + return failed(name, "received "+resp.Status) + } + return sitevalidate.Result{Name: name, Status: sitevalidate.StatusOK, Detail: resp.Status} +} + +// PublicURLFromEnv builds a public app URL from .env values in the context +// project directory. It understands DOMAIN, URI_SCHEME, HOST_INSECURE_PORT, +// HOST_SECURE_PORT, and the common TRAEFIK_TLS_ENABLED flag. +func PublicURLFromEnv(ctx *config.Context, defaultScheme, defaultDomain string) string { + env := ProjectEnv(ctx) + scheme := firstNonEmpty(env["URI_SCHEME"], defaultScheme, "http") + if strings.EqualFold(env["TRAEFIK_TLS_ENABLED"], "true") && strings.TrimSpace(env["URI_SCHEME"]) == "" { + scheme = "https" + } + domain := firstNonEmpty(env["DOMAIN"], defaultDomain, "localhost") + port := "" + switch scheme { + case "https": + port = env["HOST_SECURE_PORT"] + default: + port = env["HOST_INSECURE_PORT"] + } + host := domain + if port != "" && !isDefaultPort(scheme, port) && !strings.Contains(domain, ":") { + host = domain + ":" + port + } + return (&url.URL{Scheme: scheme, Host: host, Path: "/"}).String() +} + +// ProjectEnv reads the project's .env file. Missing or unparsable files return +// an empty map so health checks can continue with stack defaults. +func ProjectEnv(ctx *config.Context) map[string]string { + if ctx == nil || strings.TrimSpace(ctx.ProjectDir) == "" { + return map[string]string{} + } + path := filepath.Join(ctx.ProjectDir, ".env") + data, err := ctx.ReadSmallFile(path) + if err != nil { + return map[string]string{} + } + env, err := godotenv.Parse(strings.NewReader(data)) + if err != nil { + return map[string]string{} + } + return env +} + +func (c *DockerChecker) composeContainers(ctx context.Context) ([]dockercontainer.Summary, error) { + project := strings.TrimSpace(c.Context.EffectiveComposeProjectName()) + if project == "" { + return nil, fmt.Errorf("compose project name is empty") + } + filterArgs := filters.NewArgs() + filterArgs.Add("label", "com.docker.compose.project="+project) + containers, err := c.Client.CLI.ContainerList(ctx, dockercontainer.ListOptions{ + All: true, + Filters: filterArgs, + }) + if err != nil { + return nil, fmt.Errorf("list compose containers: %w", err) + } + filtered := containers[:0] + for _, container := range containers { + if strings.EqualFold(container.Labels["com.docker.compose.oneoff"], "True") { + continue + } + filtered = append(filtered, container) + } + return filtered, nil +} + +func (c *DockerChecker) checkComposeService(ctx context.Context, service string, containers []dockercontainer.Summary) sitevalidate.Result { + if len(containers) == 0 { + return failed("service:"+service, "no compose container found") + } + details := make([]string, 0, len(containers)) + ok := true + for _, container := range containers { + inspect, err := c.Client.CLI.ContainerInspect(ctx, container.ID) + if err != nil { + ok = false + details = append(details, containerName(container)+": inspect failed: "+err.Error()) + continue + } + state := inspect.State + if state == nil { + ok = false + details = append(details, containerName(container)+": state unavailable") + continue + } + if !state.Running { + ok = false + detail := fmt.Sprintf("%s: %s", containerName(container), state.Status) + if state.ExitCode != 0 { + detail += fmt.Sprintf(" exit=%d", state.ExitCode) + } + if strings.TrimSpace(state.Error) != "" { + detail += " error=" + strings.TrimSpace(state.Error) + } + details = append(details, detail) + continue + } + if state.Health != nil { + health := strings.TrimSpace(string(state.Health.Status)) + if health != "" && health != "healthy" { + ok = false + details = append(details, fmt.Sprintf("%s: health=%s", containerName(container), health)) + continue + } + details = append(details, containerName(container)+": healthy") + continue + } + details = append(details, containerName(container)+": running") + } + if !ok { + return failed("service:"+service, strings.Join(details, "; ")) + } + return sitevalidate.Result{Name: "service:" + service, Status: sitevalidate.StatusOK, Detail: strings.Join(details, "; ")} +} + +func (c *DockerChecker) checkExec(ctx context.Context, name, service string, command []string) sitevalidate.Result { + if c == nil || c.Client == nil || c.Context == nil { + return failed(name, "docker checker is not initialized") + } + containerName, err := c.Client.GetContainerNameContext(ctx, c.Context, service) + if err != nil { + return failed(name, err.Error()) + } + if strings.TrimSpace(containerName) == "" { + return failed(name, "service "+service+" is not running") + } + execCtx, cancel := context.WithTimeout(ctx, defaultHTTPTimeout) + defer cancel() + output, err := sitectldocker.ExecCapture(execCtx, c.Client, containerName, "", command) + if err != nil { + detail := strings.TrimSpace(output) + if detail != "" { + detail += ": " + } + return failed(name, detail+err.Error()) + } + return sitevalidate.Result{Name: name, Status: sitevalidate.StatusOK, Detail: trimOutput(output)} +} + +func containersByService(containers []dockercontainer.Summary) map[string][]dockercontainer.Summary { + byService := map[string][]dockercontainer.Summary{} + for _, container := range containers { + service := strings.TrimSpace(container.Labels["com.docker.compose.service"]) + if service == "" { + continue + } + byService[service] = append(byService[service], container) + } + return byService +} + +func sortedServiceNames(byService map[string][]dockercontainer.Summary) []string { + names := make([]string, 0, len(byService)) + for name := range byService { + names = append(names, name) + } + sort.Strings(names) + return names +} + +func containerName(container dockercontainer.Summary) string { + if len(container.Names) == 0 { + return container.ID[:min(len(container.ID), 12)] + } + return strings.TrimPrefix(container.Names[0], "/") +} + +func failed(name, detail string) sitevalidate.Result { + return sitevalidate.Result{Name: name, Status: sitevalidate.StatusFailed, Detail: strings.TrimSpace(detail)} +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + value = strings.TrimSpace(value) + if value != "" { + return value + } + } + return "" +} + +func isDefaultPort(scheme, port string) bool { + return scheme == "http" && port == "80" || scheme == "https" && port == "443" +} + +func trimOutput(output string) string { + output = strings.TrimSpace(output) + if len(output) <= 200 { + return output + } + return output[:200] + "..." +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/pkg/plugin/discovery.go b/pkg/plugin/discovery.go index da9311e..9b66084 100644 --- a/pkg/plugin/discovery.go +++ b/pkg/plugin/discovery.go @@ -29,6 +29,7 @@ type PluginMetadata struct { CanConverge bool `json:"can_converge,omitempty" yaml:"can_converge,omitempty"` CanSet bool `json:"can_set,omitempty" yaml:"can_set,omitempty"` CanValidate bool `json:"can_validate,omitempty" yaml:"can_validate,omitempty"` + CanHealthcheck bool `json:"can_healthcheck,omitempty" yaml:"can_healthcheck,omitempty"` Includes []string `json:"includes,omitempty" yaml:"includes,omitempty"` CreateDefinitions []CreateSpec `json:"create_definitions,omitempty" yaml:"create_definitions,omitempty"` DeployDefinitions []DeploySpec `json:"deploy_definitions,omitempty" yaml:"deploy_definitions,omitempty"` @@ -50,6 +51,7 @@ type InstalledPlugin struct { CanConverge bool `json:"can_converge,omitempty" yaml:"can_converge,omitempty"` CanSet bool `json:"can_set,omitempty" yaml:"can_set,omitempty"` CanValidate bool `json:"can_validate,omitempty" yaml:"can_validate,omitempty"` + CanHealthcheck bool `json:"can_healthcheck,omitempty" yaml:"can_healthcheck,omitempty"` Includes []string `json:"includes,omitempty" yaml:"includes,omitempty"` CreateDefinitions []CreateSpec `json:"create_definitions,omitempty" yaml:"create_definitions,omitempty"` DeployDefinitions []DeploySpec `json:"deploy_definitions,omitempty" yaml:"deploy_definitions,omitempty"` @@ -270,7 +272,7 @@ func inspectInstalledPlugin(pluginName, binaryName, pluginPath string) Installed if !parsed.CanDeploy { parsed.CanDeploy = len(parsed.DeployDefinitions) > 0 } - slog.Debug("inspected plugin metadata", "plugin", pluginName, "path", pluginPath, "can_create", parsed.CanCreate, "can_deploy", parsed.CanDeploy, "can_debug", parsed.CanDebug, "can_converge", parsed.CanConverge, "can_set", parsed.CanSet, "can_validate", parsed.CanValidate, "includes", len(parsed.Includes), "create_definitions", len(parsed.CreateDefinitions), "deploy_definitions", len(parsed.DeployDefinitions), "duration", time.Since(started)) + slog.Debug("inspected plugin metadata", "plugin", pluginName, "path", pluginPath, "can_create", parsed.CanCreate, "can_deploy", parsed.CanDeploy, "can_debug", parsed.CanDebug, "can_converge", parsed.CanConverge, "can_set", parsed.CanSet, "can_validate", parsed.CanValidate, "can_healthcheck", parsed.CanHealthcheck, "includes", len(parsed.Includes), "create_definitions", len(parsed.CreateDefinitions), "deploy_definitions", len(parsed.DeployDefinitions), "duration", time.Since(started)) return parsed } @@ -290,6 +292,7 @@ func installedPluginFromMetadata(metadata PluginMetadata, defaults InstalledPlug CanConverge: metadata.CanConverge, CanSet: metadata.CanSet, CanValidate: metadata.CanValidate, + CanHealthcheck: metadata.CanHealthcheck, Includes: append([]string{}, metadata.Includes...), CreateDefinitions: append([]CreateSpec{}, metadata.CreateDefinitions...), DeployDefinitions: append([]DeploySpec{}, metadata.DeployDefinitions...), diff --git a/pkg/plugin/discovery_test.go b/pkg/plugin/discovery_test.go index 219d2e9..b145f2c 100644 --- a/pkg/plugin/discovery_test.go +++ b/pkg/plugin/discovery_test.go @@ -172,7 +172,7 @@ func TestDiscoverInstalledFromPathPropagatesAdvertisedCapabilities(t *testing.T) t.Fatalf("expected one plugin, got %d", len(plugins)) } got := plugins[0] - if !got.CanCreate || !got.CanDeploy || !got.CanDebug || !got.CanConverge || !got.CanSet || !got.CanValidate { + if !got.CanCreate || !got.CanDeploy || !got.CanDebug || !got.CanConverge || !got.CanSet || !got.CanValidate || !got.CanHealthcheck { t.Fatalf("advertised capabilities were not propagated: %+v", got) } if !reflect.DeepEqual(got.Includes, metadata.Includes) { @@ -201,6 +201,7 @@ func fullPluginMetadataForTest() PluginMetadata { CanConverge: true, CanSet: true, CanValidate: true, + CanHealthcheck: true, Includes: []string{"drupal", "libops"}, CreateDefinitions: []CreateSpec{{ Name: "default", diff --git a/pkg/plugin/healthcheck.go b/pkg/plugin/healthcheck.go new file mode 100644 index 0000000..395b22e --- /dev/null +++ b/pkg/plugin/healthcheck.go @@ -0,0 +1,45 @@ +package plugin + +import ( + "github.com/libops/sitectl/pkg/config" + sitevalidate "github.com/libops/sitectl/pkg/validate" + "github.com/spf13/cobra" +) + +// HealthcheckRunner implements plugin-specific runtime health checks. +// Run returns a list of healthcheck results for the active context. Diagnostics +// that should be visible to users should be written to cmd.ErrOrStderr(); +// stdout is captured by the RPC envelope and may not be displayed by callers. +type HealthcheckRunner interface { + BindFlags(cmd *cobra.Command) + Run(cmd *cobra.Command, ctx *config.Context) ([]sitevalidate.Result, error) +} + +// RegisterHealthcheckRunner registers a healthcheck runner for the plugin. The +// SDK stores the handler that is invoked through the plugin RPC entrypoint. The +// handler output is encoded into the RPC result and merged with core health +// results before writing the final report. If BindFlags uses a plugin-specific +// flag for CodebaseRootfs params, mark it with MarkCodebaseRootfsFlag. +func (s *SDK) RegisterHealthcheckRunner(runner HealthcheckRunner) { + if s == nil || runner == nil { + return + } + cmd := &cobra.Command{ + Use: "healthcheck", + Short: "Internal healthcheck hook", + Hidden: true, + SilenceUsage: true, + } + runner.BindFlags(cmd) + s.registerHealthcheckCommand(cmd) + s.healthcheckRunner = runner + s.hasHealthcheck = true +} + +func (s *SDK) runHealthcheckRunner(cmd *cobra.Command, runner HealthcheckRunner) ([]sitevalidate.Result, error) { + ctx, err := s.GetContext() + if err != nil { + return nil, err + } + return runner.Run(cmd, ctx) +} diff --git a/pkg/plugin/rpc.go b/pkg/plugin/rpc.go index 91262af..c923186 100644 --- a/pkg/plugin/rpc.go +++ b/pkg/plugin/rpc.go @@ -44,6 +44,8 @@ const ( MethodComponentSet = "component.set" // MethodValidateRun runs plugin validators. MethodValidateRun = "validate.run" + // MethodHealthcheckRun runs plugin health checks. + MethodHealthcheckRun = "healthcheck.run" // MethodDebugRun renders plugin debug sections. MethodDebugRun = "debug.run" // MethodSetRun runs the plugin-level set handler. @@ -282,6 +284,9 @@ type ValidateRunParams struct { CodebaseRootfs string `json:"codebase_rootfs,omitempty" rpc_flags:"codebase-rootfs,drupal-rootfs" rpc_rootfs:"true"` } +// HealthcheckRunParams is the params payload for MethodHealthcheckRun. +type HealthcheckRunParams struct{} + // NewRPCRequest creates a request envelope for a plugin RPC method. func NewRPCRequest(method string) RPCRequest { return RPCRequest{ @@ -372,6 +377,13 @@ func NewValidateRunRequest(params ValidateRunParams, args ...string) (RPCRequest return withRPCParams(req, params) } +// NewHealthcheckRunRequest creates a typed healthcheck.run request. +func NewHealthcheckRunRequest(params HealthcheckRunParams, args ...string) (RPCRequest, error) { + req := NewRPCRequest(MethodHealthcheckRun) + req.Args = copyRPCArgs(args) + return withRPCParams(req, params) +} + func withRPCParams(req RPCRequest, params any) (RPCRequest, error) { if err := validateRPCParamsForMethod(req.Method, params); err != nil { return RPCRequest{}, err diff --git a/pkg/plugin/rpc_command.go b/pkg/plugin/rpc_command.go index 11c8b17..6621436 100644 --- a/pkg/plugin/rpc_command.go +++ b/pkg/plugin/rpc_command.go @@ -184,6 +184,9 @@ func buildRPCMethodRegistry() map[string]rpcMethodSpec { MethodValidateRun: rpcMethodWithParams[ValidateRunParams](func(s *SDK, cmd *cobra.Command, req RPCRequest, params ValidateRunParams) (RPCResponse, error) { return s.rpcValidate(cmd, req, params) }), + MethodHealthcheckRun: rpcMethodWithParams[HealthcheckRunParams](func(s *SDK, cmd *cobra.Command, req RPCRequest, params HealthcheckRunParams) (RPCResponse, error) { + return s.rpcHealthcheck(cmd, req, params) + }), MethodDebugRun: rpcMethodWithParams[DebugRunParams](func(s *SDK, cmd *cobra.Command, req RPCRequest, params DebugRunParams) (RPCResponse, error) { args, err := flagOnlyRPCArgs(nil, params, req.Args) if err != nil { @@ -247,6 +250,7 @@ func (s *SDK) discoveryMetadata() PluginMetadata { CanConverge: s.hasConverge, CanSet: s.hasSet, CanValidate: s.hasValidate, + CanHealthcheck: s.hasHealthcheck, } info.CanCreate = len(info.CreateDefinitions) > 0 info.CanDeploy = len(info.DeployDefinitions) > 0 @@ -314,6 +318,30 @@ func (s *SDK) rpcValidate(rpcCmd *cobra.Command, req RPCRequest, params Validate return rpcResponse(results, output) } +func (s *SDK) rpcHealthcheck(rpcCmd *cobra.Command, req RPCRequest, params HealthcheckRunParams) (RPCResponse, error) { + if s.healthcheckCmd == nil { + return rpcResponse([]sitevalidate.Result{}, "") + } + var results []sitevalidate.Result + args, err := flagOnlyRPCArgs(s.healthcheckCmd, params, req.Args) + if err != nil { + return RPCResponse{}, fmt.Errorf("build %s argv: %w", req.Method, err) + } + output, err := executeRPCCommandWithRunE(rpcCommandContext(rpcCmd), req.Method, s.healthcheckCmd, args, rpcCommandIOFromCommand(rpcCmd), func(cmd *cobra.Command, args []string) error { + if s.healthcheckRunner == nil { + results = []sitevalidate.Result{} + return nil + } + var runErr error + results, runErr = s.runHealthcheckRunner(cmd, s.healthcheckRunner) + return runErr + }) + if err != nil { + return RPCResponse{Output: output}, err + } + return rpcResponse(results, output) +} + func (s *SDK) rpcCommand(rpcCmd *cobra.Command, method string, command *cobra.Command, args []string) (RPCResponse, error) { if command == nil { return RPCResponse{}, newRPCError(RPCErrorCodeNotRegistered, fmt.Errorf("rpc method is not registered by plugin %q", s.Metadata.Name)) @@ -514,6 +542,15 @@ func (s *SDK) registerValidateCommand(cmd *cobra.Command) { } } +// registerHealthcheckCommand registers the healthcheck command used by RPC dispatch. +// It panics when cmd does not declare the flags bridged by HealthcheckRunParams. +func (s *SDK) registerHealthcheckCommand(cmd *cobra.Command) { + if s != nil && cmd != nil { + mustValidateRPCParamFlags(MethodHealthcheckRun, cmd, "", HealthcheckRunParams{}) + s.healthcheckCmd = cmd + } +} + func appendComponentTargetArgs(command *cobra.Command, subcommand string, args []string, params ComponentTargetParams) ([]string, error) { method := "" switch subcommand { diff --git a/pkg/plugin/rpc_command_test.go b/pkg/plugin/rpc_command_test.go index 1e4d61c..8a998a5 100644 --- a/pkg/plugin/rpc_command_test.go +++ b/pkg/plugin/rpc_command_test.go @@ -173,6 +173,7 @@ func TestRPCMethodsDecodeExpectedParamsTypes(t *testing.T) { MethodComponentReconcile: "ComponentTargetParams", MethodComponentSet: "ComponentSetParams", MethodValidateRun: "ValidateRunParams", + MethodHealthcheckRun: "HealthcheckRunParams", MethodDebugRun: "DebugRunParams", MethodSetRun: "SetRunParams", MethodConvergeRun: "ConvergeRunParams", @@ -603,6 +604,7 @@ func TestRPCRequestBuildersCoverParamMethods(t *testing.T) { MethodComponentReconcile: mustRPCRequest(NewComponentReconcileRequest(ComponentTargetParams{Name: "fcrepo"}, "--flag")), MethodComponentSet: mustRPCRequest(NewComponentSetRequest(ComponentSetParams{Name: "fcrepo", Disposition: "off"}, "--flag")), MethodValidateRun: mustRPCRequest(NewValidateRunRequest(ValidateRunParams{CodebaseRootfs: "app/rootfs"}, "--flag")), + MethodHealthcheckRun: mustRPCRequest(NewHealthcheckRunRequest(HealthcheckRunParams{}, "--flag")), MethodDebugRun: mustRPCRequest(NewDebugRunRequest(DebugRunParams{Verbose: true}, "--flag")), MethodSetRun: mustRPCRequest(NewSetRunRequest(SetRunParams{Path: "/srv/site"}, "--flag")), MethodConvergeRun: mustRPCRequest(NewConvergeRunRequest(ConvergeRunParams{Path: "/srv/site"}, "--flag")), @@ -735,6 +737,7 @@ func TestDiscoveryMetadataAdvertisesRegisteredCapabilities(t *testing.T) { sdk.RegisterSetRunner(setBridgeValidationRunner{}) sdk.RegisterConvergeRunner(convergeBridgeValidationRunner{}) sdk.RegisterValidateRunner(&validateRunnerStub{}) + sdk.RegisterHealthcheckRunner(&healthcheckRunnerStub{}) resp, err := sdk.handleRPC(&cobra.Command{Use: "rpc"}, NewRPCRequest(MethodPluginMetadata)) if err != nil { @@ -747,7 +750,7 @@ func TestDiscoveryMetadataAdvertisesRegisteredCapabilities(t *testing.T) { if err != nil { t.Fatalf("DecodeRPCResult() error = %v", err) } - if !got.CanCreate || !got.CanDeploy || !got.CanDebug || !got.CanConverge || !got.CanSet || !got.CanValidate { + if !got.CanCreate || !got.CanDeploy || !got.CanDebug || !got.CanConverge || !got.CanSet || !got.CanValidate || !got.CanHealthcheck { t.Fatalf("expected all registered capabilities to be advertised, got %+v", got) } if !reflect.DeepEqual(got, metadata) { @@ -770,6 +773,7 @@ func TestRPCParamsUseSnakeCaseJSONContract(t *testing.T) { reflect.TypeOf(SetRunParams{}), reflect.TypeOf(ConvergeRunParams{}), reflect.TypeOf(ValidateRunParams{}), + reflect.TypeOf(HealthcheckRunParams{}), } assertRPCParamTypeListComplete(t, paramTypes) @@ -786,6 +790,7 @@ func TestRPCArgBridgeContractCoversEveryField(t *testing.T) { reflect.TypeOf(SetRunParams{}), reflect.TypeOf(ConvergeRunParams{}), reflect.TypeOf(ValidateRunParams{}), + reflect.TypeOf(HealthcheckRunParams{}), } for _, typ := range bridgeTypes { t.Run(typ.Name(), func(t *testing.T) { @@ -811,6 +816,7 @@ func TestRegisteredRPCCommandsDeclareBridgedFlags(t *testing.T) { sdk.RegisterSetRunner(setBridgeValidationRunner{}) sdk.RegisterConvergeRunner(convergeBridgeValidationRunner{}) sdk.RegisterValidateRunner(&validateRunnerStub{}) + sdk.RegisterHealthcheckRunner(&healthcheckRunnerStub{}) sdk.RegisterServiceComponents(ServiceComponentRegistryOptions{ DisplayName: "ISLE", Components: []corecomponent.ComposeServiceComponent{testComposeServiceComponent(t, "fcrepo")}, @@ -827,6 +833,7 @@ func TestRegisteredRPCCommandsDeclareBridgedFlags(t *testing.T) { {name: "set", method: MethodSetRun, command: sdk.setCmd, params: SetRunParams{}}, {name: "converge", method: MethodConvergeRun, command: sdk.convergeCmd, params: ConvergeRunParams{}}, {name: "validate", method: MethodValidateRun, command: sdk.validateCmd, params: ValidateRunParams{}}, + {name: "healthcheck", method: MethodHealthcheckRun, command: sdk.healthcheckCmd, params: HealthcheckRunParams{}}, {name: "component describe", method: MethodComponentDescribe, command: sdk.componentRootCmd, subcommand: "describe", params: ComponentTargetParams{}}, {name: "component reconcile", method: MethodComponentReconcile, command: sdk.componentRootCmd, subcommand: "reconcile", params: ComponentTargetParams{}}, {name: "component set", method: MethodComponentSet, command: sdk.componentRootCmd, subcommand: "set", params: ComponentSetParams{}}, @@ -1487,6 +1494,18 @@ func (r *validateLifecycleRunner) Run(cmd *cobra.Command, ctx *config.Context) ( }}, nil } +type healthcheckRunnerStub struct{} + +func (r *healthcheckRunnerStub) BindFlags(cmd *cobra.Command) { +} + +func (r *healthcheckRunnerStub) Run(cmd *cobra.Command, ctx *config.Context) ([]sitevalidate.Result, error) { + return []sitevalidate.Result{{ + Name: "healthcheck-rootfs", + Status: sitevalidate.StatusOK, + }}, nil +} + type debugRunnerStub struct{} func (debugRunnerStub) BindFlags(cmd *cobra.Command) { diff --git a/pkg/plugin/sdk.go b/pkg/plugin/sdk.go index 41c4675..01fd018 100644 --- a/pkg/plugin/sdk.go +++ b/pkg/plugin/sdk.go @@ -76,6 +76,8 @@ type SDK struct { setCmd *cobra.Command validateCmd *cobra.Command validateRunner ValidateRunner + healthcheckCmd *cobra.Command + healthcheckRunner HealthcheckRunner componentDefs []component.Definition serviceComponents []component.ComposeServiceComponent serviceComponentDisplayName string @@ -86,6 +88,7 @@ type SDK struct { hasConverge bool hasSet bool hasValidate bool + hasHealthcheck bool } // NewSDK creates a new plugin SDK instance