5ee7cc5e6d
* Rename common.go->healthcheck.go Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com> * Push handling of no resources to the health checks This allows us to better run on empty mounts. Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com> * Exit when no issuers are found This makes health checks less useful. Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com> * Add additional test criteria, refactor tests This will allow us to setup more tests. Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com> * Add more OK statuses when checks are good Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com> * Add test cases for all bad results The test for too-many-certs was elided for now due to being too hard to setup in CI. Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com> * Add test for missing mount Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com> * Add expected failure test on empty mount Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com> * Add test for only having an issuer in the mount Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com> * More consistently perform permission checks Also return them to the caller when they're relevant. Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com> * Add test without token Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com> * Run health check tests in parallel Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com> * Update command/healthcheck/healthcheck.go Co-authored-by: Steven Clark <steven.clark@hashicorp.com> * Update command/healthcheck/healthcheck.go Co-authored-by: Steven Clark <steven.clark@hashicorp.com> Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com> Co-authored-by: Steven Clark <steven.clark@hashicorp.com>
281 lines
7.9 KiB
Go
281 lines
7.9 KiB
Go
/*
|
|
* The healthcheck package attempts to allow generic checks of arbitrary
|
|
* engines, while providing a common framework with some performance
|
|
* efficiencies in mind.
|
|
*
|
|
* The core of this package is the Executor context; a caller would
|
|
* provision a set of checks, an API client, and a configuration,
|
|
* which the executor would use to decide which checks to execute
|
|
* and how.
|
|
*
|
|
* Checks are based around a series of remote paths that are fetched by
|
|
* the client; these are broken into two categories: static paths, which
|
|
* can always be fetched; and dynamic paths, which the check fetches based
|
|
* on earlier results.
|
|
*
|
|
* For instance, a basic PKI CA lifetime check will have static fetch against
|
|
* the list of CAs, and a dynamic fetch, using that earlier list, to fetch the
|
|
* PEMs of all CAs.
|
|
*
|
|
* This allows health checks to share data: many PKI checks will need the
|
|
* issuer list and so repeatedly fetching this may result in a performance
|
|
* impact.
|
|
*/
|
|
|
|
package healthcheck
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/hashicorp/vault/api"
|
|
"github.com/hashicorp/vault/sdk/logical"
|
|
)
|
|
|
|
type Executor struct {
|
|
Client *api.Client
|
|
Mount string
|
|
DefaultEnabled bool
|
|
|
|
Config map[string]map[string]interface{}
|
|
|
|
Resources map[string]map[logical.Operation]*PathFetch
|
|
|
|
Checkers []Check
|
|
}
|
|
|
|
func NewExecutor(client *api.Client, mount string) *Executor {
|
|
return &Executor{
|
|
Client: client,
|
|
DefaultEnabled: true,
|
|
Mount: mount,
|
|
Config: make(map[string]map[string]interface{}),
|
|
Resources: make(map[string]map[logical.Operation]*PathFetch),
|
|
}
|
|
}
|
|
|
|
func (e *Executor) AddCheck(c Check) {
|
|
e.Checkers = append(e.Checkers, c)
|
|
}
|
|
|
|
func (e *Executor) BuildConfig(external map[string]interface{}) error {
|
|
merged := e.Config
|
|
|
|
for index, checker := range e.Checkers {
|
|
name := checker.Name()
|
|
if _, present := merged[name]; name == "" || present {
|
|
return fmt.Errorf("bad checker %v: name is empty or already present: %v", index, name)
|
|
}
|
|
|
|
// Fetch the default configuration; if the check returns enabled
|
|
// status, verify it matches our expectations (in the event it should
|
|
// be disabled by default), otherwise, add it in.
|
|
config := checker.DefaultConfig()
|
|
enabled, present := config["enabled"]
|
|
if !present {
|
|
config["enabled"] = e.DefaultEnabled
|
|
} else if enabled.(bool) && !e.DefaultEnabled {
|
|
config["enabled"] = e.DefaultEnabled
|
|
}
|
|
|
|
// Now apply any external config for this check.
|
|
if econfig, present := external[name]; present {
|
|
for param, evalue := range econfig.(map[string]interface{}) {
|
|
if _, ok := config[param]; !ok {
|
|
// Assumption: default configs have all possible
|
|
// configuration options. This external config has
|
|
// an unknown option, so we want to error out.
|
|
return fmt.Errorf("unknown configuration option for %v: %v", name, param)
|
|
}
|
|
|
|
config[param] = evalue
|
|
}
|
|
}
|
|
|
|
// Now apply it and save it.
|
|
if err := checker.LoadConfig(config); err != nil {
|
|
return fmt.Errorf("error saving merged config for %v: %w", name, err)
|
|
}
|
|
merged[name] = config
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (e *Executor) Execute() (map[string][]*Result, error) {
|
|
ret := make(map[string][]*Result)
|
|
for _, checker := range e.Checkers {
|
|
if !checker.IsEnabled() {
|
|
continue
|
|
}
|
|
|
|
if err := checker.FetchResources(e); err != nil {
|
|
return nil, fmt.Errorf("failed to fetch resources %v: %w", checker.Name(), err)
|
|
}
|
|
|
|
results, err := checker.Evaluate(e)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to evaluate %v: %w", checker.Name(), err)
|
|
}
|
|
|
|
for _, result := range results {
|
|
result.Endpoint = e.templatePath(result.Endpoint)
|
|
result.StatusDisplay = ResultStatusNameMap[result.Status]
|
|
}
|
|
|
|
ret[checker.Name()] = results
|
|
}
|
|
|
|
return ret, nil
|
|
}
|
|
|
|
func (e *Executor) templatePath(path string) string {
|
|
return strings.ReplaceAll(path, "{{mount}}", e.Mount)
|
|
}
|
|
|
|
func (e *Executor) FetchIfNotFetched(op logical.Operation, rawPath string) (*PathFetch, error) {
|
|
path := e.templatePath(rawPath)
|
|
|
|
byOp, present := e.Resources[path]
|
|
if present && byOp != nil {
|
|
result, present := byOp[op]
|
|
if present && result != nil {
|
|
return result, result.FetchSurfaceError()
|
|
}
|
|
}
|
|
|
|
// Must not exist in cache; create it.
|
|
if byOp == nil {
|
|
e.Resources[path] = make(map[logical.Operation]*PathFetch)
|
|
}
|
|
|
|
ret := &PathFetch{
|
|
Operation: op,
|
|
Path: path,
|
|
ParsedCache: make(map[string]interface{}),
|
|
}
|
|
|
|
data := map[string][]string{}
|
|
if op == logical.ListOperation {
|
|
data["list"] = []string{"true"}
|
|
} else if op != logical.ReadOperation {
|
|
return nil, fmt.Errorf("unknown operation: %v on %v", op, path)
|
|
}
|
|
|
|
response, err := e.Client.Logical().ReadRawWithData(path, data)
|
|
ret.Response = response
|
|
if err != nil {
|
|
ret.FetchError = err
|
|
} else {
|
|
// Not all secrets will parse correctly. Sometimes we really want
|
|
// to fetch a raw endpoint, sometimes we're run with a bad mount
|
|
// or missing permissions.
|
|
secret, secretErr := e.Client.Logical().ParseRawResponseAndCloseBody(response, err)
|
|
if secretErr != nil {
|
|
ret.SecretParseError = secretErr
|
|
} else {
|
|
ret.Secret = secret
|
|
}
|
|
}
|
|
|
|
e.Resources[path][op] = ret
|
|
return ret, ret.FetchSurfaceError()
|
|
}
|
|
|
|
type PathFetch struct {
|
|
Operation logical.Operation
|
|
Path string
|
|
Response *api.Response
|
|
FetchError error
|
|
Secret *api.Secret
|
|
SecretParseError error
|
|
ParsedCache map[string]interface{}
|
|
}
|
|
|
|
func (p *PathFetch) IsOK() bool {
|
|
return p.FetchError == nil && p.Response != nil
|
|
}
|
|
|
|
func (p *PathFetch) IsSecretOK() bool {
|
|
return p.IsOK() && p.SecretParseError == nil && p.Secret != nil
|
|
}
|
|
|
|
func (p *PathFetch) FetchSurfaceError() error {
|
|
if p.IsOK() || p.IsSecretPermissionsError() || p.IsUnsupportedPathError() || p.IsMissingResource() || p.Is404NotFound() {
|
|
return nil
|
|
}
|
|
|
|
if strings.Contains(p.FetchError.Error(), "route entry not found") {
|
|
return fmt.Errorf("Error making API request: was a bad mount given?\n\nOperation: %v\nPath: %v\nOriginal Error:\n%w", p.Operation, p.Path, p.FetchError)
|
|
}
|
|
|
|
return p.FetchError
|
|
}
|
|
|
|
func (p *PathFetch) IsSecretPermissionsError() bool {
|
|
return !p.IsOK() && strings.Contains(p.FetchError.Error(), "permission denied")
|
|
}
|
|
|
|
func (p *PathFetch) IsUnsupportedPathError() bool {
|
|
return !p.IsOK() && strings.Contains(p.FetchError.Error(), "unsupported path")
|
|
}
|
|
|
|
func (p *PathFetch) IsMissingResource() bool {
|
|
return !p.IsOK() && strings.Contains(p.FetchError.Error(), "unable to find")
|
|
}
|
|
|
|
func (p *PathFetch) Is404NotFound() bool {
|
|
return !p.IsOK() && strings.HasSuffix(strings.TrimSpace(p.FetchError.Error()), "Code: 404. Errors:")
|
|
}
|
|
|
|
type Check interface {
|
|
Name() string
|
|
IsEnabled() bool
|
|
|
|
DefaultConfig() map[string]interface{}
|
|
LoadConfig(config map[string]interface{}) error
|
|
|
|
FetchResources(e *Executor) error
|
|
|
|
Evaluate(e *Executor) ([]*Result, error)
|
|
}
|
|
|
|
type ResultStatus int
|
|
|
|
const (
|
|
ResultNotApplicable ResultStatus = iota
|
|
ResultOK
|
|
ResultInformational
|
|
ResultWarning
|
|
ResultCritical
|
|
ResultInvalidVersion
|
|
ResultInsufficientPermissions
|
|
)
|
|
|
|
var ResultStatusNameMap = map[ResultStatus]string{
|
|
ResultNotApplicable: "not_applicable",
|
|
ResultOK: "ok",
|
|
ResultInformational: "informational",
|
|
ResultWarning: "warning",
|
|
ResultCritical: "critical",
|
|
ResultInvalidVersion: "invalid_version",
|
|
ResultInsufficientPermissions: "insufficient_permissions",
|
|
}
|
|
|
|
var NameResultStatusMap = map[string]ResultStatus{
|
|
"not_applicable": ResultNotApplicable,
|
|
"ok": ResultOK,
|
|
"informational": ResultInformational,
|
|
"warning": ResultWarning,
|
|
"critical": ResultCritical,
|
|
"invalid_version": ResultInvalidVersion,
|
|
"insufficient_permissions": ResultInsufficientPermissions,
|
|
}
|
|
|
|
type Result struct {
|
|
Status ResultStatus `json:"status_code"`
|
|
StatusDisplay string `json:"status"`
|
|
Endpoint string `json:"endpoint,omitempty"`
|
|
Message string `json:"message,omitempty"`
|
|
}
|