Skip to content
This repository has been archived by the owner on Dec 15, 2022. It is now read-only.

Add common controller based on presumed tfcli interface #14

Merged
merged 18 commits into from
Aug 20, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion .golangci.yml
Expand Up @@ -38,7 +38,7 @@ linters-settings:
goimports:
# put imports beginning with prefix after 3rd-party packages;
# it's a comma-separated list of prefixes
local-prefixes: github.com/crossplane/crossplane
local-prefixes: github.com/crossplane-contrib/terrajet

gocyclo:
# minimal code complexity to report, 30 by default (but we recommend 10-20)
Expand Down
5 changes: 5 additions & 0 deletions go.mod
Expand Up @@ -3,9 +3,14 @@ module github.com/crossplane-contrib/terrajet
go 1.16

require (
github.com/crossplane/crossplane-runtime v0.14.0
github.com/hashicorp/hcl/v2 v2.8.2 // indirect
github.com/hashicorp/terraform-plugin-sdk/v2 v2.7.0
github.com/iancoleman/strcase v0.2.0
github.com/json-iterator/go v1.1.10
github.com/muvaf/typewriter v0.0.0-20210818141336-01a132960eec
github.com/pkg/errors v0.9.1
k8s.io/apimachinery v0.20.1
k8s.io/client-go v0.20.1
sigs.k8s.io/controller-runtime v0.8.0
)
504 changes: 503 additions & 1 deletion go.sum

Large diffs are not rendered by default.

30 changes: 30 additions & 0 deletions pkg/conversion/adapter.go
@@ -0,0 +1,30 @@
package conversion

import (
"context"

"github.com/crossplane/crossplane-runtime/pkg/reconciler/managed"

"github.com/crossplane-contrib/terrajet/pkg/terraform/resource"
)

// Observation represents result of an observe operation
type Observation struct {
ConnectionDetails managed.ConnectionDetails
UpToDate bool
Exists bool
LateInitialized bool
}

// Update represents result of an update operation
type Update struct {
Completed bool
ConnectionDetails managed.ConnectionDetails
}

// An Adapter is used to interact with terraform managed resources
type Adapter interface {
Observe(ctx context.Context, tr resource.Terraformed) (Observation, error)
CreateOrUpdate(ctx context.Context, tr resource.Terraformed) (Update, error)
Delete(ctx context.Context, tr resource.Terraformed) (bool, error)
}
206 changes: 206 additions & 0 deletions pkg/conversion/cli.go
@@ -0,0 +1,206 @@
package conversion

import (
"context"

xpmeta "github.com/crossplane/crossplane-runtime/pkg/meta"
"github.com/crossplane/crossplane-runtime/pkg/reconciler/managed"
"github.com/pkg/errors"

"github.com/crossplane-contrib/terrajet/pkg/meta"
"github.com/crossplane-contrib/terrajet/pkg/terraform/resource"
"github.com/crossplane-contrib/terrajet/pkg/tfcli"
tferrors "github.com/crossplane-contrib/terrajet/pkg/tfcli/errors"
)

const (
errCannotConsumeState = "cannot consume state"

errFmtCannotDoWithTFCli = "cannot %s with tf cli"
)

// BuildClientForResource returns a tfcli client by setting attributes
// (i.e. desired spec input) and terraform state (if available) for a given
// client builder base.
func BuildClientForResource(builderBase tfcli.Builder, tr resource.Terraformed) (tfcli.Client, error) {
var stateRaw []byte
if meta.GetState(tr) != "" {
stEnc := meta.GetState(tr)
st, err := BuildStateV4(stEnc, nil)
if err != nil {
return nil, errors.Wrap(err, "cannot build state")
}

stateRaw, err = st.Serialize()
if err != nil {
return nil, errors.Wrap(err, "cannot serialize state")
}
}

attr, err := tr.GetParameters()
if err != nil {
return nil, errors.Wrap(err, "failed to get attributes")
}

return builderBase.WithState(stateRaw).WithResourceBody(attr).BuildClient()
}

// CLI is an Adapter implementation for Terraform CLI
type CLI struct {
tfcli tfcli.Client
}

// NewCli returns a CLI object
func NewCli(client tfcli.Client) *CLI {
return &CLI{
tfcli: client,
}
}

