diff --git a/HACKING.md b/HACKING.md index 501fac243..59032f53f 100644 --- a/HACKING.md +++ b/HACKING.md @@ -5,6 +5,7 @@ - [Using Curl to hit the API](#using-curl-to-hit-the-api) - [Code style](#code-style) - [Running the tests](#running-the-tests) +- [Docs](#docs) - [Creating a release](#creating-a-release) Hacking on Pebble is easy. It's written in Go, so install or [download](https://golang.org/dl/) a copy of the latest version of Go. Pebble uses [Go modules](https://golang.org/ref/mod) for managing dependencies, so all of the standard Go tooling just works. @@ -226,6 +227,33 @@ To pull in the latest style and dependencies from the starter pack, clone the [C - Under the `docs/` folder, run `python3 build_requirements.py`. This generates the latest `requirements.txt` under the `.sphinx/` folder. - Under the `docs/` folder, run `tox -e docs-dep` to compile a pinned requirements file for tox environments. +### Updating the CLI reference documentation + +To add a new CLI command, ensure that it is added in the list at the top of the [doc](docs/reference/cli-commands.md) in the appropriate section, and then add a new section for the details **in alphabetical order**. + +The section should look like: + + +(reference_pebble_{command name}_command)= +## {command name} + +The `{command name}` command is used to {describe the command}. + + +```{terminal} +:input: pebble {command name} --help +``` + + + +With `{command name}` replaced by the name of the command and `{describe the command}` replaced by a suitable description. + +In the `docs` directory, run `tox -e commands` to automatically update the CLI reference documentation. + +A CI workflow will fail if the CLI reference documentation does not match the actual output from Pebble. + +Note that the [OpenAPI spec](docs/specs/openapi.yaml) also needs to be manually updated. + ### Writing a great doc - Use short sentences, ideally with one or two clauses. diff --git a/client/checks.go b/client/checks.go index b8e092b30..f074ff9bc 100644 --- a/client/checks.go +++ b/client/checks.go @@ -15,7 +15,10 @@ package client import ( + "bytes" "context" + "encoding/json" + "fmt" "net/url" ) @@ -31,6 +34,11 @@ type ChecksOptions struct { Names []string } +type ChecksActionOptions struct { + // Names is the list of check names on which to perform the action. + Names []string +} + // CheckLevel represents the level of a health check. type CheckLevel string @@ -44,8 +52,17 @@ const ( type CheckStatus string const ( - CheckStatusUp CheckStatus = "up" - CheckStatusDown CheckStatus = "down" + CheckStatusUp CheckStatus = "up" + CheckStatusDown CheckStatus = "down" + CheckStatusInactive CheckStatus = "inactive" +) + +// CheckStartup defines the different startup modes for a check. +type CheckStartup string + +const ( + CheckStartupEnabled CheckStartup = "enabled" + CheckStartupDisabled CheckStartup = "disabled" ) // CheckInfo holds status information for a single health check. @@ -56,8 +73,14 @@ type CheckInfo struct { // Level is this check's level, from the layer configuration. Level CheckLevel `json:"level"` + // Startup is the startup mode for the check. If it is "enabled", the check + // will be started in a Pebble replan and when Pebble starts. If it is + // "disabled", it must be started manually. + Startup CheckStartup `json:"startup"` + // Status is the status of this check: "up" if healthy, "down" if the - // number of failures has reached the configured threshold. + // number of failures has reached the configured threshold, or "inactive" if + // the check is inactive. Status CheckStatus `json:"status"` // Failures is the number of times in a row this check has failed. It is @@ -100,3 +123,54 @@ func (client *Client) Checks(opts *ChecksOptions) ([]*CheckInfo, error) { } return checks, nil } + +// Start starts the checks named in opts.Names. We ignore ops.Level for this +// action. +func (client *Client) StartChecks(opts *ChecksActionOptions) (response string, err error) { + response, err = client.doMultiCheckAction("start", opts.Names) + return response, err +} + +// Stop stops the checks named in opts.Names. We ignore ops.Level for this +// action. +func (client *Client) StopChecks(opts *ChecksActionOptions) (response string, err error) { + response, err = client.doMultiCheckAction("stop", opts.Names) + return response, err +} + +type multiCheckActionData struct { + Action string `json:"action"` + Checks []string `json:"checks"` +} + +func (client *Client) doMultiCheckAction(actionName string, checks []string) (response string, err error) { + action := multiCheckActionData{ + Action: actionName, + Checks: checks, + } + data, err := json.Marshal(&action) + if err != nil { + return "", fmt.Errorf("cannot marshal multi-check action: %w", err) + } + headers := map[string]string{ + "Content-Type": "application/json", + } + + resp, err := client.Requester().Do(context.Background(), &RequestOptions{ + Type: SyncRequest, + Method: "POST", + Path: "/v1/checks", + Headers: headers, + Body: bytes.NewBuffer(data), + }) + if err != nil { + return "", err + } + var response string + err = resp.DecodeResult(&response) + if err != nil { + return "", err + } + + return response, nil +} diff --git a/client/checks_test.go b/client/checks_test.go index 74c75afbe..f2758b4c5 100644 --- a/client/checks_test.go +++ b/client/checks_test.go @@ -26,7 +26,8 @@ func (cs *clientSuite) TestChecksGet(c *check.C) { cs.rsp = `{ "result": [ {"name": "chk1", "status": "up"}, - {"name": "chk3", "status": "down", "failures": 42} + {"name": "chk3", "status": "down", "failures": 42}, + {"name": "chk5", "status": "inactive"} ], "status": "OK", "status-code": 200, @@ -35,7 +36,7 @@ func (cs *clientSuite) TestChecksGet(c *check.C) { opts := client.ChecksOptions{ Level: client.AliveLevel, - Names: []string{"chk1", "chk3"}, + Names: []string{"chk1", "chk3", "chk5"}, } checks, err := cs.cli.Checks(&opts) c.Assert(err, check.IsNil) @@ -47,11 +48,14 @@ func (cs *clientSuite) TestChecksGet(c *check.C) { Name: "chk3", Status: client.CheckStatusDown, Failures: 42, + }, { + Name: "chk5", + Status: client.CheckStatusInactive, }}) c.Assert(cs.req.Method, check.Equals, "GET") c.Assert(cs.req.URL.Path, check.Equals, "/v1/checks") c.Assert(cs.req.URL.Query(), check.DeepEquals, url.Values{ "level": {"alive"}, - "names": {"chk1", "chk3"}, + "names": {"chk1", "chk3", "chk5"}, }) } diff --git a/docs/reference/cli-commands.md b/docs/reference/cli-commands.md index 7a48494bb..7d68f06b0 100644 --- a/docs/reference/cli-commands.md +++ b/docs/reference/cli-commands.md @@ -8,9 +8,9 @@ The `pebble` command has the following subcommands, organised into logical group * Run: [run](#reference_pebble_run_command) * Info: [help](#reference_pebble_help_command), [version](#reference_pebble_version_command) -* Plan: [add](#reference_pebble_add_command), [plan](#reference_pebble_plan_command) -* Services: [services](#reference_pebble_services_command), [logs](#reference_pebble_logs_command), [start](#reference_pebble_start_command), [restart](#reference_pebble_restart_command), [signal](#reference_pebble_signal_command), [stop](#reference_pebble_stop_command), [replan](#reference_pebble_replan_command) -* Checks: [checks](#reference_pebble_checks_command), [health](#reference_pebble_health_command) +* Plan: [add](#reference_pebble_add_command), [plan](#reference_pebble_plan_command), [replan](#reference_pebble_replan_command) +* Services: [services](#reference_pebble_services_command), [logs](#reference_pebble_logs_command), [start](#reference_pebble_start_command), [restart](#reference_pebble_restart_command), [signal](#reference_pebble_signal_command), [stop](#reference_pebble_stop_command) +* Checks: [checks](#reference_pebble_checks_command), [start-checks](#reference_pebble_start_checks_command), [stop-checks](#reference_pebble_stop_checks_command), [health](#reference_pebble_health_command) * Files: [push](#reference_pebble_push_command), [pull](#reference_pebble_pull_command), [ls](#reference_pebble_ls_command), [mkdir](#reference_pebble_mkdir_command), [rm](#reference_pebble_rm_command), [exec](#reference_pebble_exec_command) * Changes: [changes](#reference_pebble_changes_command), [tasks](#reference_pebble_tasks_command) * Notices: [warnings](#reference_pebble_warnings_command), [okay](#reference_pebble_okay_command), [notices](#reference_pebble_notices_command), [notice](#reference_pebble_notice_command), [notify](#reference_pebble_notify_command) @@ -236,9 +236,9 @@ Commands can be classified as follows: Run: run Info: help, version - Plan: add, plan - Services: services, logs, start, restart, signal, stop, replan - Checks: checks, health + Plan: add, plan, replan + Services: services, logs, start, restart, signal, stop + Checks: checks, start-checks, stop-checks, health Files: push, pull, ls, mkdir, rm, exec Changes: changes, tasks Notices: warnings, okay, notices, notice, notify @@ -1007,6 +1007,31 @@ pebble start srv1 srv2 ``` +(reference_pebble_start_checks_command)= +## start-checks + +The `start-checks` command starts the checks with the provided names. + + +```{terminal} +:input: pebble start-checks --help +Usage: + pebble start-checks ... + +The start-checks command starts the configured health checks provided as +positional arguments. For any checks that are already active, the command +has no effect. +``` + + +### Examples + +To start specific checks, run `pebble start-checks` followed by one or more check names. For example, to start two checks named "chk1" and "chk2", run: + +```bash +pebble start-checks chk1 chk2 +``` + (reference_pebble_stop_command)= ## stop @@ -1040,6 +1065,32 @@ pebble stop srv1 ``` +(reference_pebble_stop_checks_command)= +## stop-checks + +The `stop-checks` command stops the checks with the provided names. + + +```{terminal} +:input: pebble stop-checks --help +Usage: + pebble stop-checks ... + +The stop-checks command stops the configured health checks provided as +positional arguments. For any checks that are inactive, the command has +no effect. +``` + + +### Examples + +To stop specific checks, use `pebble stop-checks` followed by one or more check names. The following example stops one check named "chk1": + +```bash +pebble stop-checks chk1 +``` + + (reference_pebble_tasks_command)= ## tasks diff --git a/docs/reference/health-checks.md b/docs/reference/health-checks.md index ad2f4e4c8..2cee3a046 100644 --- a/docs/reference/health-checks.md +++ b/docs/reference/health-checks.md @@ -15,6 +15,8 @@ checks: # Optional level: alive | ready # Optional + startup: enabled | disabled + # Optional period: # Optional timeout: @@ -107,20 +109,22 @@ checks: test: override: replace + startup: disabled http: url: http://localhost:8080/test ``` ## Checks command -You can view check status using the `pebble checks` command. This reports the checks along with their status (`up` or `down`) and number of failures. For example: +You can view check status using the `pebble checks` command. This reports the checks along with their status (`up`, `down`, or `inactive`) and number of failures. For example: ```{terminal} :input: pebble checks -Check Level Status Failures Change -up alive up 0/1 10 -online ready down 1/3 13 (dial tcp 127.0.0.1:8000: connect: connection refused) -test - down 42/3 14 (Get "http://localhost:8080/": dial t... run "pebble tasks 14" for more) +Check Level Startup Status Failures Change +up alive enabled up 0/1 10 +online ready enabled down 1/3 13 (dial tcp 127.0.0.1:8000: connect: connection refused) +test - disabled down 42/3 14 (Get "http://localhost:8080/": dial t... run "pebble tasks 14" for more) +extra - disabled inactive - - ``` The "Failures" column shows the current number of failures since the check started failing, a slash, and the configured threshold. @@ -132,10 +136,24 @@ Health checks are implemented using two change kinds: * `perform-check`: drives the check while it's "up". The change finishes when the number of failures hits the threshold, at which point the change switches to Error status and a `recover-check` change is spawned. Each check failure records a task log. * `recover-check`: drives the check while it's "down". The change finishes when the check starts succeeding again, at which point the change switches to Done status and a new `perform-check` change is spawned. Again, each check failure records a task log. +When a check is stopped, the active `perform-check` or `recover-check` change is aborted. When a stopped (inactive) check is started, a new `perform-check` change is created for the check. + +## Start-checks and stop-checks commands + +You can stop one or more checks using the `pebble stop-checks` command. A stopped check shows in the `pebble checks` output as "inactive" status, and the check will no longer be executed until the check is started again. Stopped (inactive) checks appear in check lists but do not contribute to any overall health calculations - they behave as if the check did not exist. + +A stopped check that has `startup` set to `enabled` will be started in a `replan` operation and when the layer is first added. Stopped checks can also be manually started via the `pebble start-checks` command. + +Checks that have `startup` set to `disabled` will be added in a stopped (inactive) state. These checks will only be started when instructed by a `pebble start-checks` command. + +Including a check that is already running in a `start-checks` command, or including a check that is already stopped (inactive) in a `stop-checks` command is always safe and will simply have no effect on the check. + ## Health endpoint If the `--http` option was given when starting `pebble run`, Pebble exposes a `/v1/health` HTTP endpoint that allows a user to query the health of configured checks, optionally filtered by check level with the query string `?level=` This endpoint returns an HTTP 200 status if the checks are healthy, HTTP 502 otherwise. +Stopped (inactive) checks are ignored for health calculations. + Each check can specify a `level` of "alive" or "ready". These have semantic meaning: "alive" means the check or the service it's connected to is up and running; "ready" means it's properly accepting network traffic. These correspond to [Kubernetes "liveness" and "readiness" probes](https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/). The tool running the Pebble server can make use of this, for example, under Kubernetes you could initialize its liveness and readiness probes to hit Pebble's `/v1/health` endpoint with `?level=alive` and `?level=ready` filters, respectively. diff --git a/docs/reference/layer-specification.md b/docs/reference/layer-specification.md index 84d0fddb8..0631e9eac 100644 --- a/docs/reference/layer-specification.md +++ b/docs/reference/layer-specification.md @@ -149,6 +149,10 @@ checks: # section in the docs for details. level: alive | ready + # (Optional) Control whether the check is started automatically when + # Pebble starts or performs a 'replan' operation. Default is "enabled". + startup: enabled | disabled + # (Optional) Check is run every time this period (time interval) # elapses. Must not be zero. Default is "10s". period: diff --git a/docs/specs/openapi.yaml b/docs/specs/openapi.yaml index 7c05882b5..66c08bb5f 100644 --- a/docs/specs/openapi.yaml +++ b/docs/specs/openapi.yaml @@ -311,6 +311,45 @@ paths: } ] } + post: + summary: Manage checks + description: Perform a check operation such as start or stop. + tags: + - checks + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + action: + type: string + description: The action to perform. + enum: ["start", "stop"] + checks: + type: array + description: | + A list of service names. Required. + items: + type: string + example: + {"action": "start", "checks": ["svc1"]} + responses: + "202": + description: Accepted - asynchronous operation started. + content: + application/json: + schema: + $ref: "#/components/schemas/PostServicesResponse" + example: + { + "type": "async", + "status-code": 202, + "status": "Accepted", + "change": "25", + "result": null + } /v1/exec: post: summary: Execute a command diff --git a/internals/cli/cmd_checks.go b/internals/cli/cmd_checks.go index 9a90b90c4..b7353424f 100644 --- a/internals/cli/cmd_checks.go +++ b/internals/cli/cmd_checks.go @@ -78,16 +78,20 @@ func (cmd *cmdChecks) Execute(args []string) error { w := tabWriter() defer w.Flush() - fmt.Fprintln(w, "Check\tLevel\tStatus\tFailures\tChange") + fmt.Fprintln(w, "Check\tLevel\tStartup\tStatus\tFailures\tChange") for _, check := range checks { level := check.Level if level == client.UnsetLevel { level = "-" } - fmt.Fprintf(w, "%s\t%s\t%s\t%d/%d\t%s\n", - check.Name, level, check.Status, check.Failures, - check.Threshold, cmd.changeInfo(check)) + failures := "-" + if check.Status != client.CheckStatusInactive { + failures = fmt.Sprintf("%d/%d", check.Failures, check.Threshold) + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", + check.Name, level, check.Startup, check.Status, failures, + cmd.changeInfo(check)) } return nil } diff --git a/internals/cli/cmd_checks_test.go b/internals/cli/cmd_checks_test.go index e70889978..1e34a8cfb 100644 --- a/internals/cli/cmd_checks_test.go +++ b/internals/cli/cmd_checks_test.go @@ -35,11 +35,12 @@ func (s *PebbleSuite) TestChecks(c *check.C) { "type": "sync", "status-code": 200, "result": [ - {"name": "chk1", "status": "up", "threshold": 3, "change-id": "1"}, - {"name": "chk2", "status": "down", "failures": 1, "threshold": 1, "change-id": "2"}, - {"name": "chk3", "level": "alive", "status": "down", "failures": 42, "threshold": 3, "change-id": "3"}, - {"name": "chk4", "status": "down", "failures": 6, "threshold": 2, "change-id": "4"}, - {"name": "chk5", "status": "down", "failures": 3, "threshold": 2, "change-id": "5"} + {"name": "chk1", "startup": "enabled", "status": "up", "threshold": 3, "change-id": "1"}, + {"name": "chk2", "startup": "enabled", "status": "down", "failures": 1, "threshold": 1, "change-id": "2"}, + {"name": "chk3", "startup": "enabled", "level": "alive", "status": "down", "failures": 42, "threshold": 3, "change-id": "3"}, + {"name": "chk4", "startup": "enabled", "status": "down", "failures": 6, "threshold": 2, "change-id": "4"}, + {"name": "chk5", "startup": "enabled", "status": "down", "failures": 3, "threshold": 2, "change-id": "5"}, + {"name": "chk6", "startup": "disabled", "status": "inactive", "failures": 0, "threshold": 3, "change-id": ""} ] }`) case "/v1/changes/2": @@ -89,12 +90,13 @@ func (s *PebbleSuite) TestChecks(c *check.C) { c.Assert(err, check.IsNil) c.Assert(rest, check.HasLen, 0) c.Check(s.Stdout(), check.Equals, ` -Check Level Status Failures Change -chk1 - up 0/3 1 -chk2 - down 1/1 2 (second) -chk3 alive down 42/3 3 (cannot get change 3) -chk4 - down 6/2 4 (Get "http://localhost:8000/": dial tc... run "pebble tasks 4" for more) -chk5 - down 3/2 5 (error with some\nline breaks\nin it\n) +Check Level Startup Status Failures Change +chk1 - enabled up 0/3 1 +chk2 - enabled down 1/1 2 (second) +chk3 alive enabled down 42/3 3 (cannot get change 3) +chk4 - enabled down 6/2 4 (Get "http://localhost:8000/": dial tc... run "pebble tasks 4" for more) +chk5 - enabled down 3/2 5 (error with some\nline breaks\nin it\n) +chk6 - disabled inactive - - `[1:]) c.Check(s.Stderr(), check.Equals, "") } @@ -144,8 +146,8 @@ func (s *PebbleSuite) TestChecksFiltering(c *check.C) { "type": "sync", "status-code": 200, "result": [ - {"name": "chk1", "status": "up", "threshold": 3}, - {"name": "chk3", "level": "alive", "status": "down", "failures": 42, "threshold": 3} + {"name": "chk1", "startup": "enabled", "status": "up", "threshold": 3}, + {"name": "chk3", "startup": "enabled", "level": "alive", "status": "down", "failures": 42, "threshold": 3} ] }`) }) @@ -153,9 +155,9 @@ func (s *PebbleSuite) TestChecksFiltering(c *check.C) { c.Assert(err, check.IsNil) c.Assert(rest, check.HasLen, 0) c.Check(s.Stdout(), check.Equals, ` -Check Level Status Failures Change -chk1 - up 0/3 - -chk3 alive down 42/3 - +Check Level Startup Status Failures Change +chk1 - enabled up 0/3 - +chk3 alive enabled down 42/3 - `[1:]) c.Check(s.Stderr(), check.Equals, "") } diff --git a/internals/cli/cmd_help.go b/internals/cli/cmd_help.go index 81bdb3298..9a0a25de0 100644 --- a/internals/cli/cmd_help.go +++ b/internals/cli/cmd_help.go @@ -193,15 +193,15 @@ var HelpCategories = []HelpCategory{{ }, { Label: "Plan", Description: "view and change configuration", - Commands: []string{"add", "plan"}, + Commands: []string{"add", "plan", "replan"}, }, { Label: "Services", Description: "manage services", - Commands: []string{"services", "logs", "start", "restart", "signal", "stop", "replan"}, + Commands: []string{"services", "logs", "start", "restart", "signal", "stop"}, }, { Label: "Checks", Description: "manage health checks", - Commands: []string{"checks", "health"}, + Commands: []string{"checks", "start-checks", "stop-checks", "health"}, }, { Label: "Files", Description: "work with files and execute commands", diff --git a/internals/cli/cmd_start-checks.go b/internals/cli/cmd_start-checks.go new file mode 100644 index 000000000..1de2b768c --- /dev/null +++ b/internals/cli/cmd_start-checks.go @@ -0,0 +1,66 @@ +// Copyright (c) 2025 Canonical Ltd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 3 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package cli + +import ( + "fmt" + + "github.com/canonical/go-flags" + + "github.com/canonical/pebble/client" +) + +const cmdStartChecksSummary = "Start one or more checks" +const cmdStartChecksDescription = ` +The start-checks command starts the configured health checks provided as +positional arguments. For any checks that are already active, the command +has no effect. +` + +type cmdStartChecks struct { + client *client.Client + + Positional struct { + Checks []string `positional-arg-name:"" required:"1"` + } `positional-args:"yes"` +} + +func init() { + AddCommand(&CmdInfo{ + Name: "start-checks", + Summary: cmdStartChecksSummary, + Description: cmdStartChecksDescription, + New: func(opts *CmdOptions) flags.Commander { + return &cmdStartChecks{client: opts.Client} + }, + }) +} + +func (cmd cmdStartChecks) Execute(args []string) error { + if len(args) > 1 { + return ErrExtraArgs + } + + checkopts := client.ChecksActionOptions{ + Names: cmd.Positional.Checks, + } + response, err := cmd.client.StartChecks(&checkopts) + if err != nil { + return err + } + + fmt.Fprintln(Stdout, response) + return nil +} diff --git a/internals/cli/cmd_start-checks_test.go b/internals/cli/cmd_start-checks_test.go new file mode 100644 index 000000000..63502ca8e --- /dev/null +++ b/internals/cli/cmd_start-checks_test.go @@ -0,0 +1,70 @@ +// Copyright (c) 2025 Canonical Ltd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 3 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package cli_test + +import ( + "fmt" + "net/http" + + "gopkg.in/check.v1" + + "github.com/canonical/pebble/internals/cli" +) + +func (s *PebbleSuite) TestStartChecks(c *check.C) { + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + c.Check(r.Method, check.Equals, "POST") + c.Check(r.URL.Path, check.Equals, "/v1/checks") + + body := DecodedRequestBody(c, r) + c.Check(body, check.DeepEquals, map[string]interface{}{ + "action": "start", + "checks": []interface{}{"chk1", "chk2"}, + }) + + fmt.Fprintf(w, `{ + "type": "sync", + "status-code": 200, + "result": "Queued \"start\" for check chk1 and 1 more" + }`) + }) + + rest, err := cli.ParserForTest().ParseArgs([]string{"start-checks", "chk1", "chk2"}) + c.Assert(err, check.IsNil) + c.Assert(rest, check.HasLen, 0) + c.Check(s.Stdout(), check.Equals, "Queued \"start\" for check chk1 and 1 more\n") + c.Check(s.Stderr(), check.Equals, "") +} + +func (s *PebbleSuite) TestStartChecksFails(c *check.C) { + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + c.Check(r.Method, check.Equals, "POST") + c.Check(r.URL.Path, check.Equals, "/v1/checks") + + body := DecodedRequestBody(c, r) + c.Check(body, check.DeepEquals, map[string]interface{}{ + "action": "start", + "checks": []interface{}{"chk1", "chk3"}, + }) + + fmt.Fprintf(w, `{"type": "error", "result": {"message": "could not foo"}}`) + }) + + rest, err := cli.ParserForTest().ParseArgs([]string{"start-checks", "chk1", "chk3"}) + c.Assert(err, check.ErrorMatches, "could not foo") + c.Assert(rest, check.HasLen, 1) + c.Check(s.Stdout(), check.Equals, "") + c.Check(s.Stderr(), check.Equals, "") +} diff --git a/internals/cli/cmd_stop-checks.go b/internals/cli/cmd_stop-checks.go new file mode 100644 index 000000000..18e0091e6 --- /dev/null +++ b/internals/cli/cmd_stop-checks.go @@ -0,0 +1,66 @@ +// Copyright (c) 2025 Canonical Ltd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 3 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package cli + +import ( + "fmt" + + "github.com/canonical/go-flags" + + "github.com/canonical/pebble/client" +) + +const cmdStopChecksSummary = "Stop one or more checks" +const cmdStopChecksDescription = ` +The stop-checks command stops the configured health checks provided as +positional arguments. For any checks that are inactive, the command has +no effect. +` + +type cmdStopChecks struct { + client *client.Client + + Positional struct { + Checks []string `positional-arg-name:"" required:"1"` + } `positional-args:"yes"` +} + +func init() { + AddCommand(&CmdInfo{ + Name: "stop-checks", + Summary: cmdStopChecksSummary, + Description: cmdStopChecksDescription, + New: func(opts *CmdOptions) flags.Commander { + return &cmdStopChecks{client: opts.Client} + }, + }) +} + +func (cmd cmdStopChecks) Execute(args []string) error { + if len(args) > 1 { + return ErrExtraArgs + } + + checkopts := client.ChecksActionOptions{ + Names: cmd.Positional.Checks, + } + response, err := cmd.client.StopChecks(&checkopts) + if err != nil { + return err + } + + fmt.Fprintln(Stdout, response) + return nil +} diff --git a/internals/cli/cmd_stop-checks_test.go b/internals/cli/cmd_stop-checks_test.go new file mode 100644 index 000000000..1d3e360ec --- /dev/null +++ b/internals/cli/cmd_stop-checks_test.go @@ -0,0 +1,70 @@ +// Copyright (c) 2025 Canonical Ltd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 3 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package cli_test + +import ( + "fmt" + "net/http" + + "gopkg.in/check.v1" + + "github.com/canonical/pebble/internals/cli" +) + +func (s *PebbleSuite) TestStopChecks(c *check.C) { + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + c.Check(r.Method, check.Equals, "POST") + c.Check(r.URL.Path, check.Equals, "/v1/checks") + + body := DecodedRequestBody(c, r) + c.Check(body, check.DeepEquals, map[string]interface{}{ + "action": "stop", + "checks": []interface{}{"chk1", "chk2"}, + }) + + fmt.Fprintf(w, `{ + "type": "sync", + "status-code": 200, + "result": "Queued \"stop\" for check chk1 and 1 more" + }`) + }) + + rest, err := cli.ParserForTest().ParseArgs([]string{"stop-checks", "chk1", "chk2"}) + c.Assert(err, check.IsNil) + c.Assert(rest, check.HasLen, 0) + c.Check(s.Stdout(), check.Equals, "Queued \"stop\" for check chk1 and 1 more\n") + c.Check(s.Stderr(), check.Equals, "") +} + +func (s *PebbleSuite) TestStopChecksFails(c *check.C) { + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + c.Check(r.Method, check.Equals, "POST") + c.Check(r.URL.Path, check.Equals, "/v1/checks") + + body := DecodedRequestBody(c, r) + c.Check(body, check.DeepEquals, map[string]interface{}{ + "action": "stop", + "checks": []interface{}{"chk1", "chk3"}, + }) + + fmt.Fprintf(w, `{"type": "error", "result": {"message": "could not foo"}}`) + }) + + rest, err := cli.ParserForTest().ParseArgs([]string{"stop-checks", "chk1", "chk3"}) + c.Assert(err, check.ErrorMatches, "could not foo") + c.Assert(rest, check.HasLen, 1) + c.Check(s.Stdout(), check.Equals, "") + c.Check(s.Stderr(), check.Equals, "") +} diff --git a/internals/daemon/api.go b/internals/daemon/api.go index e6c6de16c..4de12c1c7 100644 --- a/internals/daemon/api.go +++ b/internals/daemon/api.go @@ -89,9 +89,11 @@ var API = []*Command{{ WriteAccess: AdminAccess{}, POST: v1PostSignals, }, { - Path: "/v1/checks", - ReadAccess: UserAccess{}, - GET: v1GetChecks, + Path: "/v1/checks", + ReadAccess: UserAccess{}, + WriteAccess: AdminAccess{}, + GET: v1GetChecks, + POST: v1PostChecks, }, { Path: "/v1/notices", ReadAccess: UserAccess{}, diff --git a/internals/daemon/api_changes.go b/internals/daemon/api_changes.go index 46e75d3cc..962833b19 100644 --- a/internals/daemon/api_changes.go +++ b/internals/daemon/api_changes.go @@ -243,7 +243,7 @@ func v1PostChange(c *Command, r *http.Request, _ *UserState) Response { } if reqData.Action != "abort" { - return BadRequest("change action %q is unsupported", reqData.Action) + return BadRequest("invalid action %q", reqData.Action) } if chg.Status().Ready() { diff --git a/internals/daemon/api_checks.go b/internals/daemon/api_checks.go index 10dc45b2b..3b4305ac4 100644 --- a/internals/daemon/api_checks.go +++ b/internals/daemon/api_checks.go @@ -15,6 +15,8 @@ package daemon import ( + "encoding/json" + "fmt" "net/http" "github.com/canonical/x-go/strutil" @@ -25,6 +27,7 @@ import ( type checkInfo struct { Name string `json:"name"` Level string `json:"level,omitempty"` + Startup string `json:"startup,omitempty"` Status string `json:"status"` Failures int `json:"failures,omitempty"` Threshold int `json:"threshold"` @@ -56,6 +59,7 @@ func v1GetChecks(c *Command, r *http.Request, _ *UserState) Response { info := checkInfo{ Name: check.Name, Level: string(check.Level), + Startup: string(check.Startup), Status: string(check.Status), Failures: check.Failures, Threshold: check.Threshold, @@ -66,3 +70,49 @@ func v1GetChecks(c *Command, r *http.Request, _ *UserState) Response { } return SyncResponse(infos) } + +func v1PostChecks(c *Command, r *http.Request, _ *UserState) Response { + var payload struct { + Action string `json:"action"` + Checks []string `json:"checks"` + } + + decoder := json.NewDecoder(r.Body) + if err := decoder.Decode(&payload); err != nil { + return BadRequest("cannot decode data from request body: %v", err) + } + + if len(payload.Checks) == 0 { + return BadRequest("must specify checks for %s action", payload.Action) + } + + checkmgr := c.d.overlord.CheckManager() + plan := c.d.overlord.PlanManager().Plan() + + var err error + var checks []string + switch payload.Action { + case "start": + checks, err = checkmgr.StartChecks(plan, payload.Checks) + case "stop": + checks, err = checkmgr.StopChecks(plan, payload.Checks) + default: + return BadRequest("invalid action %q", payload.Action) + } + if err != nil { + return BadRequest("cannot %s checks: %v", payload.Action, err) + } + + st := c.d.overlord.State() + st.EnsureBefore(0) // start and stop tasks right away + + var result string + if len(checks) == 0 { + result = fmt.Sprintf("No checks needed to %s", payload.Action) + } else if len(checks) == 1 { + result = fmt.Sprintf("Queued %s for check %q", payload.Action, checks[0]) + } else { + result = fmt.Sprintf("Queued %s for check %q and %d more", payload.Action, checks[0], len(checks)-1) + } + return SyncResponse(result) +} diff --git a/internals/daemon/api_checks_test.go b/internals/daemon/api_checks_test.go index 4eb35b49b..e3bd30614 100644 --- a/internals/daemon/api_checks_test.go +++ b/internals/daemon/api_checks_test.go @@ -57,9 +57,9 @@ checks: c.Check(rsp.Status, Equals, 200) c.Check(rsp.Type, Equals, ResponseTypeSync) expected := []interface{}{ - map[string]interface{}{"name": "chk1", "status": "up", "level": "ready", "threshold": 3.0, "change-id": "C0"}, - map[string]interface{}{"name": "chk2", "status": "up", "level": "alive", "threshold": 3.0, "change-id": "C1"}, - map[string]interface{}{"name": "chk3", "status": "up", "threshold": 3.0, "change-id": "C2"}, + map[string]interface{}{"name": "chk1", "startup": "enabled", "status": "up", "level": "ready", "threshold": 3.0, "change-id": "C0"}, + map[string]interface{}{"name": "chk2", "startup": "enabled", "status": "up", "level": "alive", "threshold": 3.0, "change-id": "C1"}, + map[string]interface{}{"name": "chk3", "startup": "enabled", "status": "up", "threshold": 3.0, "change-id": "C2"}, } if reflect.DeepEqual(body["result"], expected) { break @@ -76,8 +76,8 @@ checks: c.Check(rsp.Status, Equals, 200) c.Check(rsp.Type, Equals, ResponseTypeSync) c.Check(body["result"], DeepEquals, []interface{}{ - map[string]interface{}{"name": "chk1", "status": "up", "level": "ready", "threshold": 3.0, "change-id": "C0"}, - map[string]interface{}{"name": "chk3", "status": "up", "threshold": 3.0, "change-id": "C1"}, + map[string]interface{}{"name": "chk1", "startup": "enabled", "status": "up", "level": "ready", "threshold": 3.0, "change-id": "C0"}, + map[string]interface{}{"name": "chk3", "startup": "enabled", "status": "up", "threshold": 3.0, "change-id": "C1"}, }) // Request with names filter (comma-separated values) @@ -85,8 +85,8 @@ checks: c.Check(rsp.Status, Equals, 200) c.Check(rsp.Type, Equals, ResponseTypeSync) c.Check(body["result"], DeepEquals, []interface{}{ - map[string]interface{}{"name": "chk1", "status": "up", "level": "ready", "threshold": 3.0, "change-id": "C0"}, - map[string]interface{}{"name": "chk3", "status": "up", "threshold": 3.0, "change-id": "C1"}, + map[string]interface{}{"name": "chk1", "startup": "enabled", "status": "up", "level": "ready", "threshold": 3.0, "change-id": "C0"}, + map[string]interface{}{"name": "chk3", "startup": "enabled", "status": "up", "threshold": 3.0, "change-id": "C1"}, }) // Request with level filter @@ -94,7 +94,7 @@ checks: c.Check(rsp.Status, Equals, 200) c.Check(rsp.Type, Equals, ResponseTypeSync) c.Check(body["result"], DeepEquals, []interface{}{ - map[string]interface{}{"name": "chk2", "status": "up", "level": "alive", "threshold": 3.0, "change-id": "C0"}, + map[string]interface{}{"name": "chk2", "startup": "enabled", "status": "up", "level": "alive", "threshold": 3.0, "change-id": "C0"}, }) // Request with names and level filters @@ -102,7 +102,7 @@ checks: c.Check(rsp.Status, Equals, 200) c.Check(rsp.Type, Equals, ResponseTypeSync) c.Check(body["result"], DeepEquals, []interface{}{ - map[string]interface{}{"name": "chk1", "status": "up", "level": "ready", "threshold": 3.0, "change-id": "C0"}, + map[string]interface{}{"name": "chk1", "startup": "enabled", "status": "up", "level": "ready", "threshold": 3.0, "change-id": "C0"}, }) } diff --git a/internals/daemon/api_health.go b/internals/daemon/api_health.go index 28eef5e67..cb5a0b8d5 100644 --- a/internals/daemon/api_health.go +++ b/internals/daemon/api_health.go @@ -51,7 +51,8 @@ func v1Health(c *Command, r *http.Request, _ *UserState) Response { levelMatch := level == plan.UnsetLevel || level == check.Level || level == plan.ReadyLevel && check.Level == plan.AliveLevel // ready implies alive namesMatch := len(names) == 0 || strutil.ListContains(names, check.Name) - if levelMatch && namesMatch && check.Status != checkstate.CheckStatusUp { + // CheckStatusUp is healthy, and CheckStatusInactive is ignored. + if levelMatch && namesMatch && check.Status == checkstate.CheckStatusDown { healthy = false status = http.StatusBadGateway } diff --git a/internals/daemon/api_health_test.go b/internals/daemon/api_health_test.go index 8a67c80ce..054a168f8 100644 --- a/internals/daemon/api_health_test.go +++ b/internals/daemon/api_health_test.go @@ -54,6 +54,7 @@ func (s *healthSuite) TestHealthy(c *C) { return []*checkstate.CheckInfo{ {Name: "chk1", Status: checkstate.CheckStatusUp}, {Name: "chk2", Status: checkstate.CheckStatusUp}, + {Name: "chk3", Status: checkstate.CheckStatusInactive}, }, nil }) defer restore() @@ -72,6 +73,7 @@ func (s *healthSuite) TestUnhealthy(c *C) { {Name: "chk1", Status: checkstate.CheckStatusUp}, {Name: "chk2", Status: checkstate.CheckStatusDown}, {Name: "chk3", Status: checkstate.CheckStatusUp}, + {Name: "chk4", Status: checkstate.CheckStatusInactive}, }, nil }) defer restore() @@ -152,6 +154,7 @@ func (s *healthSuite) TestNames(c *C) { {Name: "chk1", Status: checkstate.CheckStatusDown}, {Name: "chk2", Status: checkstate.CheckStatusUp}, {Name: "chk3", Status: checkstate.CheckStatusUp}, + {Name: "chk4", Status: checkstate.CheckStatusInactive}, }, nil }) defer restore() @@ -179,6 +182,27 @@ func (s *healthSuite) TestNames(c *C) { c.Assert(response, DeepEquals, map[string]interface{}{ "healthy": true, }) + + // With only an inactive check, this is the same as no checks, so healthy. + status, response = serveHealth(c, "GET", "/v1/health?names=chk4", nil) + c.Assert(status, Equals, 200) + c.Assert(response, DeepEquals, map[string]interface{}{ + "healthy": true, + }) + + // One healthy check, one that should be ignored. + status, response = serveHealth(c, "GET", "/v1/health?names=chk2,chk4", nil) + c.Assert(status, Equals, 200) + c.Assert(response, DeepEquals, map[string]interface{}{ + "healthy": true, + }) + + // One unhealthy check, one that should be ignored. + status, response = serveHealth(c, "GET", "/v1/health?names=chk1,chk4", nil) + c.Assert(status, Equals, 502) + c.Assert(response, DeepEquals, map[string]interface{}{ + "healthy": false, + }) } func (s *healthSuite) TestBadLevel(c *C) { diff --git a/internals/daemon/api_services.go b/internals/daemon/api_services.go index dfc045c6b..9ec7f83de 100644 --- a/internals/daemon/api_services.go +++ b/internals/daemon/api_services.go @@ -95,10 +95,35 @@ func v1PostServices(c *Command, r *http.Request, _ *UserState) Response { payload.Services = services default: if len(payload.Services) == 0 { - return BadRequest("no services to %s provided", payload.Action) + return BadRequest("must specify services for %s action", payload.Action) } } + // TODO (ping @benhoyt) This is clearly the wrong place for this - it's out + // of the switch below (to avoid deadlocking on the state lock) but even + // more so, it doesn't seem correct for the services API to be aware of the + // check manager's need to be notified. + // It seems like the right thing to do would be to trigger a PlanChanged + // (the doc says that it might happen without the plan changing, which is + // the case here - but alternatively there could be a ReplanRequested + // listener that works similarly). However, notifying the PlanChanged + // listeners is a planstate manager thing, and planstate seems to be more + // "build the layers into a plan" than actually taking action on the plan. + // Maybe planstate should gain a Replan method that does call the listeners + // but then this code would be calling that method and it's not clear what + // it would do *other* that calling the listeners. planstate's + // callChangeListeners could get a public interface, but it still seems like + // it's around the wrong way for the services API to be calling it. + // If you don't instinctively know the right way to do this, I'm happy to + // have a call to discuss. I missed this earlier (all my replan testing also + // had changes to the plan happening so services were started up again), so + // have only had a small amount of time to think it over/try things out. + if payload.Action == "replan" { + checkmgr := c.d.overlord.CheckManager() + plan := c.d.overlord.PlanManager().Plan() + defer checkmgr.PlanChanged(plan) + } + st := c.d.overlord.State() st.Lock() defer st.Unlock() @@ -181,8 +206,9 @@ func v1PostServices(c *Command, r *http.Request, _ *UserState) Response { } sort.Strings(services) payload.Services = services + default: - return BadRequest("action %q is unsupported", payload.Action) + return BadRequest("invalid action %q", payload.Action) } if err != nil { return BadRequest("cannot %s services: %v", payload.Action, err) diff --git a/internals/overlord/checkstate/manager.go b/internals/overlord/checkstate/manager.go index 81eed861d..16fe80c78 100644 --- a/internals/overlord/checkstate/manager.go +++ b/internals/overlord/checkstate/manager.go @@ -43,6 +43,8 @@ type CheckManager struct { checksLock sync.Mutex checks map[string]CheckInfo + + plan *plan.Plan } // FailureFunc is the type of function called when a failure action is triggered. @@ -136,6 +138,15 @@ func (m *CheckManager) PlanChanged(newPlan *plan.Plan) { // Don't restart check if its configuration hasn't changed. continue } + // We exclude any checks that have changed from startup:enabled + // to startup:disabled, because these should now be inactive and + // only started when explicitly requested. + // If the plan doesn't have an explicit startup value, it + // defaults to enabled, for backwards compatibility. + if newConfig.Startup == plan.CheckStartupDisabled && + (oldConfig.Startup == plan.CheckStartupEnabled || oldConfig.Startup == plan.CheckStartupUnknown) { + continue + } // Check is in old and new plans and has been modified. newOrModified[details.Name] = true } @@ -145,10 +156,17 @@ func (m *CheckManager) PlanChanged(newPlan *plan.Plan) { } } - // Also find checks that are new (in new plan but not in old one). + // Also find checks that are new (in new plan but not in old one) and have + // `startup` set to `enabled` (or not explicitly set). for _, config := range newPlan.Checks { if !existingChecks[config.Name] { - newOrModified[config.Name] = true + if config.Startup == plan.CheckStartupEnabled || config.Startup == plan.CheckStartupUnknown { + newOrModified[config.Name] = true + } else { + // Check is new and should be inactive - no need to start it, + // but we need to add it to the list of existing checks. + m.updateCheckInfo(config, "", 0) + } } } @@ -316,12 +334,19 @@ func (m *CheckManager) updateCheckInfo(config *plan.Check, changeID string, fail defer m.checksLock.Unlock() status := CheckStatusUp - if failures >= config.Threshold { + if changeID == "" { + status = CheckStatusInactive + } else if failures >= config.Threshold { status = CheckStatusDown } + startup := config.Startup + if startup == plan.CheckStartupUnknown { + startup = plan.CheckStartupEnabled + } m.checks[config.Name] = CheckInfo{ Name: config.Name, Level: config.Level, + Startup: startup, Status: status, Failures: failures, Threshold: config.Threshold, @@ -340,6 +365,7 @@ func (m *CheckManager) deleteCheckInfo(name string) { type CheckInfo struct { Name string Level plan.CheckLevel + Startup plan.CheckStartup Status CheckStatus Failures int Threshold int @@ -349,10 +375,74 @@ type CheckInfo struct { type CheckStatus string const ( - CheckStatusUp CheckStatus = "up" - CheckStatusDown CheckStatus = "down" + CheckStatusUp CheckStatus = "up" + CheckStatusDown CheckStatus = "down" + CheckStatusInactive CheckStatus = "inactive" ) type checker interface { check(ctx context.Context) error } + +// StartChecks starts the specified checks, if not already running, and returns +// the checks that did need to be started. +func (m *CheckManager) StartChecks(currentPlan *plan.Plan, checks []string) ([]string, error) { + m.state.Lock() + defer m.state.Unlock() + + // If any check specified is not in the plan, return an error. + for _, name := range checks { + if _, ok := currentPlan.Checks[name]; !ok { + return nil, fmt.Errorf("cannot find check %q in plan", name) + } + } + var started []string + for _, name := range checks { + check := currentPlan.Checks[name] // We know this is ok because we checked it above. + info, ok := m.checks[name] + if !ok { + panic(fmt.Sprintf("check %s is in the plan but not known to the manager", name)) + } + // If the check is already running, skip it. + if info.ChangeID != "" { + continue + } + changeID := performCheckChange(m.state, check) + m.updateCheckInfo(check, changeID, 0) + started = append(started, check.Name) + } + + return started, nil +} + +// StopChecks stops the specified checks, if currently running, and returns +// the checks that did need to be stopped. +func (m *CheckManager) StopChecks(currentPlan *plan.Plan, checks []string) ([]string, error) { + m.state.Lock() + defer m.state.Unlock() + + // If any check specified is not in the plan, return an error. + for _, name := range checks { + if _, ok := currentPlan.Checks[name]; !ok { + return nil, fmt.Errorf("cannot find check %q in plan", name) + } + } + var stopped []string + for _, name := range checks { + check := currentPlan.Checks[name] // We know this is ok because we checked it above. + info, ok := m.checks[name] + if !ok { + panic(fmt.Sprintf("check %s is in the plan but not known to the manager", name)) + } + // If the check is not running, skip it. + if info.ChangeID == "" { + continue + } + change := m.state.Change(info.ChangeID) + change.Abort() + m.updateCheckInfo(currentPlan.Checks[check.Name], "", 0) + stopped = append(stopped, check.Name) + } + + return stopped, nil +} diff --git a/internals/overlord/checkstate/manager_test.go b/internals/overlord/checkstate/manager_test.go index a455c6a0f..e12b3cdc8 100644 --- a/internals/overlord/checkstate/manager_test.go +++ b/internals/overlord/checkstate/manager_test.go @@ -102,9 +102,9 @@ func (s *ManagerSuite) TestChecks(c *C) { // Wait for expected checks to be started. waitChecks(c, s.manager, []*checkstate.CheckInfo{ - {Name: "chk1", Status: "up", Threshold: 3}, - {Name: "chk2", Status: "up", Level: "alive", Threshold: 3}, - {Name: "chk3", Status: "up", Level: "ready", Threshold: 3}, + {Name: "chk1", Startup: "enabled", Status: "up", Threshold: 3}, + {Name: "chk2", Startup: "enabled", Status: "up", Level: "alive", Threshold: 3}, + {Name: "chk3", Startup: "enabled", Status: "up", Level: "ready", Threshold: 3}, }) // Re-configuring should update checks. @@ -121,7 +121,7 @@ func (s *ManagerSuite) TestChecks(c *C) { // Wait for checks to be updated. waitChecks(c, s.manager, []*checkstate.CheckInfo{ - {Name: "chk4", Status: "up", Threshold: 3}, + {Name: "chk4", Startup: "enabled", Status: "up", Threshold: 3}, }) } @@ -342,9 +342,9 @@ func (s *ManagerSuite) TestPlanChangedSmarts(c *C) { }) waitChecks(c, s.manager, []*checkstate.CheckInfo{ - {Name: "chk1", Status: "up", Threshold: 3}, - {Name: "chk2", Status: "up", Threshold: 3}, - {Name: "chk3", Status: "up", Threshold: 3}, + {Name: "chk1", Startup: "enabled", Status: "up", Threshold: 3}, + {Name: "chk2", Startup: "enabled", Status: "up", Threshold: 3}, + {Name: "chk3", Startup: "enabled", Status: "up", Threshold: 3}, }) checks, err := s.manager.Checks() c.Assert(err, IsNil) @@ -373,8 +373,8 @@ func (s *ManagerSuite) TestPlanChangedSmarts(c *C) { }) waitChecks(c, s.manager, []*checkstate.CheckInfo{ - {Name: "chk1", Status: "up", Threshold: 3}, - {Name: "chk2", Status: "up", Threshold: 6}, + {Name: "chk1", Startup: "enabled", Status: "up", Threshold: 3}, + {Name: "chk2", Startup: "enabled", Status: "up", Threshold: 6}, }) checks, err = s.manager.Checks() c.Assert(err, IsNil) @@ -426,8 +426,8 @@ func (s *ManagerSuite) TestPlanChangedServiceContext(c *C) { s.manager.PlanChanged(origPlan) waitChecks(c, s.manager, []*checkstate.CheckInfo{ - {Name: "chk1", Status: "up", Threshold: 3}, - {Name: "chk2", Status: "up", Threshold: 3}, + {Name: "chk1", Startup: "enabled", Status: "up", Threshold: 3}, + {Name: "chk2", Startup: "enabled", Status: "up", Threshold: 3}, }) checks, err := s.manager.Checks() c.Assert(err, IsNil) @@ -454,8 +454,8 @@ func (s *ManagerSuite) TestPlanChangedServiceContext(c *C) { }) waitChecks(c, s.manager, []*checkstate.CheckInfo{ - {Name: "chk1", Status: "up", Threshold: 3}, - {Name: "chk2", Status: "up", Threshold: 3}, + {Name: "chk1", Startup: "enabled", Status: "up", Threshold: 3}, + {Name: "chk2", Startup: "enabled", Status: "up", Threshold: 3}, }) checks, err = s.manager.Checks() c.Assert(err, IsNil) diff --git a/internals/plan/plan.go b/internals/plan/plan.go index d2a9a4f3f..1c47a61bb 100644 --- a/internals/plan/plan.go +++ b/internals/plan/plan.go @@ -420,9 +420,10 @@ const ( // Check specifies configuration for a single health check. type Check struct { // Basic details - Name string `yaml:"-"` - Override Override `yaml:"override,omitempty"` - Level CheckLevel `yaml:"level,omitempty"` + Name string `yaml:"-"` + Override Override `yaml:"override,omitempty"` + Level CheckLevel `yaml:"level,omitempty"` + Startup CheckStartup `yaml:"startup,omitempty"` // Common check settings Period OptionalDuration `yaml:"period,omitempty"` @@ -455,6 +456,9 @@ func (c *Check) Merge(other *Check) { if other.Level != "" { c.Level = other.Level } + if other.Startup != "" { + c.Startup = other.Startup + } if other.Period.IsSet { c.Period = other.Period } @@ -493,6 +497,15 @@ const ( ReadyLevel CheckLevel = "ready" ) +// CheckStartup defines the different startup modes for a check. +type CheckStartup string + +const ( + CheckStartupUnknown CheckStartup = "" + CheckStartupEnabled CheckStartup = "enabled" + CheckStartupDisabled CheckStartup = "disabled" +) + // HTTPCheck holds the configuration for an HTTP health check. type HTTPCheck struct { URL string `yaml:"url,omitempty"` @@ -908,6 +921,11 @@ func (layer *Layer) Validate() error { Message: fmt.Sprintf(`plan check %q level must be "alive" or "ready"`, name), } } + if check.Startup != CheckStartupUnknown && check.Startup != CheckStartupEnabled && check.Startup != CheckStartupDisabled { + return &FormatError{ + Message: fmt.Sprintf(`plan check %q startup must be "enabled" or "disabled"`, name), + } + } if check.Period.IsSet && check.Period.Value == 0 { return &FormatError{ Message: fmt.Sprintf("plan check %q period must not be zero", name),