docs/docs/master/services-developer-guide.md

1118 lines
52 KiB
Markdown

---
title: Services Developer Guide
toc: true
weight: 720
indent: true
---
# Services Developer Guide
Crossplane Services supports managed service provisioning using `kubectl`. It
applies the Kubernetes pattern for Persistent Volume (PV) claims and classes to
managed service provisioning with support for a strong separation of concern
between app teams and cluster administrators. This guide will walk through the
process of adding support for a new managed service.
## What Makes a Crossplane Managed Service?
Crossplane builds atop Kubernetes's powerful architecture in which declarative
configuration, known as resources, are continually 'reconciled' with reality by
one or more controllers. A controller is an endless loop that:
1. Observes the desired state (the declarative configuration resource).
1. Observes the actual state (the thing said configuration resource represents).
1. Tries to make the actual state match the desired state.
A typical Crossplane managed service consists of five configuration resources
and five controllers. The GCP Stack's support for Google Cloud Memorystore
illustrates this. First, the configuration resources:
1. A [managed resource]. Managed resources are cluster scoped, high-fidelity
representations of a resource in an external system such as a cloud
provider's API. Managed resources are _non-portable_ across external systems
(i.e. cloud providers); they're tightly coupled to the implementation details
of the external resource they represent. Managed resources are defined by a
Stack. The GCP Stack's [`CloudMemorystoreInstance`] resource is an example of
a managed resource.
1. A [resource claim]. Resource claims are namespaced abstract declarations of a
need for a service. Resource claims are frequently portable across external
systems. Crossplane defines a series of common resource claim kinds,
including [`RedisCluster`]. A resource claim is satisfied by _binding_ to a
managed resource.
1. A [resource class]. Resource classes represent a class of a specific kind of
managed resource. They are the template used to create a new managed resource
in order to satisfy a resource claim during [dynamic provisioning]. Resource
classes are cluster scoped, and tightly coupled to the managed resources they
template. [`CloudMemorystoreInstanceClass`] is an example of a resource
class.
1. A provider. Providers enable access to an external system, typically by
indicating a Kubernetes Secret containing any credentials required to
authenticate to the system, as well as any other metadata required to
connect. Providers are cluster scoped, like managed resources and classes.
The GCP [`Provider`] is an example of a provider.
These resources are powered by:
1. The managed resource controller. This controller is responsible for taking
instances of the aforementioned high-fidelity managed resource kind and
reconciling them with an external system. Managed resource controllers are
unaware of resource claims or classes. The `CloudMemorystoreInstance`
controller watches for changes to `CloudMemorystoreInstance` resources and
calls Google's Cloud Memorystore API to create, update, or delete an instance
as necessary.
1. The resource claim scheduling controller. A claim scheduling controller
exists for each kind of resource class that could satisfy a resource claim.
This controller is unaware of any external system - it simply schedules
resource claims to resource classes that match their class selector labels,
so that they may be handled by the resource claim controller.
1. The resource claim defaulting controller. A claim defaulting controller
exists for each kind of resource class that could satisfy a resource claim.
This controller is unaware of any external system - it allocates resource
claims that do not specify a class selector to a resource class annotated as
the default, if any, so that they may be handled by the claim controller.
1. The resource claim controller. A resource claim controller exists for each
kind of managed resource that could satisfy a resource claim. This controller
is unaware of any external system - it responsible only for taking resource
claims and binding them to a managed resource. The
`CloudMemorystoreInstance` resource claim controller watches for
`RedisCluster` resource claims that should be satisfied by a
`CloudMemorystoreInstance`. It either binds to an explicitly referenced
`CloudMemorystoreInstance` (static provisioning) or creates a new one and
then binds to it (dynamic provisioning).
1. The secret propagation controller. Like the resource claim controller, a
secret propagation controller exists for each kind of managed resource that
could satisfy a resource claim. Its job is simply to ensure that changes to
the connection secret of a managed resource are always propagated to the
connection secret of the resource claim it is bound to. The secret
propagation controller is optional - managed resources that only write to
their connection secret at creation time may omit this controller.
Crossplane does not require controllers to be written in any particular
language. The Kubernetes API server is our API boundary, so any process capable
of [watching the API server] and updating resources can be a Crossplane
controller.
## Getting Started
At the time of writing all Crossplane Services controllers are written in Go,
and built using [kubebuilder] v0.2.x and [crossplane-runtime]. Per [What Makes
a Crossplane Managed Service] it is possible to write a controller using any
language and tooling with a Kubernetes client, but this set of tools are the
"[golden path]". They're well supported, broadly used, and provide a shared
language with the Crossplane maintainers. This guide targets [crossplane-runtime
v0.2.1].
This guide assumes the reader is familiar with the Kubernetes [API Conventions]
and the [kubebuilder book]. If you're not adding a new managed service to an
existing Crossplane Stack you should start by working through the [Stacks quick
start] to scaffold a new Stack in which the new types and controllers will live.
## Defining Resource Kinds
Let's assume we want to add Crossplane support for your favourite cloud's
database-as-a-service. Your favourite cloud brands these instances as "Favourite
DB instances". Under the hood they're powered by the open source FancySQL
engine. We'll name the new managed resource kind `FavouriteDBInstance` and the
new resource claim `FancySQLInstance`.
The first step toward implementing a new managed service is to define the code
level schema of its configuration resources. These are referred to as
[resources], (resource) [kinds], and [objects] interchangeably. The kubebuilder
scaffolding is a good starting point for any new Crossplane API kind, whether
they'll be a managed resource, resource class, or resource claim.
```console
# The resource claim.
kubebuilder create api \
--group example --version v1alpha1 --kind FancySQLInstance \
--resource=true --controller=false
```
The above command should produce a scaffold similar to the below
example:
```go
type FancySQLInstanceSpec struct {
// INSERT ADDITIONAL SPEC FIELDS - desired state of cluster
// Important: Run "make" to regenerate code after modifying this file
}
// FancySQLInstanceStatus defines the observed state of FancySQLInstance
type FancySQLInstanceStatus struct {
// INSERT ADDITIONAL STATUS FIELD - define observed state of cluster
// Important: Run "make" to regenerate code after modifying this file
}
// +kubebuilder:object:root=true
// FancySQLInstance is the Schema for the fancysqlinstances API
type FancySQLInstance struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec FancySQLInstanceSpec `json:"spec,omitempty"`
Status FancySQLInstanceStatus `json:"status,omitempty"`
}
```
Crossplane requires that these newly generated API type scaffolds be extended
with a set of struct fields, getters, and setters that are standard to all
Crossplane resource kinds. The fields and setters differ depending on whether
the new resource kind is a managed resource, resource claim, or resource class.
The getters and setter methods required to satisfy the various
crossplane-runtime interfaces are omitted from the below examples for brevity.
They can be added by hand, but new services are encouraged to use [`angryjet`]
to generate them automatically using a `//go:generate` comment per the [`angryjet`
documentation].
Note that in many cases a suitable provider and resource claim will already
exist. Frequently adding support for a new managed service requires only the
definition of a new managed resource and resource class.
### Managed Resource Kinds
Managed resources must:
* Satisfy crossplane-runtime's [`resource.Managed`] interface.
* Embed a [`ResourceStatus`] struct in their `Status` struct.
* Embed a [`ResourceSpec`] struct in their `Spec` struct.
* Embed a `Parameters` struct in their `Spec` struct.
* Use the `+kubebuilder:subresource:status` [comment marker].
* Use the `+kubebuilder:resource:scope=Cluster` [comment marker].
The `Parameters` struct should be a _high fidelity_ representation of the
writeable fields of the external resource's API. Put otherwise, if your
favourite cloud represents Favourite DB instances as a JSON object then
`FavouriteDBParameters` should marshal to a something as close to that JSON
object as possible while still complying with Kubernetes API conventions.
For example, assume the external API object for Favourite DB instance was:
```json
{
"id": 42,
"name": "mycoolinstance",
"fanciness_level": 100,
"version": "2.3",
"status": "ONLINE",
"hostname": "cool.fcp.example.org"
}
```
Further assume the `id`, `status`, and `hostname` fields were output only, and
the `version` field was optional. The `FavouriteDBInstance` managed resource
should look as follows:
```go
// FavouriteDBInstanceParameters define the desired state of an FavouriteDB
// instance. Most fields map directly to an Instance:
// https://favourite.example.org/api/v1/db#Instance
type FavouriteDBInstanceParameters struct {
// We're still working on a standard for naming external resources. See
// https://github.com/crossplaneio/crossplane/issues/624 for context.
// Name of this instance.
Name string `json:"name"`
// Note that fanciness_level becomes fancinessLevel below. Kubernetes API
// conventions trump cloud provider fidelity.
// FancinessLevel specifies exactly how fancy this instance is.
FancinessLevel int `json:"fancinessLevel"`
// Version specifies what version of FancySQL this instance will run.
// +optional
Version *string `json:"version,omitempty"`
}
// A FavouriteDBInstanceSpec defines the desired state of a FavouriteDBInstance.
type FavouriteDBInstanceSpec struct {
runtimev1alpha1.ResourceSpec `json:",inline"`
FavouriteDBInstanceParameters `json:",forProvider"`
}
// A FavouriteDBInstanceStatus represents the observed state of a
// FavouriteDBInstance.
type FavouriteDBInstanceStatus struct {
runtimev1alpha1.ResourceStatus `json:",inline"`
// Note that we add the three "output only" fields here in the status,
// instead of the parameters. We want this representation to be high
// fidelity just like the parameters.
// ID of this instance.
ID int `json:"id,omitempty"`
// Status of this instance.
Status string `json:"status,omitempty"`
// Hostname of this instance.
Hostname string `json:"hostname,omitempty"`
}
// A FavouriteDBInstance is a managed resource that represents a Favourite DB
// instance.
// +kubebuilder:subresource:status
type FavouriteDBInstance struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec FavouriteDBInstanceSpec `json:"spec,omitempty"`
Status FavouriteDBInstanceStatus `json:"status,omitempty"`
}
```
Note that Crossplane uses the GoDoc strings of API kinds to generate user facing
API documentation. __Document all fields__ and prefer GoDoc that assumes the
reader is running `kubectl explain`, or reading an API reference, not reading
the code. Refer to the [Managed Resource API Patterns] one pager for more detail
on authoring high fidelity managed resources.
### Resource Class Kinds
The resource class kind for a particular managed resource kind are typically
defined in the same file as their the managed resource. Resource classes must:
* Satisfy crossplane-runtime's [`resource.Class`] interface.
* Have a `SpecTemplate` struct field instead of a `Spec`.
* Embed a [`ClassSpecTemplate`] struct in their `SpecTemplate` struct.
* Embed their managed resource's `Parameters` struct in their `SpecTemplate`
struct.
* Not have a `Status` struct.
* Use the `+kubebuilder:resource:scope=Cluster` [comment marker].
A resource class for the above `FavouriteDBInstance` would look as
follows:
```go
// A FavouriteDBInstanceClassSpecTemplate is a template for the spec of a
// dynamically provisioned FavouriteDBInstance.
type FavouriteDBInstanceClassSpecTemplate struct {
runtimev1alpha1.ClassSpecTemplate `json:",inline"`
FavouriteDBInstanceParameters `json:",forProvider"`
}
// A FavouriteDBInstanceClass is a resource class. It defines the desired spec
// of resource claims that use it to dynamically provision a managed resource.
type FavouriteDBInstanceClass struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
// SpecTemplate is a template for the spec of a dynamically provisioned
// FavouriteDBInstance.
SpecTemplate FavouriteDBInstanceSpecTemplate `json:"specTemplate,omitempty"`
}
```
### Resource Claim Kinds
Once the underlying managed resource and its resource class have been defined
the next step is to define the resource claim. Resource claim controllers
typically live alongside their managed resource controllers (i.e. in an
infrastructure stack), but at the time of writing all resource claim kinds are
defined in Crossplane core. This is because resource claims can frequently be
satisfied by binding to managed resources from more than one cloud. Consider
[opening a Crossplane issue] to propose adding your new resource claim kind to
Crossplane if it could be satisfied by managed resources from more than one
infrastructure stack.
Resource claims must:
* Satisfy crossplane-runtime's [`resource.Claim`] interface.
* Use (not embed) a [`ResourceClaimStatus`] struct as their `Status` field.
* Embed a [`ResourceClaimSpec`] struct in their `Spec` struct.
* Use the `+kubebuilder:subresource:status` [comment marker].
* **Not** use the `+kubebuilder:resource:scope=Cluster` [comment marker].
The `FancySQLInstance` resource claim would look as follows:
```go
// A FancySQLInstanceSpec defines the desired state of a FancySQLInstance.
type FancySQLInstanceSpec struct {
runtimev1alpha1.ResourceClaimSpec `json:",inline"`
// Resource claims typically expose few to no spec fields, instead
// leveraging resource classes to specify detailed configuration. A resource
// claim should only support a very small set of fields that:
//
// * Are applicable to every conceivable managed resource kind that might
// ever satisfy the claim kind.
// * Are more likely than average to be interesting to resource claim
// authors, who frequently want to be concerned with as few configuration
// details as possible.
// Version specifies what version of FancySQL this instance will run.
// +optional
Version *string `json:"version,omitempty"`
}
// A FancySQLInstance is a portable resource claim that may be satisfied by
// binding to FancySQL managed resources such as a Favourite Cloud FavouriteDB
// instance or an Other Cloud AmbivalentDB instance.
// +kubebuilder:subresource:status
type FancySQLInstance struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec FancySQLInstanceSpec `json:"spec,omitempty"`
Status runtimev1alpha1.ResourceClaimStatus `json:"status,omitempty"`
}
// SetBindingPhase of this FancySQLInstance.
func (i *FancySQLInstance) SetBindingPhase(p runtimev1alpha1.BindingPhase) {
i.Status.SetBindingPhase(p)
}
```
### Provider Kinds
You'll typically only need to add a new Provider kind if you're creating an
infrastructure stack that adds support for a new infrastructure provider.
Providers must:
* Be named exactly `Provider`.
* Have a `Spec` struct with a `Secret` field indicating where to find
credentials for this provider.
* Use the `+kubebuilder:resource:scope=Cluster` [comment marker].
The Favourite Cloud `Provider` would look as follows. Note that the cloud to
which it belongs should be indicated by its API group, i.e. its API Version
would be `favouritecloud.crossplane.io/v1alpha1` or similar.
```go
// A ProviderSpec defines the desired state of a Provider.
type ProviderSpec struct {
// A Secret containing credentials for a Favourite Cloud Service Account
// that will be used to authenticate to this Provider.
Secret runtimev1alpha1.SecretKeySelector `json:"credentialsSecretRef"`
}
// A Provider configures a Favourite Cloud 'provider', i.e. a connection to a
// particular Favourite Cloud project using a particular Favourite Cloud service
// account.
type Provider struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec ProviderSpec `json:"spec,omitempty"`
}
```
### Finishing Touches
At this point we've defined all of the resource kinds necessary to start
building controllers - a managed resource, a resource class, and a resource
claim. Before moving on to the controllers:
* Add any kubebuilder [comment markers] that may be useful for your resource.
Comment markers can be used to validate input, or add additional columns to
the standard `kubectl get` output, among other things.
* Run `make generate && make manifests` (or `make reviewable` if you're working
in one of the projects in the [crossplaneio org]) to generate Custom Resource
Definitions and additional helper methods for your new resource kinds.
* Make sure a `//go:generate` comment exists for [angryjet] and you ran `go generate -v ./...`
* Make sure any package documentation (i.e. `// Package v1alpha1...` GoDoc,
including package level comment markers) are in a file named `doc.go`.
kubebuilder adds them to `groupversion_info.go`, but several code generation
tools only check `doc.go`.
Finally, add convenience [`GroupVersionKind`] variables for each new resource
kind. These are typically added to either `register.go` or
`groupversion_info.go` depending on which version of kubebuilder scaffolded the
API type:
```go
// FancySQLInstance type metadata.
var (
FancySQLInstanceKind = reflect.TypeOf(FancySQLInstance{}).Name()
FancySQLInstanceKindAPIVersion = FancySQLInstanceKind + "." + GroupVersion.String()
FancySQLInstanceGroupVersionKind = GroupVersion.WithKind(FancySQLInstanceKind)
)
```
Consider opening a draft pull request and asking a Crossplane maintainer for
review before you start work on the controller!
## Adding Controllers
Crossplane controllers, like those scaffolded by kubebuilder, are built around
the [controller-runtime] library. controller-runtime flavoured controllers
encapsulate most of their domain-specific logic in a [`reconcile.Reconciler`]
implementation. Most Crossplane controllers are one of the three kinds mentioned
under [What Makes a Crossplane Managed Service]. Each of these controller kinds
are similar enough across implementations that [crossplane-runtime] provides
'default' reconcilers. These reconcilers encode what the Crossplane community
has learned about managing external systems and narrow the problem space from
reconciling a Kubernetes resource kind with an arbitrary system down to
Crossplane-specific tasks.
crossplane-runtime provides the following `reconcile.Reconcilers`:
* The [`resource.ManagedReconciler`] reconciles managed resources with external
systems by instantiating a client of the external API and using it to create,
update, or delete the external resource as necessary.
* [`resource.ClaimSchedulingReconciler`] reconciles resource claims by
scheduling them to a resource class that matches their class selector labels
(if any).
* [`resource.ClaimDefaultingReconciler`] reconciles resource claims that omit
their class selector by defaulting them to a resource class annotated as the
default (if any).
* [`resource.ClaimReconciler`] reconciles resource claims with managed resources
by either binding or dynamically provisioning and then binding them.
* [`resource.SecretPropagatingReconciler`] reconciles secrets by propagating
their data to another secret. This controller is typically used to ensure
resource claim connection secrets remain in sync with the connection secrets
of their bound managed resources.
Crossplane controllers typically differ sufficiently from those scaffolded by
kubebuilder that there is little value in using kubebuilder to generate a
controller scaffold.
### Managed Resource Controllers
Managed resource controllers should use [`resource.NewManagedReconciler`] to
wrap a managed-resource specific implementation of
[`resource.ExternalConnecter`]. Parts of `resource.ManagedReconciler`'s
behaviour is customisable; refer to the [`resource.NewManagedReconciler`] GoDoc
for a list of options. The following is an example controller for the
`FavouriteDBInstance` managed resource we defined earlier:
```go
import (
"context"
"fmt"
"strings"
"github.com/pkg/errors"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
// An API client of the hypothetical FavouriteDB service.
"github.com/fcp-sdk/v1/services/database"
runtimev1alpha1 "github.com/crossplaneio/crossplane-runtime/apis/core/v1alpha1"
"github.com/crossplaneio/crossplane-runtime/pkg/meta"
"github.com/crossplaneio/crossplane-runtime/pkg/resource"
"github.com/crossplaneio/stack-fcp/apis/database/v1alpha3"
fcpv1alpha3 "github.com/crossplaneio/stack-fcp/apis/v1alpha3"
)
type FavouriteDBInstanceController struct{}
// SetupWithManager instantiates a new controller using a resource.ManagedReconciler
// configured to reconcile FavouriteDBInstances using an ExternalClient produced by
// connecter, which satisfies the ExternalConnecter interface.
func (c *FavouriteDBInstanceController) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
Named(strings.ToLower(fmt.Sprintf("%s.%s", v1alpha3.FavouriteDBInstanceKind, v1alpha3.Group))).
For(&v1alpha3.FavouriteDBInstance{}).
Complete(resource.NewManagedReconciler(mgr,
resource.ManagedKind(v1alpha3.FavouriteDBInstanceGroupVersionKind),
resource.WithExternalConnecter(&connecter{client: mgr.GetClient()})))
}
// Connecter satisfies the resource.ExternalConnecter interface.
type connecter struct{ client client.Client }
// Connect to the supplied resource.Managed (presumed to be a
// FavouriteDBInstance) by using the Provider it references to create a new
// database client.
func (c *connecter) Connect(ctx context.Context, mg resource.Managed) (resource.ExternalClient, error) {
// Assert that resource.Managed we were passed in fact contains a
// FavouriteDBInstance. We told NewControllerManagedBy that this was a
// controller For FavouriteDBInstance, so something would have to go
// horribly wrong for us to encounter another type.
i, ok := mg.(*v1alpha3.FavouriteDBInstance)
if !ok {
return nil, errors.New("managed resource is not a FavouriteDBInstance")
}
// Get the Provider referenced by the FavouriteDBInstance.
p := &fcpv1alpha3.Provider{}
if err := c.client.Get(ctx, meta.NamespacedNameOf(i.Spec.ProviderReference), p); err != nil {
return nil, errors.Wrap(err, "cannot get Provider")
}
// Get the Secret referenced by the Provider.
s := &corev1.Secret{}
n := types.NamespacedName{Namespace: p.Namespace, Name: p.Spec.Secret.Name}
if err := c.client.Get(ctx, n, s); err != nil {
return nil, errors.Wrap(err, "cannot get Provider secret")
}
// Create and return a new database client using the credentials read from
// our Provider's Secret.
client, err := database.NewClient(ctx, s.Data[p.Spec.Secret.Key])
return &external{client: client}, errors.Wrap(err, "cannot create client")
}
// External satisfies the resource.ExternalClient interface.
type external struct{ client database.Client }
// Observe the existing external resource, if any. The resource.ManagedReconciler
// calls Observe in order to determine whether an external resource needs to be
// created, updated, or deleted.
func (e *external) Observe(ctx context.Context, mg resource.Managed) (resource.ExternalObservation, error) {
i, ok := mg.(*v1alpha3.FavouriteDBInstance)
if !ok {
return resource.ExternalObservation{}, errors.New("managed resource is not a FavouriteDBInstance")
}
// Use our FavouriteDB API client to get an up to date view of the external
// resource.
existing, err := e.client.GetInstance(ctx, i.Spec.Name)
// If we encounter an error indicating the external resource does not exist
// we want to let the resource.ManagedReconciler know so it can create it.
if database.IsNotFound(err) {
return resource.ExternalObservation{ResourceExists: false}, nil
}
// Any other errors are wrapped (as is good Go practice) and returned to the
// resource.ManagedReconciler. It will update the "Synced" status condition
// of the managed resource to reflect that the most recent reconcile failed
// and ensure the reconcile is reattempted after a brief wait.
if err != nil {
return resource.ExternalObservation{}, errors.Wrap(err, "cannot get instance")
}
// The external resource exists. Copy any output-only fields to their
// corresponding entries in our status field.
i.Status.Status = existing.GetStatus()
i.Status.Hostname = existing.GetHostname()
i.Status.ID = existing.GetID()
// Update our "Ready" status condition to reflect the status of the external
// resource. Most managed resources use the below well known reasons that
// the "Ready" status may be true or false, but managed resource authors
// are welcome to define and use their own.
switch i.Status.Status {
case database.StatusOnline:
// If the resource is available we also want to mark it as bindable to
// resource claims.
resource.SetBindable(i)
i.SetConditions(runtimev1alpha1.Available())
case database.StatusCreating:
i.SetConditions(runtimev1alpha1.Creating())
case database.StatusDeleting:
i.SetConditions(runtimev1alpha1.Deleting())
}
// Finally, we report what we know about the external resource. In this
// hypothetical case FancinessLevel is the only field that can be updated
// after creation time, so the resource does not need to be updated if
// the actual fanciness level matches our desired fanciness level. Any
// ConnectionDetails we return will be published to the managed resource's
// connection secret if it specified one.
o := resource.ExternalObservation{
ResourceExists: true,
ResourceUpToDate: existing.GetFancinessLevel == i.Spec.FancinessLevel,
ConnectionDetails: resource.ConnectionDetails{
runtimev1alpha1.ResourceCredentialsSecretUserKey: []byte(existing.GetUsername()),
runtimev1alpha1.ResourceCredentialsSecretEndpointKey: []byte(existing.GetHostname()),
},
}
return o, nil
}
// Create a new external resource based on the specification of our managed
// resource. resource.ManagedReconciler only calls Create if Observe reported
// that the external resource did not exist.
func (e *external) Create(ctx context.Context, mg resource.Managed) (resource.ExternalCreation, error) {
i, ok := mg.(*v1alpha3.FavouriteDBInstance)
if !ok {
return resource.ExternalCreation{}, errors.New("managed resource is not a FavouriteDBInstance")
}
// Indicate that we're about to create the instance. Remember ExternalClient
// authors can use a bespoke condition reason here in cases where Creating
// doesn't make sense.
i.SetConditions(runtimev1alpha1.Creating())
// Create must return any connection details that are set or returned only
// at creation time. The resource.ManagedReconciler will merge any details
// with those returned during the Observe phase.
password := database.GeneratePassword()
cd := resource.ConnectionDetails{runtimev1alpha1.ResourceCredentialsSecretPasswordKey: []byte(password)}
// Create a new instance.
new := database.Instance{Name: i.Name, FancinessLevel: i.FancinessLevel, Version: i.Version}
err := e.client.CreateInstance(ctx, new, password)
// Note that we use resource.Ignore to squash any error that indicates the
// external resource already exists. Create implementations must not return
// an error if asked to create a resource that already exists. Real managed
// resource controllers are advised to avoid unintentially 'adoptign' an
// existing, unrelated external resource, per
// https://github.com/crossplaneio/crossplane-runtime/issues/27
return resource.ExternalCreation{ConnectionDetails: cd}, errors.Wrap(resource.Ignore(database.IsExists, err), "cannot create instance")
}
// Update the existing external resource to match the specifications of our
// managed resource. resource.ManagedReconciler only calls Update if Observe
// reported that the external resource was not up to date.
func (e *external) Update(ctx context.Context, mg resource.Managed) (resource.ExternalUpdate, error) {
i, ok := mg.(*v1alpha3.FavouriteDBInstance)
if !ok {
return resource.ExternalUpdate{}, errors.New("managed resource is not a FavouriteDBInstance")
}
// Recall that FancinessLevel is the only field that we _can_ update.
new := database.Instance{Name: i.Name, FancinessLevel: i.FancinessLevel}
err := e.client.UpdateInstance(ctx, new)
return resource.ExternalUpdate{}, errors.Wrap(err, "cannot update instance")
}
// Delete the external resource. resource.ManagedReconciler only calls Delete
// when a managed resource with the 'Delete' reclaim policy has been deleted.
func (e *external) Delete(ctx context.Context, mg resource.Managed) error {
i, ok := mg.(*v1alpha3.FavouriteDBInstance)
if !ok {
return errors.New("managed resource is not a FavouriteDBInstance")
}
// Indicate that we're about to delete the instance.
i.SetConditions(runtimev1alpha1.Deleting())
// Delete the instance.
err := e.client.DeleteInstance(ctx, i.Spec.Name)
// Note that we use resource.Ignore to squash any error that indicates the
// external resource does not exist. Delete implementations must not return
// an error when asked to delete a non-existent external resource.
return errors.Wrap(resource.Ignore(database.IsNotFound, err), "cannot delete instance")
}
```
### Resource Claim Scheduling Controllers
Scheduling controllers should use [`resource.NewClaimSchedulingReconciler`] to
specify the resource claim kind it schedules and the resource class kind it
schedules them to. Note that unlike their resource claim kinds, resource claim
scheduling controllers are always part of the infrastructure stack that defines
the resource class they schedule claims to. The following is an example
controller that reconciles the `FancySQLInstance` resource claim by scheduling
it to a `FavouriteDBInstanceClass`:
```go
import (
"fmt"
"strings"
ctrl "sigs.k8s.io/controller-runtime"
runtimev1alpha1 "github.com/crossplaneio/crossplane-runtime/apis/core/v1alpha1"
"github.com/crossplaneio/crossplane-runtime/pkg/resource"
// Note that the hypothetical FancySQL resource claim is part of Crossplane,
// not stack-fcp, because it is (hypothetically) portable across multiple
// infrastructure stacks.
databasev1alpha1 "github.com/crossplaneio/crossplane/apis/database/v1alpha1"
"github.com/crossplaneio/stack-fcp/apis/database/v1alpha3"
)
type PostgreSQLInstanceClaimSchedulingController struct{}
// SetupWithManager instantiates a new controller using a
// resource.ClaimSchedulingReconciler configured to reconcile FancySQLInstances
// by scheduling them to FavouriteDBInstanceClasses.
func (c *FancySQLInstanceClaimSchedulingController) SetupWithManager(mgr ctrl.Manager) error {
// It's Crossplane convention to name resource claim scheduling controllers
// "scheduler.claimkind.resourcekind.resourceapigroup", for example in this
// case "fancysqlinstance.favouritedbinstance.fcp.crossplane.io".
name := strings.ToLower(fmt.Sprintf("scheduler.%s.%s.%s",
databasev1alpha1.FancySQLInstanceKind,
v1alpha3.FavouriteDBInstanceKind,
v1alpha3.Group))
return ctrl.NewControllerManagedBy(mgr).
Named(name).
For(&databasev1alpha1.FancySQLInstance{}).
WithEventFilter(resource.NewPredicates(resource.AllOf(
// Claims must supply a class selector to be scheduled. Claims that
// do not supply a class selector use a default resource class, if
// one exists.
resource.HasClassSelector(),
// Claims with a class reference have either already been scheduled
// to a resource class, or specified one explicitly.
resource.HasNoClassReference(),
// Claims with a managed resource reference are either already bound
// to a managed resource, or are requesting to be bound to an
// existing managed resource.
resource.HasNoManagedResourceReference(),
))).
Complete(resource.NewClaimSchedulingReconciler(mgr,
resource.ClaimKind(databasev1alpha1.FancySQLInstanceGroupVersionKind),
resource.ClassKind(v1alpha3.FavouriteDBInstanceClassGroupVersionKind),
))
}
```
### Resource Claim Defaulting Controllers
Defaulting controllers are configured almost (but not quite) identically to
scheduling controllers. They use a [`resource.NewClaimSchedulingReconciler`] to
specify the resource claim kind they configure and the resource class kind they
default to. Unlike their resource claim kinds, defaulting controllers are always
part of the infrastructure stack that defines the resource class they default
claims to. The following is an example controller that reconciles the
`FancySQLInstance` resource claim by setting its class reference to a
`FavouriteDBInstanceClass` annotated as the default class:
```go
import (
"fmt"
"strings"
ctrl "sigs.k8s.io/controller-runtime"
runtimev1alpha1 "github.com/crossplaneio/crossplane-runtime/apis/core/v1alpha1"
"github.com/crossplaneio/crossplane-runtime/pkg/resource"
// Note that the hypothetical FancySQL resource claim is part of Crossplane,
// not stack-fcp, because it is (hypothetically) portable across multiple
// infrastructure stacks.
databasev1alpha1 "github.com/crossplaneio/crossplane/apis/database/v1alpha1"
"github.com/crossplaneio/stack-fcp/apis/database/v1alpha3"
)
type PostgreSQLInstanceClaimDefaultingController struct{}
// SetupWithManager instantiates a new controller using a
// resource.ClaimDefaultingReconciler configured to reconcile FancySQLInstances
// by scheduling them to FavouriteDBInstanceClasses.
func (c *FancySQLInstanceClaimDefaultingController) SetupWithManager(mgr ctrl.Manager) error {
// It's Crossplane convention to name resource claim scheduling controllers
// "defaulter.claimkind.resourcekind.resourceapigroup", for example in this
// case "fancysqlinstance.favouritedbinstance.fcp.crossplane.io".
name := strings.ToLower(fmt.Sprintf("scheduler.%s.%s.%s",
databasev1alpha1.FancySQLInstanceKind,
v1alpha3.FavouriteDBInstanceKind,
v1alpha3.Group))
return ctrl.NewControllerManagedBy(mgr).
Named(name).
For(&databasev1alpha1.FancySQLInstance{}).
WithEventFilter(resource.NewPredicates(resource.AllOf(
// Claims with a class selector desire scheduling to a matching
// resource class, and are not subject to defaulting.
resource.HasNoClassSelector(),
// Claims with a class reference have either already been scheduled
// to a resource class, or specified one explicitly.
resource.HasNoClassReference(),
// Claims with a managed resource reference are either already bound
// to a managed resource, or are requesting to be bound to an
// existing managed resource.
resource.HasNoManagedResourceReference(),
))).
Complete(resource.NewClaimDefaultingReconciler(mgr,
resource.ClaimKind(databasev1alpha1.FancySQLInstanceGroupVersionKind),
resource.ClassKind(v1alpha3.FavouriteDBInstanceClassGroupVersionKind),
))
}
```
### Resource Claim Controllers
Resource claim controllers should use [`resource.NewClaimReconciler`] to wrap a
managed-resource specific implementation of [`resource.ManagedConfigurator`].
Parts of `resource.ClaimReconciler`'s behaviour is customisable; refer to the
[`resource.NewClaimReconciler`] GoDoc for a list of options. Note that unlike
their resource claim kinds, resource claim controllers are always part of the
infrastructure stack that defines the managed resource they reconcile claims
with. The following is an example controller that reconciles the
`FancySQLInstance` resource claim with the `FavouriteDBInstance` managed
resource:
```go
import (
"context"
"fmt"
"strings"
"github.com/pkg/errors"
corev1 "k8s.io/api/core/v1"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/source"
runtimev1alpha1 "github.com/crossplaneio/crossplane-runtime/apis/core/v1alpha1"
"github.com/crossplaneio/crossplane-runtime/pkg/resource"
// Note that the hypothetical FancySQL resource claim is part of Crossplane,
// not stack-fcp, because it is (hypothetically) portable across multiple
// infrastructure stacks.
databasev1alpha1 "github.com/crossplaneio/crossplane/apis/database/v1alpha1"
"github.com/crossplaneio/stack-fcp/apis/database/v1alpha3"
)
type FavouriteDBInstanceClaimController struct{}
// SetupWithManager instantiates a new controller using a resource.ClaimReconciler
// configured to reconcile FancySQLInstances by binding them to FavouriteDBInstances.
func (c *FavouriteDBInstanceClaimController) SetupWithManager(mgr ctrl.Manager) error {
// It's Crossplane convention to name resource claim controllers
// "claimkind.resourcekind.resourceapigroup", for example in this case
// "fancysqlinstance.favouritedbinstance.fcp.crossplane.io".
name := strings.ToLower(fmt.Sprintf("%s.%s.%s",
databasev1alpha1.FancySQLInstanceKind,
v1alpha3.FavouriteDBInstanceKind,
v1alpha3.Group))
// The controller below watches for changes to both FancySQLInstance and
// FavouriteDBInstance kind resources. We use watch predicates to filter
// out any requests to reconcile resources that we're not interested in.
p := resource.NewPredicates(resource.AnyOf(
// We want to reconcile FancySQLInstance kind resource claims that
// reference a FavouriteDBInstanceClass.
resource.HasClassReferenceKind(resource.ClassKind(v1alpha3.FavouriteDBInstanceClassGroupVersionKind),
// We want to reconcile FancySQLInstance kind resource claims that
// explicitly set their .spec.resourceRef to a FavouriteDBInstance kind
// managed resource.
resource.HasManagedResourceReferenceKind(resource.ManagedKind(v1alpha3.FavouriteDBInstanceGroupVersionKind)),
// We want to reconcile FavouriteDBInstance managed resources. Resources
// without a claim reference will be filtered by the below
// EnqueueRequestForClaim watch event handler.
resource.IsManagedKind(resource.ManagedKind(v1alpha3.FavouriteDBInstanceClassGroupVersionKind), mgr.GetScheme()),
))
// Create a new resource claim reconciler...
r := resource.NewClaimReconciler(mgr,
// ..that uses the supplied claim, class, and managed resource kinds.
resource.ClaimKind(databasev1alpha1.FancySQLInstanceGroupVersionKind),
resource.ClassKind(v1alpha3.FavouriteDBInstanceClassGroupVersionKind),
resource.ManagedKind(v1alpha3.FavouriteDBInstanceGroupVersionKind),
// The following configurators configure how a managed resource will be
// configured when one must be dynamically provisioned.
resource.WithManagedConfigurators(
resource.ManagedConfiguratorFn(ConfigureFavouriteDBInstance),
resource.NewObjectMetaConfigurator(mgr.GetScheme()),
))
// Note that we watch for both FancySQLInstance and FavouriteDBInstance
// resources. When the latter passes our predicates we look up the resource
// claim it references and reconcile that claim.
return ctrl.NewControllerManagedBy(mgr).
Named(name).
Watches(&source.Kind{Type: &v1alpha3.FavouriteDBInstance{}}, &resource.EnqueueRequestForClaim{}).
For(&databasev1alpha1.FancySQLInstance{}).
WithEventFilter(p).
Complete(r)
}
// ConfigureFavouriteDBInstance is responsible for updating the supplied managed
// resource using the supplied resource class.
func ConfigureFavouriteDBInstance(_ context.Context, cm resource.Claim, cs resource.Class, mg resource.Managed) error {
if _, ok := cm.(*databasev1alpha1.FancySQLInstance); !ok {
return errors.New("resource claim is not a FancySQLInstance")
}
class, ok := cs.(*v1alpha3.FavouriteDBInstanceClass)
if !ok {
return errors.New("resource class is not a FavouriteDBInstanceClass")
}
instance, ok := mg.(*v1alpha3.FavouriteDBInstance)
if !ok {
return errors.New("managed resource is not a FavouriteDBInstance")
}
instance.Spec = v1alpha3.FavouriteDBInstanceSpec{
ResourceSpec: runtimev1alpha1.ResourceSpec{
// It's typical for dynamically provisioned managed resources to
// store their connection details in a Secret named for the claim's
// UID. Managed resource secrets are not intended for human
// consumption; they're copied to the resource claim's secret when
// the resource is bound.
WriteConnectionSecretToReference: runtimev1alpha1.SecretReference{
Namespace: class.SpecTemplate.WriteConnectionSecretsToNamespace,
Name: string(cm.GetUID()),
},
ProviderReference: class.SpecTemplate.ProviderReference,
ReclaimPolicy: class.SpecTemplate.ReclaimPolicy,
},
FavouriteDBInstanceParameters: class.SpecTemplate.FavouriteDBInstanceParameters,
}
return nil
}
```
### Connection Secret Propagation Controller
Managed resource kinds that may update their connection secrets after creation
time must instantiate a connection secret propagation controller. This
controller ensures any updates to the managed resource's connection secret are
propagated to the connection secret of its bound resource claim. The resource
claim reconciler ensures managed resource and resource claim secrets are
eligible for use with by the secret propagatation controller by adding the
appropriate annotations and controller references.
The following controller propagates any changes made to a `FavouriteDBInstance`
connection secret to the connection secret of its bound `FancySQLInstance`:
```go
import (
"fmt"
"strings"
corev1 "k8s.io/api/core/v1"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/source"
"github.com/crossplaneio/crossplane-runtime/pkg/resource"
databasev1alpha1 "github.com/crossplaneio/crossplane/apis/database/v1alpha1"
"github.com/crossplaneio/stack-fcp/apis/database/v1alpha3"
)
type FavouriteDBInstanceSecretController struct{}
func (c *FavouriteDBInstanceSecretController) SetupWithManager(mgr ctrl.Manager) error {
p := resource.NewPredicates(resource.AnyOf(
resource.AllOf(resource.IsControlledByKind(databasev1alpha1.FancySQLInstanceGroupVersionKind), resource.IsPropagated()),
resource.AllOf(resource.IsControlledByKind(v1alpha3.FavouriteDBInstanceGroupVersionKind), resource.IsPropagator()),
))
return ctrl.NewControllerManagedBy(mgr).
Named(strings.ToLower(fmt.Sprintf("connectionsecret.%s.%s", v1alpha3.FavouriteDBInstanceKind, v1alpha3.Group))).
Watches(&source.Kind{Type: &corev1.Secret{}}, &resource.EnqueueRequestForPropagator{}).
For(&corev1.Secret{}).
WithEventFilter(p).
Complete(resource.NewSecretPropagatingReconciler(mgr))
}
```
### Wrapping Up
Once all your controllers are in place you'll want to test them. Note that most
projects under the [crossplaneio org] [favor] table driven tests that use Go's
standard library `testing` package over kubebuilder's Gingko based tests.
Finally, don't forget to plumb any newly added resource kinds and controllers up
to your controller manager. Simple stacks may do this for each type within
within `main()`, but most more complicated stacks take an approach in which each
package exposes an `AddToScheme` (for resource kinds) or `SetupWithManager` (for
controllers) function that invokes the same function within its child packages,
resulting in a `main.go` like:
```go
import (
"time"
"sigs.k8s.io/controller-runtime/pkg/client/config"
"sigs.k8s.io/controller-runtime/pkg/manager"
"sigs.k8s.io/controller-runtime/pkg/runtime/signals"
crossplaneapis "github.com/crossplaneio/crossplane/apis"
fcpapis "github.com/crossplaneio/stack-fcp/apis"
"github.com/crossplaneio/stack-fcp/pkg/controller"
)
func main() {
cfg, err := config.GetConfig()
if err != nil {
panic(err)
}
mgr, err := manager.New(cfg, manager.Options{SyncPeriod: 1 * time.Hour})
if err != nil {
panic(err)
}
if err := crossplaneapis.AddToScheme(mgr.GetScheme()); err != nil {
panic(err)
}
if err := fcpapis.AddToScheme(mgr.GetScheme()); err != nil {
panic(err)
}
if err := controller.SetupWithManager(mgr); err != nil {
panic(err)
}
panic(mgr.Start(signals.SetupSignalHandler()))
}
```
## In Review
In this guide we walked through the process of defining all of the resource
kinds and controllers necessary to build support for a new managed service;
possibly even a completely new infrastructure stack. Please do not hesitate to
[reach out] to the Crossplane maintainers and community for help designing and
implementing support for new managed services. [#sig-services] would highly
value any feedback you may have about the services development process!
[What Makes a Crossplane Managed Service]: #what-makes-a-crossplane-managed-service
[managed resource]: concepts.md#managed-resource
[resource claim]: concepts.md#resource-claim
[resource class]: concepts.md#resource-class
[dynamic provisioning]: concepts.md#dynamic-and-static-provisioning
[`CloudMemorystoreInstance`]: https://github.com/crossplaneio/stack-gcp/blob/42ebb8b71/gcp/apis/cache/v1beta1/cloudmemorystore_instance_types.go#L146
[`CloudMemorystoreInstanceClass`]: https://github.com/crossplaneio/stack-gcp/blob/42ebb8b71/gcp/apis/cache/v1beta1/cloudmemorystore_instance_types.go#L237
[`Provider`]: https://github.com/crossplaneio/stack-gcp/blob/24ab7381b/gcp/apis/v1alpha3/types.go#L37
[`RedisCluster`]: https://github.com/crossplaneio/crossplane/blob/3c6cf4e/apis/cache/v1alpha1/rediscluster_types.go#L40
[`RedisClusterClass`]: https://github.com/crossplaneio/crossplane/blob/3c6cf4e/apis/cache/v1alpha1/rediscluster_types.go#L116
[watching the API server]: https://kubernetes.io/docs/reference/using-api/api-concepts/#efficient-detection-of-changes
[kubebuilder]: https://kubebuilder.io/
[controller-runtime]: https://github.com/kubernetes-sigs/controller-runtime
[crossplane-runtime]: https://github.com/crossplaneio/crossplane-runtime/
[crossplane-runtime v0.2.1]: https://github.com/crossplaneio/crossplane-runtime/releases/tag/v0.2.1
[golden path]: https://charity.wtf/2018/12/02/software-sprawl-the-golden-path-and-scaling-teams-with-agency/
[API Conventions]: https://github.com/kubernetes/community/blob/c6e1e89a/contributors/devel/sig-architecture/api-conventions.md
[kubebuilder book]: https://book.kubebuilder.io/
[Stacks quick start]: https://github.com/crossplaneio/crossplane-cli/blob/357d18e7b/README.md#quick-start-stacks
[resources]: https://kubebuilder.io/cronjob-tutorial/gvks.html#kinds-and-resources
[kinds]: https://kubebuilder.io/cronjob-tutorial/gvks.html#kinds-and-resources
[objects]: https://kubernetes.io/docs/concepts/#kubernetes-objects
[comment marker]: https://kubebuilder.io/reference/markers.html
[comment markers]: https://kubebuilder.io/reference/markers.html
[`resource.Managed`]: https://godoc.org/github.com/crossplaneio/crossplane-runtime/pkg/resource#Managed
[`resource.Claim`]: https://godoc.org/github.com/crossplaneio/crossplane-runtime/pkg/resource#Claim
[`resource.Class`]: https://godoc.org/github.com/crossplaneio/crossplane-runtime/pkg/resource#Class
[`resource.ManagedReconciler`]: https://godoc.org/github.com/crossplaneio/crossplane-runtime/pkg/resource#ManagedReconciler
[`resource.NewManagedReconciler`]: https://godoc.org/github.com/crossplaneio/crossplane-runtime/pkg/resource#NewManagedReconciler
[`resource.ClaimReconciler`]: https://godoc.org/github.com/crossplaneio/crossplane-runtime/pkg/resource#ClaimReconciler
[`resource.NewClaimReconciler`]: https://godoc.org/github.com/crossplaneio/crossplane-runtime/pkg/resource#NewClaimReconciler
[`resource.ClaimSchedulingReconciler`]: https://godoc.org/github.com/crossplaneio/crossplane-runtime/pkg/resource#ClaimSchedulingReconciler
[`resource.NewClaimSchedulingReconciler`]: https://godoc.org/github.com/crossplaneio/crossplane-runtime/pkg/resource#NewClaimSchedulingReconciler
[`resource.ClaimDefaultingReconciler`]: https://godoc.org/github.com/crossplaneio/crossplane-runtime/pkg/resource#ClaimDefaultingReconciler
[`resource.NewClaimDefaultingReconciler`]: https://godoc.org/github.com/crossplaneio/crossplane-runtime/pkg/resource#NewClaimDefaultingReconciler
[`resource.SecretPropagatingReconciler`]: https://godoc.org/github.com/crossplaneio/crossplane-runtime/pkg/resource#SecretPropagatingReconciler
[`resource.NewSecretPropagatingReconciler`]: https://godoc.org/github.com/crossplaneio/crossplane-runtime/pkg/resource#NewSecretPropagatingReconciler
[`resource.ExternalConnecter`]: https://godoc.org/github.com/crossplaneio/crossplane-runtime/pkg/resource#ExternalConnecter
[`resource.ExternalClient`]: https://godoc.org/github.com/crossplaneio/crossplane-runtime/pkg/resource#ExternalClient
[`resource.ManagedConfigurator`]: https://godoc.org/github.com/crossplaneio/crossplane-runtime/pkg/resource#ManagedConfigurator
[`ResourceSpec`]: https://godoc.org/github.com/crossplaneio/crossplane-runtime/apis/core/v1alpha1#ResourceSpec
[`ResourceStatus`]: https://godoc.org/github.com/crossplaneio/crossplane-runtime/apis/core/v1alpha1#ResourceStatus
[`ResourceClaimSpec`]: https://godoc.org/github.com/crossplaneio/crossplane-runtime/apis/core/v1alpha1#ResourceClaimSpec
[`ResourceClaimStatus`]: https://godoc.org/github.com/crossplaneio/crossplane-runtime/apis/core/v1alpha1#ResourceClaimStatus
[`ClassSpecTemplate`]: https://godoc.org/github.com/crossplaneio/crossplane-runtime/apis/core/v1alpha1#ClassSpecTemplate
['resource.ExternalConnecter`]: https://godoc.org/github.com/crossplaneio/crossplane-runtime/pkg/resource#ExternalConnecter
[opening a Crossplane issue]: https://github.com/crossplaneio/crossplane/issues/new/choose
[`GroupVersionKind`]: https://godoc.org/k8s.io/apimachinery/pkg/runtime/schema#GroupVersionKind
[`reconcile.Reconciler`]: https://godoc.org/sigs.k8s.io/controller-runtime/pkg/reconcile#Reconciler
[favor]: https://github.com/crossplaneio/crossplane/issues/452
[reach out]: https://github.com/crossplaneio/crossplane#contact
[#sig-services]: https://crossplane.slack.com/messages/sig-services
[crossplaneio org]: https://github.com/crossplaneio
[`angryjet`]: https://github.com/crossplaneio/crossplane-tools
[Managed Resource API Patterns]: ../design/one-pager-managed-resource-api-design.md
[Crossplane CLI]: https://github.com/crossplaneio/crossplane-cli#quick-start-stacks
[`angryjet` documentation]: https://github.com/crossplaneio/crossplane-tools/blob/master/README.md