// Observe is a Terraform CLI implementation for Observe function of Adapter interface.
func (t *CLI) Observe(ctx context.Context, tr resource.Terraformed) (Observation, error) {

tfRes, err := t.tfcli.Refresh(ctx, xpmeta.GetExternalName(tr))

if tferrors.IsApplying(err) {
// A previously started "Apply" operation is in progress or completed
// but one last call needs to be done as completed to be able to kick
// off a new operation. We will return "Exists: true, UpToDate: false"
// in order to trigger an Update call.
return Observation{
Exists: true,
UpToDate: false,
}, nil
}

if tferrors.IsDestroying(err) {
// A previously started "Destroy" operation is in progress or completed
// but one last call needs to be done as completed to be able to kick
// off a new operation. We will return "Exists: true, UpToDate: true" in
// order to trigger a Delete call (given we already have deletion
// timestamp set).
return Observation{
Exists: true,
UpToDate: true,
}, nil
}

if err != nil {
return Observation{}, errors.Wrapf(err, errFmtCannotDoWithTFCli, "observe")
}
// No tfcli operation was in progress, our blocking observation completed
// successfully, and we have an observation to consume.

// If resource does not exist, and it was actually deleted, we no longer
// need this client (hence underlying workspace) for this resource.
if !tfRes.Exists && xpmeta.WasDeleted(tr) {
muvaf marked this conversation as resolved.
Show resolved Hide resolved
return Observation{}, errors.Wrap(t.tfcli.Close(ctx), "failed to clean up tfcli client")
}

// After a successful observation, we now have a state to consume.
// We will consume the state by:
// - returning "sensitive attributes" as connection details
// - setting external name annotation, if not set already, from <id> attribute
// - late initializing "spec.forProvider" with "attributes"
// - setting observation at "status.atProvider" with "attributes"
// - storing base64encoded "tfstate" as an annotation
var conn managed.ConnectionDetails
if tfRes.State != nil {
conn, err = consumeState(tfRes.State, tr)
if err != nil {
return Observation{}, errors.Wrap(err, errCannotConsumeState)
}
}

return Observation{
ConnectionDetails: conn,
UpToDate: tfRes.UpToDate,
Exists: tfRes.Exists,
}, nil
}

// CreateOrUpdate is a Terraform CLI implementation for CreateOrUpdate function of Adapter interface.
func (t *CLI) CreateOrUpdate(ctx context.Context, tr resource.Terraformed) (Update, error) {
ar, err := t.tfcli.Apply(ctx)
if err != nil {
return Update{}, errors.Wrapf(err, errFmtCannotDoWithTFCli, "update")
}

if !ar.Completed {
return Update{}, nil
}

// After a successful Apply, we now have a state to consume.
// We will consume the state by:
// - returning "sensitive attributes" as connection details
// - setting external name annotation, if not set already, from <id> attribute
// - late initializing "spec.forProvider" with "attributes"
// - setting observation at "status.atProvider" with "attributes"
// - storing base64encoded "tfstate" as an annotation
conn, err := consumeState(ar.State, tr)
if err != nil {
return Update{}, errors.Wrap(err, errCannotConsumeState)
}
return Update{
Completed: true,
ConnectionDetails: conn,
}, err
}

// Delete is a Terraform CLI implementation for Delete function of Adapter interface.
func (t *CLI) Delete(ctx context.Context, tr resource.Terraformed) (bool, error) {
dr, err := t.tfcli.Destroy(ctx)
if err != nil {
return false, errors.Wrapf(err, errFmtCannotDoWithTFCli, "delete")
}

return dr.Completed, nil
}

// consumeState parses input tfstate and sets related fields in the custom resource.
func consumeState(state []byte, tr resource.Terraformed) (managed.ConnectionDetails, error) {
st, err := ParseStateV4(state)
if err != nil {
return nil, errors.Wrap(err, "cannot build state")
}

if xpmeta.GetExternalName(tr) == "" {
// Terraform stores id for the external resource as an attribute in the
// resource state. Key for the attribute holding external identifier is
// resource specific. We rely on GetTerraformResourceIdField() function
// to find out that key.
stAttr := map[string]interface{}{}
if err = JSParser.Unmarshal(st.GetAttributes(), &stAttr); err != nil {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a Get in jsoniterator that could help here.

return nil, errors.Wrap(err, "cannot parse state attributes")
}

id, exists := stAttr[tr.GetTerraformResourceIdField()]
if !exists {
return nil, errors.Wrapf(err, "no value for id field: %s", tr.GetTerraformResourceIdField())
}
extID, ok := id.(string)
if !ok {
return nil, errors.Wrap(err, "id field is not a string")
}
xpmeta.SetExternalName(tr, extID)
}
Comment on lines +167 to +186
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be an Initializer, alternative to NameAsExternalName?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure. If we make it an Initializer, then setting the external name will be done in the next reconcile parsing the stored state annotation (again). I don't see an immediate problem with this but also do not see much of a value, would prefer to consider again later.


// TODO(hasan): Handle late initialization

if err = tr.SetObservation(st.GetAttributes()); err != nil {
return nil, errors.Wrap(err, "cannot set observation")
}

conn := managed.ConnectionDetails{}
if err = JSParser.Unmarshal(st.GetSensitiveAttributes(), &conn); err != nil {
return nil, errors.Wrap(err, "cannot parse connection details")
}

stEnc, err := st.GetEncodedState()
if err != nil {
return nil, errors.Wrap(err, "cannot encoded state")
}
meta.SetState(tr, stEnc)

return conn, nil
}
9 changes: 9 additions & 0 deletions pkg/conversion/json.go
@@ -0,0 +1,9 @@
package conversion

import jsoniter "github.com/json-iterator/go"

// TFParser is a json parser to marshal/unmarshal using "tf" tag.
turkenh marked this conversation as resolved.
Show resolved Hide resolved
var TFParser = jsoniter.Config{TagKey: "tf"}.Froze()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can borrow some properties from the fastest configuration.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we confident that configuration wouldn't cause any data loss for any generated resource?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Floats lose precision up until 6-digits, which is fine I believe but not a strong opinion here.


// JSParser is a json parser to marshal/unmarshal using "json" tag.
var JSParser = jsoniter.Config{TagKey: "json"}.Froze()