mirror of https://github.com/status-im/consul.git
565 lines
17 KiB
Markdown
565 lines
17 KiB
Markdown
# Resource and Controller Developer Guide
|
|
|
|
This is a whistle-stop tour through adding a new resource type and controller to
|
|
Consul 🚂
|
|
|
|
## Resource Schema
|
|
|
|
Adding a new resource type begins with defining the object schema as a protobuf
|
|
message, in the appropriate package under [`proto-public`](../../../proto-public).
|
|
|
|
```shell
|
|
$ mkdir proto-public/pbfoo/v1alpha1
|
|
```
|
|
|
|
```proto
|
|
// proto-public/pbfoo/v1alpha1/foo.proto
|
|
syntax = "proto3";
|
|
|
|
import "pbresource/resource.proto";
|
|
import "pbresource/annotations.proto";
|
|
|
|
package hashicorp.consul.foo.v1alpha1;
|
|
|
|
message Bar {
|
|
option (hashicorp.consul.resource.spec) = {scope: SCOPE_NAMESPACE};
|
|
|
|
string baz = 1;
|
|
hashicorp.consul.resource.ID qux = 2;
|
|
}
|
|
```
|
|
|
|
```shell
|
|
$ make proto
|
|
```
|
|
|
|
Next, we must add our resource type to the registry. At this point, it's useful
|
|
to add a package (e.g. under [`internal`](../../../internal)) to contain the logic
|
|
associated with this resource type.
|
|
|
|
The convention is to have this package export variables for its type identifiers
|
|
along with a method for registering its types:
|
|
|
|
```Go
|
|
// internal/foo/types.go
|
|
package foo
|
|
|
|
import (
|
|
"github.com/hashicorp/consul/internal/resource"
|
|
pbv1alpha1 "github.com/hashicorp/consul/proto-public/pbfoo/v1alpha1"
|
|
"github.com/hashicorp/consul/proto-public/pbresource"
|
|
)
|
|
|
|
func RegisterTypes(r resource.Registry) {
|
|
r.Register(resource.Registration{
|
|
Type: pbv1alpha1.BarType,
|
|
Scope: resource.ScopePartition,
|
|
Proto: &pbv1alpha1.Bar{},
|
|
})
|
|
}
|
|
```
|
|
Note that Scope reference the scope of the new resource, `resource.ScopePartition`
|
|
mean that resource will be at the partition level and have no namespace, while `resource.ScopeNamespace` mean it will have both a namespace
|
|
and a partition.
|
|
|
|
Update the `NewTypeRegistry` method in [`type_registry.go`] to call your
|
|
package's type registration method:
|
|
|
|
[`type_registry.go`]: ../../../agent/consul/type_registry.go
|
|
|
|
```Go
|
|
import (
|
|
// …
|
|
"github.com/hashicorp/consul/internal/foo"
|
|
// …
|
|
)
|
|
|
|
func NewTypeRegistry() resource.Registry {
|
|
// …
|
|
foo.RegisterTypes(registry)
|
|
// …
|
|
}
|
|
```
|
|
|
|
That should be all you need to start using your new resource type. Test it out
|
|
by starting an agent in dev mode:
|
|
|
|
```shell
|
|
$ make dev
|
|
$ consul agent -dev
|
|
```
|
|
|
|
You can now use [grpcurl](https://github.com/fullstorydev/grpcurl) to interact
|
|
with the [resource service](../../../proto-public/pbresource/resource.proto):
|
|
|
|
```shell
|
|
$ grpcurl -d @ \
|
|
-plaintext \
|
|
-protoset pkg/consul.protoset \
|
|
127.0.0.1:8502 \
|
|
hashicorp.consul.resource.ResourceService.Write \
|
|
<<EOF
|
|
{
|
|
"resource": {
|
|
"id": {
|
|
"type": {
|
|
"group": "foo",
|
|
"group_version": "v1alpha1",
|
|
"kind": "bar"
|
|
},
|
|
"tenancy": {
|
|
"partition": "default",
|
|
"namespace": "default"
|
|
}
|
|
},
|
|
"data": {
|
|
"@type": "types.googleapis.com/hashicorp.consul.foo.v1alpha1.Bar",
|
|
"baz": "Hello World"
|
|
}
|
|
}
|
|
}
|
|
EOF
|
|
```
|
|
|
|
## Validation
|
|
|
|
Broadly, there are two kinds of validation you might want to perform against
|
|
your resources:
|
|
|
|
- **Structural** validation ensures the user's input is well-formed, for
|
|
example: checking that a required field is provided, or that a port is within
|
|
an acceptable range.
|
|
- **Semantic** validation ensures that the resource makes sense in the context
|
|
of *other* resources, for example: checking that an L7 intention is not
|
|
targeting an L4 service.
|
|
|
|
Structural validation should be done up-front, before the resource is admitted,
|
|
using a validation hook provided in the type registration:
|
|
|
|
```Go
|
|
func RegisterTypes(r resource.Registry) {
|
|
r.Register(resource.Registration{
|
|
Type: pbv1alpha1.BarType,
|
|
Proto: &pbv1alpha1.Bar{},
|
|
Scope: resource.ScopeNamespace,
|
|
Validate: validateBar,
|
|
})
|
|
}
|
|
|
|
func validateBar(res *pbresource.Resource) error {
|
|
var bar pbv1alpha1.Bar
|
|
if err := res.Data.UnmarshalTo(&bar); err != nil {
|
|
return resource.NewErrDataParse(&bar, err)
|
|
}
|
|
if bar.Baz == "" {
|
|
return resource.ErrInvalidField{
|
|
Name: "baz",
|
|
Wrapped: resource.ErrMissing,
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
```
|
|
|
|
Semantic validation should be done asynchronously, after the resource is
|
|
written, by controllers ([covered below](#controllers)).
|
|
|
|
## Authorization
|
|
|
|
You can control how operations on your resource type are authorized by providing
|
|
a set of ACL hooks:
|
|
|
|
```Go
|
|
func RegisterTypes(r resource.Registry) {
|
|
r.Register(resource.Registration{
|
|
Type: pbv1alpha1.BarType,
|
|
Proto: &pbv1alpha1.Bar{},
|
|
Scope: resource.ScopeNamespace,
|
|
ACLs: &resource.ACLHooks{,
|
|
Read: authzReadBar,
|
|
Write: authzWriteBar,
|
|
List: authzListBar,
|
|
},
|
|
})
|
|
}
|
|
|
|
func authzReadBar(authz acl.Authorizer, authzContext *acl.AuthorizerContext, id *pbresource.ID, _ *pbresource.Resource) error {
|
|
return authz.ToAllowAuthorizer().
|
|
BarReadAllowed(id.Name, authzContext)
|
|
}
|
|
|
|
func authzWriteBar(authz acl.Authorizer, authzContext *acl.AuthorizerContext, res *pbresource.Resource) error {
|
|
return authz.ToAllowAuthorizer().
|
|
BarWriteAllowed(res.ID().Name, authzContext)
|
|
}
|
|
|
|
func authzListBar(authz acl.Authorizer, authzContext *acl.AuthorizerContext) error {
|
|
return authz.ToAllowAuthorizer().
|
|
BarListAllowed(authzContext)
|
|
}
|
|
```
|
|
|
|
If you do not provide ACL hooks, `operator:read` and `operator:write`
|
|
permissions will be required.
|
|
|
|
## Mutation
|
|
|
|
Sometimes, it's necessary to modify resources before they're persisted. For
|
|
example, to set sensible default values or normalize user input. You can do this
|
|
by providing a mutation hook:
|
|
|
|
```Go
|
|
func RegisterTypes(r resource.Registry) {
|
|
r.Register(resource.Registration{
|
|
Type: pbv1alpha1.BarType,
|
|
Proto: &pbv1alpha1.Bar{},
|
|
Scope: resource.ScopeNamespace,
|
|
Mutate: mutateBar,
|
|
})
|
|
}
|
|
|
|
func mutateBar(res *pbresource.Resource) error {
|
|
var bar pbv1alpha1.Bar
|
|
if err := res.Data.UnmarshalTo(&bar); err != nil {
|
|
return resource.NewErrDataParse(&bar, err)
|
|
}
|
|
bar.Baz = strings.ToLower(bar.Baz)
|
|
return res.Data.MarshalFrom(&bar)
|
|
}
|
|
```
|
|
|
|
## Controllers
|
|
|
|
Controllers are where the business logic of your resources will live. They're
|
|
asynchronous [reconciliation loops] that "wake up" whenever a resource is
|
|
modified to validate and realize the changes.
|
|
|
|
You can create a new controller using the [builder API]. Start by identifying
|
|
the resource type you want this controller to manage, and provide a reconciler
|
|
that will be called whenever a resource of that type is changed.
|
|
|
|
```Go
|
|
package foo
|
|
|
|
import (
|
|
"context"
|
|
|
|
"github.com/hashicorp/consul/internal/controller"
|
|
pbv1alpha1 "github.com/hashicorp/consul/proto-public/pbfoo/v1alpha1"
|
|
"github.com/hashicorp/consul/proto-public/pbresource"
|
|
)
|
|
|
|
func barController() controller.Controller {
|
|
return controller.NewController("bar", pbv1alpha1.BarType).
|
|
WithReconciler(barReconciler{})
|
|
}
|
|
|
|
type barReconciler struct{}
|
|
|
|
func (barReconciler) Reconcile(ctx context.Context, rt controller.Runtime, req controller.Request) error {
|
|
rsp, err := rt.Client.Read(ctx, &pbresource.ReadRequest{Id: req.ID})
|
|
switch {
|
|
case status.Code(err) == codes.NotFound:
|
|
return nil
|
|
case err != nil:
|
|
return err
|
|
}
|
|
|
|
var bar pbv1alpha1.Bar
|
|
if err := rsp.Resource.Data.UnmarshalTo(&bar); err != nil {
|
|
return err
|
|
}
|
|
rt.Logger.Debug("Hello from bar reconciler!", "baz", bar.Baz)
|
|
|
|
return nil
|
|
}
|
|
```
|
|
|
|
[reconciliation loops]: https://www.oreilly.com/library/view/97-things-every/9781492050896/ch73.html
|
|
[builder API]: https://pkg.go.dev/github.com/hashicorp/consul/internal/controller#Controller
|
|
|
|
Next, register your controller with the controller manager. Another common
|
|
pattern is to have your package expose a method for registering controllers,
|
|
which is called from `registerControllers` in [`server.go`].
|
|
|
|
[`server.go`]: ../../../agent/consul/server.go
|
|
|
|
```Go
|
|
package foo
|
|
|
|
func RegisterControllers(mgr *controller.Manager) {
|
|
mgr.Register(barController())
|
|
}
|
|
```
|
|
|
|
```Go
|
|
package consul
|
|
|
|
func (s *Server) registerControllers() {
|
|
// …
|
|
foo.RegisterControllers(s.controllerManager)
|
|
// …
|
|
}
|
|
```
|
|
|
|
### Retries
|
|
|
|
By default, if your reconciler returns an error, it will be retried with
|
|
exponential backoff. While this is correct in most circumstances, you can
|
|
override it by returning [`RequeueAfter`] or [`RequeueNow`].
|
|
|
|
[`RequeueAfter`]: https://pkg.go.dev/github.com/hashicorp/consul/internal/controller#RequeueAfter
|
|
[`RequeueNow`]: https://pkg.go.dev/github.com/hashicorp/consul/internal/controller#RequeueNow
|
|
|
|
```Go
|
|
func (barReconciler) Reconcile(context.Context, controller.Runtime, controller.Request) error {
|
|
if time.Now().Hour() < 9 {
|
|
return controller.RequeueAfter(1 * time.Hour)
|
|
}
|
|
return nil
|
|
}
|
|
```
|
|
|
|
### Status
|
|
|
|
Controllers can communicate the result of reconciling resource changes (e.g.
|
|
surfacing semantic validation issues) with users and other controllers by
|
|
updating the resource's status using the `WriteStatus` method.
|
|
|
|
Each resource can have multiple statuses, typically one per controller,
|
|
identified by a string key. Statuses are composed of a set of conditions, which
|
|
represent discreet observations about the resource in relation to the current
|
|
state of the system.
|
|
|
|
That all sounds a little abstract, so let's take a look at an example.
|
|
|
|
```Go
|
|
client.WriteStatus(ctx, &pbresource.WriteStatusRequest{
|
|
Id: res.Id,
|
|
Key: "consul.io/bar",
|
|
Status: &pbresource.Status{
|
|
ObservedGeneration: res.Generation,
|
|
Conditions: []*pbresource.Condition{
|
|
{
|
|
Type: "Healthy",
|
|
State: pbresource.Condition_STATE_TRUE,
|
|
Reason: "OK",
|
|
Message: "All checks are passing",
|
|
},
|
|
{
|
|
Type: "ResolvedRefs",
|
|
State: pbresource.Condition_STATE_FALSE,
|
|
Reason: "INVALID_REFERENCE",
|
|
Message: "Bar contained an invalid reference to qux",
|
|
Resource: resource.Reference(bar.Qux, ""),
|
|
},
|
|
},
|
|
},
|
|
})
|
|
```
|
|
|
|
In the previous example, the controller makes two observations about the
|
|
current state of the resource:
|
|
|
|
1. That it's "healthy" (whatever that means in this hypothetical scenario)
|
|
1. That it contains a reference that couldn't be resolved
|
|
|
|
The `Type` and `Reason` should be simple, machine-readable, strings, but there
|
|
aren't any strict rules about what are acceptable values. Over time, we
|
|
anticipate that common values will emerge that we'll standardize on for
|
|
consistency.
|
|
|
|
`Message` should be a human-readable explanation of the condition.
|
|
|
|
> **Warning**
|
|
> Writing a status to the resource will cause it to be re-reconciled. To avoid
|
|
> infinite loops, we recommend dirty checking the status before writing it with
|
|
> [`resource.EqualStatus`].
|
|
|
|
[`resource.EqualStatus`]: https://pkg.go.dev/github.com/hashicorp/consul/internal/resource#EqualStatus
|
|
|
|
### Watching Other Resources
|
|
|
|
In addition to watching their "managed" resources, controllers can also watch
|
|
resources of different, related, types. For example, the service endpoints
|
|
controller also watches workloads and services.
|
|
|
|
```Go
|
|
func barController() controller.Controller {
|
|
return controller.NewController("bar", pbv1alpha1.BarType).
|
|
WithWatch(pbv1alpha1.BazType, controller.MapOwner)
|
|
WithReconciler(barReconciler{})
|
|
}
|
|
```
|
|
|
|
The second argument to `WithWatch` is a [dependency mapper] function. Whenever a
|
|
resource of the watched type is modified, the dependency mapper will be called
|
|
to determine which of the controller's managed resources need to be reconciled.
|
|
|
|
[`dependency.MapOwner`] is a convenience function which causes the watched
|
|
resource's [owner](#ownership--cascading-deletion) to be reconciled.
|
|
|
|
[dependency mapper]: https://pkg.go.dev/github.com/hashicorp/consul/internal/controller#DependencyMapper
|
|
[`dependency.MapOwner`]: https://pkg.go.dev/github.com/hashicorp/consul/internal/controller/dependency#MapOwner
|
|
|
|
### Placement
|
|
|
|
By default, only a single, leader-elected, replica of each controller will run
|
|
within a cluster. Sometimes it's necessary to override this, for example when
|
|
you want to run a copy of the controller on each server (e.g. to apply some
|
|
configuration to the server whenever it changes). You can do this by changing
|
|
the controller's placement.
|
|
|
|
```Go
|
|
func barController() controller.Controller {
|
|
return controller.NewController("bar", pbv1alpha1.BarType).
|
|
WithPlacement(controller.PlacementEachServer)
|
|
WithReconciler(barReconciler{})
|
|
}
|
|
```
|
|
|
|
> **Warning**
|
|
> Controllers placed with [`controller.PlacementEachServer`] generally shouldn't
|
|
> modify resources (as it could lead to race conditions).
|
|
|
|
[`controller.PlacementEachServer`]: https://pkg.go.dev/github.com/hashicorp/consul/internal/controller#PlacementEachServer
|
|
|
|
### Initializer
|
|
|
|
If your controller needs to execute setup steps when the controller
|
|
first starts and before any resources are reconciled, you can add an
|
|
Initializer.
|
|
|
|
If the controller has an Initializer, it will not start unless the
|
|
Initialize method is successful. The controller does not have retry
|
|
logic for the initialize method specifically, but the controller
|
|
is restarted on error. When restarted, the controller will attempt
|
|
to execute the initialization again.
|
|
|
|
The example below has the controller creating a default resource as
|
|
part of initialization.
|
|
|
|
```Go
|
|
package foo
|
|
|
|
import (
|
|
"context"
|
|
|
|
"github.com/hashicorp/consul/internal/controller"
|
|
pbv1alpha1 "github.com/hashicorp/consul/proto-public/pbfoo/v1alpha1"
|
|
"github.com/hashicorp/consul/proto-public/pbresource"
|
|
)
|
|
|
|
func barController() controller.Controller {
|
|
return controller.ForType(pbv1alpha1.BarType).
|
|
WithReconciler(barReconciler{}).
|
|
WithInitializer(barInitializer{})
|
|
}
|
|
|
|
type barInitializer struct{}
|
|
|
|
func (barInitializer) Initialize(ctx context.Context, rt controller.Runtime) error {
|
|
_, err := rt.Client.Write(ctx,
|
|
&pbresource.WriteRequest{
|
|
Resource: &pbresource.Resource{
|
|
Id: &pbresource.ID{
|
|
Name: "default",
|
|
Type: pbv1alpha1.BarType,
|
|
},
|
|
},
|
|
},
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
```
|
|
|
|
### Finalizer
|
|
|
|
A finalizer allows a controller to execute teardown logic before a
|
|
resource is deleted. This can be useful to perform cleanup or block
|
|
deletion until certain conditions are met.
|
|
|
|
Finalizers are encoded as keys within a resource's metadata map. It
|
|
is the responsibility of each controller that adds a finalizer to a
|
|
resource to remove the finalizer when it is marked for deletion.
|
|
Once a resource has no finalizers present, it is deleted by the
|
|
resource service.
|
|
|
|
When the `Delete` endpoint is called on a resource with one or more
|
|
finalizers, the resource is marked for deletion by adding an immutable
|
|
`deletionTimestamp` key to the resource's metadata map. The resource is
|
|
now effectively frozen and will only accept subsequent `Write`s
|
|
that remove finalizers. `WriteStatus` is still allowed.
|
|
|
|
The `resource` package API can be used to manage finalizers and
|
|
check whether a resource has been marked for deletion. You would
|
|
typically use this API within the logic of your controller's
|
|
`Reconcile` method to either put a finalizer in place or perform
|
|
cleanup and then remove a finalizer. Don't forget to `Write` your
|
|
changes once you add or remove finalizers.
|
|
|
|
```Go
|
|
package resource
|
|
|
|
// IsMarkedForDeletion returns true if a resource has been marked for deletion,
|
|
// false otherwise.
|
|
func IsMarkedForDeletion(res *pbresource.Resource) bool { ... }
|
|
|
|
// HasFinalizers returns true if a resource has one or more finalizers, false otherwise.
|
|
func HasFinalizers(res *pbresource.Resource) bool { ... }
|
|
|
|
// HasFinalizer returns true if a resource has a given finalizer, false otherwise.
|
|
func HasFinalizer(res *pbresource.Resource, finalizer string) bool { ... }
|
|
|
|
// AddFinalizer adds a finalizer to the given resource.
|
|
func AddFinalizer(res *pbresource.Resource, finalizer string) { ... }
|
|
|
|
// RemoveFinalizer removes a finalizer from the given resource.
|
|
func RemoveFinalizer(res *pbresource.Resource, finalizer string) { ... }
|
|
|
|
// GetFinalizers returns the set of finalizers for the given resource.
|
|
func GetFinalizers(res *pbresource.Resource) mapset.Set[string] { ... }
|
|
```
|
|
|
|
Example flow in a controller's `Reconcile` method
|
|
```Go
|
|
const finalizer = "consul.io/bar-finalizer"
|
|
|
|
func (barReconciler) Reconcile(ctx context.Context, rt controller.Runtime, req controller.Request) error {
|
|
...
|
|
// Check if resource is marked for deletion. If yes, perform cleanup, remove finalizer, and Write the resource
|
|
if resource.IsMarkedForDeletion(res) {
|
|
// Perform some cleanup...
|
|
return EnsureFinalizerRemoved(ctx, rt, res, finalizer)
|
|
}
|
|
|
|
// Check if resource has finalizer. If not, add it and Write the resource
|
|
if err := EnsureHasFinalizer(ctx, rt, res, finalizer); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
```
|
|
|
|
## Ownership & Cascading Deletion
|
|
|
|
The resource service implements a lightweight `1:N` ownership model where, on
|
|
creation, you can mark a resource as being "owned" by another resource. When the
|
|
owner is deleted, the owned resource will be deleted too.
|
|
|
|
```Go
|
|
client.Write(ctx, &pbresource.WriteRequest{
|
|
Resource: &pbresource.Resource{,
|
|
Owner: ownerID,
|
|
// …
|
|
},
|
|
})
|
|
```
|
|
|
|
## Testing
|
|
|
|
Now that you have created your controller its time to test it. The types of tests each controller should have and boiler plat for test files is documented [here](./testing.md)
|