mirror of https://github.com/hashicorp/consul
added node health resource (#19803)
parent
65c06f67e6
commit
7936e55807
@ -0,0 +1,90 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package types
|
||||
|
||||
import (
|
||||
"github.com/hashicorp/go-multierror"
|
||||
|
||||
"github.com/hashicorp/consul/acl"
|
||||
"github.com/hashicorp/consul/internal/resource"
|
||||
pbcatalog "github.com/hashicorp/consul/proto-public/pbcatalog/v2beta1"
|
||||
"github.com/hashicorp/consul/proto-public/pbresource"
|
||||
)
|
||||
|
||||
type DecodedNodeHealthStatus = resource.DecodedResource[*pbcatalog.NodeHealthStatus]
|
||||
|
||||
func RegisterNodeHealthStatus(r resource.Registry) {
|
||||
r.Register(resource.Registration{
|
||||
Type: pbcatalog.NodeHealthStatusType,
|
||||
Proto: &pbcatalog.NodeHealthStatus{},
|
||||
Scope: resource.ScopePartition,
|
||||
Validate: ValidateNodeHealthStatus,
|
||||
ACLs: &resource.ACLHooks{
|
||||
Read: resource.AuthorizeReadWithResource(aclReadHookNodeHealthStatus),
|
||||
Write: aclWriteHookNodeHealthStatus,
|
||||
List: resource.NoOpACLListHook,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
var ValidateNodeHealthStatus = resource.DecodeAndValidate(validateNodeHealthStatus)
|
||||
|
||||
func validateNodeHealthStatus(res *DecodedNodeHealthStatus) error {
|
||||
var err error
|
||||
|
||||
// Should we allow empty types? I think for now it will be safest to require
|
||||
// the type field is set and we can relax this restriction in the future
|
||||
// if we deem it desirable.
|
||||
if res.Data.Type == "" {
|
||||
err = multierror.Append(err, resource.ErrInvalidField{
|
||||
Name: "type",
|
||||
Wrapped: resource.ErrMissing,
|
||||
})
|
||||
}
|
||||
|
||||
switch res.Data.Status {
|
||||
case pbcatalog.Health_HEALTH_PASSING,
|
||||
pbcatalog.Health_HEALTH_WARNING,
|
||||
pbcatalog.Health_HEALTH_CRITICAL,
|
||||
pbcatalog.Health_HEALTH_MAINTENANCE:
|
||||
default:
|
||||
err = multierror.Append(err, resource.ErrInvalidField{
|
||||
Name: "status",
|
||||
Wrapped: errInvalidHealth,
|
||||
})
|
||||
}
|
||||
|
||||
// Ensure that the NodeHealthStatus' owner is a type that we want to allow. The
|
||||
// owner is currently the resource that this NodeHealthStatus applies to. If we
|
||||
// change this to be a parent reference within the NodeHealthStatus.Data then
|
||||
// we could allow for other owners.
|
||||
if res.Resource.Owner == nil {
|
||||
err = multierror.Append(err, resource.ErrInvalidField{
|
||||
Name: "owner",
|
||||
Wrapped: resource.ErrMissing,
|
||||
})
|
||||
} else if !resource.EqualType(res.Owner.Type, pbcatalog.NodeType) {
|
||||
err = multierror.Append(err, resource.ErrOwnerTypeInvalid{ResourceType: res.Id.Type, OwnerType: res.Owner.Type})
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func aclReadHookNodeHealthStatus(authorizer acl.Authorizer, authzContext *acl.AuthorizerContext, res *pbresource.Resource) error {
|
||||
// For a health status of a node we need to check node:read perms.
|
||||
if res.GetOwner() != nil && resource.EqualType(res.GetOwner().GetType(), pbcatalog.NodeType) {
|
||||
return authorizer.ToAllowAuthorizer().NodeReadAllowed(res.GetOwner().GetName(), authzContext)
|
||||
}
|
||||
|
||||
return acl.PermissionDenied("cannot read catalog.NodeHealthStatus because there is no owner")
|
||||
}
|
||||
|
||||
func aclWriteHookNodeHealthStatus(authorizer acl.Authorizer, authzContext *acl.AuthorizerContext, res *pbresource.Resource) error {
|
||||
// For a health status of a node we need to check node:write perms.
|
||||
if res.GetOwner() != nil && resource.EqualType(res.GetOwner().GetType(), pbcatalog.NodeType) {
|
||||
return authorizer.ToAllowAuthorizer().NodeWriteAllowed(res.GetOwner().GetName(), authzContext)
|
||||
}
|
||||
|
||||
return acl.PermissionDenied("cannot write catalog.NodeHealthStatus because there is no owner")
|
||||
}
|
@ -0,0 +1,273 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package types
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/consul/internal/resource"
|
||||
"github.com/hashicorp/consul/internal/resource/resourcetest"
|
||||
pbcatalog "github.com/hashicorp/consul/proto-public/pbcatalog/v2beta1"
|
||||
"github.com/hashicorp/consul/proto-public/pbresource"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/protobuf/reflect/protoreflect"
|
||||
"google.golang.org/protobuf/types/known/anypb"
|
||||
)
|
||||
|
||||
var (
|
||||
defaultNodeHealthStatusOwnerTenancy = &pbresource.Tenancy{
|
||||
Partition: "default",
|
||||
PeerName: "local",
|
||||
}
|
||||
|
||||
defaultNodeHealthStatusOwner = &pbresource.ID{
|
||||
Type: pbcatalog.NodeType,
|
||||
Tenancy: defaultNodeHealthStatusOwnerTenancy,
|
||||
Name: "foo",
|
||||
}
|
||||
)
|
||||
|
||||
func createNodeHealthStatusResource(t *testing.T, data protoreflect.ProtoMessage, owner *pbresource.ID) *pbresource.Resource {
|
||||
res := &pbresource.Resource{
|
||||
Id: &pbresource.ID{
|
||||
Type: pbcatalog.NodeHealthStatusType,
|
||||
Tenancy: &pbresource.Tenancy{
|
||||
Partition: "default",
|
||||
PeerName: "local",
|
||||
},
|
||||
Name: "test-status",
|
||||
},
|
||||
Owner: owner,
|
||||
}
|
||||
|
||||
var err error
|
||||
res.Data, err = anypb.New(data)
|
||||
require.NoError(t, err)
|
||||
return res
|
||||
}
|
||||
|
||||
func TestValidateNodeHealthStatus_Ok(t *testing.T) {
|
||||
data := &pbcatalog.NodeHealthStatus{
|
||||
Type: "tcp",
|
||||
Status: pbcatalog.Health_HEALTH_PASSING,
|
||||
Description: "Doesn't matter as this is user settable",
|
||||
Output: "Health check executors are free to use this field",
|
||||
}
|
||||
|
||||
type testCase struct {
|
||||
owner *pbresource.ID
|
||||
}
|
||||
|
||||
cases := map[string]testCase{
|
||||
"node-owned": {
|
||||
owner: &pbresource.ID{
|
||||
Type: pbcatalog.NodeType,
|
||||
Tenancy: defaultNodeHealthStatusOwnerTenancy,
|
||||
Name: "bar-node",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for name, tcase := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
res := createNodeHealthStatusResource(t, data, tcase.owner)
|
||||
err := ValidateNodeHealthStatus(res)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateNodeHealthStatus_ParseError(t *testing.T) {
|
||||
// Any type other than the NodeHealthStatus type would work
|
||||
// to cause the error we are expecting
|
||||
data := &pbcatalog.IP{Address: "198.18.0.1"}
|
||||
|
||||
res := createNodeHealthStatusResource(t, data, defaultNodeHealthStatusOwner)
|
||||
|
||||
err := ValidateNodeHealthStatus(res)
|
||||
require.Error(t, err)
|
||||
require.ErrorAs(t, err, &resource.ErrDataParse{})
|
||||
}
|
||||
|
||||
func TestValidateNodeHealthStatus_InvalidHealth(t *testing.T) {
|
||||
// while this is a valid enum value it is not allowed to be used
|
||||
// as the Status field.
|
||||
data := &pbcatalog.NodeHealthStatus{
|
||||
Type: "tcp",
|
||||
Status: pbcatalog.Health_HEALTH_ANY,
|
||||
}
|
||||
|
||||
res := createNodeHealthStatusResource(t, data, defaultNodeHealthStatusOwner)
|
||||
|
||||
err := ValidateNodeHealthStatus(res)
|
||||
require.Error(t, err)
|
||||
expected := resource.ErrInvalidField{
|
||||
Name: "status",
|
||||
Wrapped: errInvalidHealth,
|
||||
}
|
||||
var actual resource.ErrInvalidField
|
||||
require.ErrorAs(t, err, &actual)
|
||||
require.Equal(t, expected, actual)
|
||||
}
|
||||
|
||||
func TestValidateNodeHealthStatus_MissingType(t *testing.T) {
|
||||
data := &pbcatalog.NodeHealthStatus{
|
||||
Status: pbcatalog.Health_HEALTH_PASSING,
|
||||
}
|
||||
|
||||
res := createNodeHealthStatusResource(t, data, defaultNodeHealthStatusOwner)
|
||||
|
||||
err := ValidateNodeHealthStatus(res)
|
||||
require.Error(t, err)
|
||||
expected := resource.ErrInvalidField{
|
||||
Name: "type",
|
||||
Wrapped: resource.ErrMissing,
|
||||
}
|
||||
var actual resource.ErrInvalidField
|
||||
require.ErrorAs(t, err, &actual)
|
||||
require.Equal(t, expected, actual)
|
||||
}
|
||||
|
||||
func TestValidateNodeHealthStatus_MissingOwner(t *testing.T) {
|
||||
data := &pbcatalog.NodeHealthStatus{
|
||||
Type: "tcp",
|
||||
Status: pbcatalog.Health_HEALTH_PASSING,
|
||||
}
|
||||
|
||||
res := createNodeHealthStatusResource(t, data, nil)
|
||||
|
||||
err := ValidateNodeHealthStatus(res)
|
||||
require.Error(t, err)
|
||||
expected := resource.ErrInvalidField{
|
||||
Name: "owner",
|
||||
Wrapped: resource.ErrMissing,
|
||||
}
|
||||
var actual resource.ErrInvalidField
|
||||
require.ErrorAs(t, err, &actual)
|
||||
require.Equal(t, expected, actual)
|
||||
}
|
||||
|
||||
func TestValidateNodeHealthStatus_InvalidOwner(t *testing.T) {
|
||||
data := &pbcatalog.NodeHealthStatus{
|
||||
Type: "tcp",
|
||||
Status: pbcatalog.Health_HEALTH_PASSING,
|
||||
}
|
||||
|
||||
type testCase struct {
|
||||
owner *pbresource.ID
|
||||
}
|
||||
|
||||
cases := map[string]testCase{
|
||||
"group-mismatch": {
|
||||
owner: &pbresource.ID{
|
||||
Type: &pbresource.Type{
|
||||
Group: "fake",
|
||||
GroupVersion: pbcatalog.Version,
|
||||
Kind: pbcatalog.NodeKind,
|
||||
},
|
||||
Tenancy: defaultNodeHealthStatusOwnerTenancy,
|
||||
Name: "baz",
|
||||
},
|
||||
},
|
||||
"group-version-mismatch": {
|
||||
owner: &pbresource.ID{
|
||||
Type: &pbresource.Type{
|
||||
Group: pbcatalog.GroupName,
|
||||
GroupVersion: "v99",
|
||||
Kind: pbcatalog.NodeKind,
|
||||
},
|
||||
Tenancy: defaultNodeHealthStatusOwnerTenancy,
|
||||
Name: "baz",
|
||||
},
|
||||
},
|
||||
"kind-mismatch": {
|
||||
owner: &pbresource.ID{
|
||||
Type: pbcatalog.ServiceType,
|
||||
Tenancy: defaultNodeHealthStatusOwnerTenancy,
|
||||
Name: "baz",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for name, tcase := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
res := createNodeHealthStatusResource(t, data, tcase.owner)
|
||||
err := ValidateNodeHealthStatus(res)
|
||||
require.Error(t, err)
|
||||
expected := resource.ErrOwnerTypeInvalid{
|
||||
ResourceType: pbcatalog.NodeHealthStatusType,
|
||||
OwnerType: tcase.owner.Type,
|
||||
}
|
||||
var actual resource.ErrOwnerTypeInvalid
|
||||
require.ErrorAs(t, err, &actual)
|
||||
require.Equal(t, expected, actual)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeHealthStatusACLs(t *testing.T) {
|
||||
registry := resource.NewRegistry()
|
||||
Register(registry)
|
||||
|
||||
node := resourcetest.Resource(pbcatalog.NodeType, "test").ID()
|
||||
|
||||
nodehealthStatusData := &pbcatalog.NodeHealthStatus{
|
||||
Type: "tcp",
|
||||
Status: pbcatalog.Health_HEALTH_PASSING,
|
||||
}
|
||||
|
||||
cases := map[string]resourcetest.ACLTestCase{
|
||||
"no rules": {
|
||||
Rules: ``,
|
||||
Data: nodehealthStatusData,
|
||||
Owner: node,
|
||||
Typ: pbcatalog.NodeHealthStatusType,
|
||||
ReadOK: resourcetest.DENY,
|
||||
WriteOK: resourcetest.DENY,
|
||||
ListOK: resourcetest.DEFAULT,
|
||||
},
|
||||
"service test read with node owner": {
|
||||
Rules: `service "test" { policy = "read" }`,
|
||||
Data: nodehealthStatusData,
|
||||
Owner: node,
|
||||
Typ: pbcatalog.NodeHealthStatusType,
|
||||
ReadOK: resourcetest.DENY,
|
||||
WriteOK: resourcetest.DENY,
|
||||
ListOK: resourcetest.DEFAULT,
|
||||
},
|
||||
"service test write with node owner": {
|
||||
Rules: `service "test" { policy = "write" }`,
|
||||
Data: nodehealthStatusData,
|
||||
Owner: node,
|
||||
Typ: pbcatalog.NodeHealthStatusType,
|
||||
ReadOK: resourcetest.DENY,
|
||||
WriteOK: resourcetest.DENY,
|
||||
ListOK: resourcetest.DEFAULT,
|
||||
},
|
||||
"node test read with node owner": {
|
||||
Rules: `node "test" { policy = "read" }`,
|
||||
Data: nodehealthStatusData,
|
||||
Owner: node,
|
||||
Typ: pbcatalog.NodeHealthStatusType,
|
||||
ReadOK: resourcetest.ALLOW,
|
||||
WriteOK: resourcetest.DENY,
|
||||
ListOK: resourcetest.DEFAULT,
|
||||
},
|
||||
"node test write with node owner": {
|
||||
Rules: `node "test" { policy = "write" }`,
|
||||
Data: nodehealthStatusData,
|
||||
Owner: node,
|
||||
Typ: pbcatalog.NodeHealthStatusType,
|
||||
ReadOK: resourcetest.ALLOW,
|
||||
WriteOK: resourcetest.ALLOW,
|
||||
ListOK: resourcetest.DEFAULT,
|
||||
},
|
||||
}
|
||||
|
||||
for name, tc := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
resourcetest.RunACLTestCase(t, tc, registry)
|
||||
})
|
||||
}
|
||||
}
|
Loading…
Reference in new issue