diff --git a/apis/workspace/v1alpha1/types.go b/apis/workspace/v1alpha1/types.go index f582937..9b79b3c 100644 --- a/apis/workspace/v1alpha1/types.go +++ b/apis/workspace/v1alpha1/types.go @@ -78,12 +78,29 @@ type KeyReference struct { Key string `json:"key"` } +// A ModuleSource represents the source of a Terraform module. +// +kubebuilder:validation:Enum=Remote;Inline +type ModuleSource string + +// Module sources. +const ( + ModuleSourceRemote ModuleSource = "Remote" + ModuleSourceInline ModuleSource = "Inline" +) + // WorkspaceParameters are the configurable fields of a Workspace. type WorkspaceParameters struct { - // The root module of this workspace; i.e. the path to the directory that - // contains the main.tf file of the Terraform configuration. + // The root module of this workspace; i.e. the module containing its main.tf + // file. When the workspace's source is 'Remote' (the default) this can be + // any address supported by terraform init -from-module, for example a git + // repository or an S3 bucket. When the workspace's source is 'Inline' the + // content of a simple main.tf file may be written inline. Module string `json:"module"` + // Source of the root module of this workspace. + // +kubebuilder:default=Remote + Source ModuleSource `json:"source"` + // Configuration variables. // +optional Vars []Var `json:"vars,omitempty"` diff --git a/go.mod b/go.mod index 091e461..263b246 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/google/go-cmp v0.5.2 github.com/pkg/errors v0.9.1 gopkg.in/alecthomas/kingpin.v2 v2.2.6 + k8s.io/api v0.20.1 k8s.io/apimachinery v0.20.1 k8s.io/client-go v0.20.1 sigs.k8s.io/controller-runtime v0.8.0 diff --git a/go.sum b/go.sum index 19ff45d..9ca4fa2 100644 --- a/go.sum +++ b/go.sum @@ -121,8 +121,10 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= +github.com/docker/docker v0.7.3-0.20190327010347-be7ac8be2ae0 h1:w3NnFcKR5241cfmQU5ZZAsf0xcpId6mWOupTvJlUX2U= github.com/docker/docker v0.7.3-0.20190327010347-be7ac8be2ae0/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-units v0.3.3/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/docker/go-units v0.4.0 h1:3uh0PgVws3nIA0Q+MwDC8yjEPf9zjRfZZWXZYDct3Tw= github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/docker/spdystream v0.0.0-20160310174837-449fdfce4d96/go.mod h1:Qh8CwZgvJUkLughtfhJv5dyTYa91l1fOUCrgjqmcifM= github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= @@ -484,6 +486,7 @@ github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeV github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/sirupsen/logrus v1.6.0 h1:UBcNElsrwanuuMsnGSlYmtmgbb23qDR5dG+6X6Oo89I= github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= @@ -831,6 +834,7 @@ google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfG google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20201110150050-8816d57aaa9a h1:pOwg4OoaRYScjmR4LlLgdtnyoHYTSAVhhqe5uPdpII8= google.golang.org/genproto v0.0.0-20201110150050-8816d57aaa9a/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= @@ -840,6 +844,7 @@ google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyac google.golang.org/grpc v1.23.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.1 h1:zvIju4sqAGvwKspUQOhwnpcqSbzi7/H6QomNNjTL4sk= google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= diff --git a/internal/controller/workspace/workspace.go b/internal/controller/workspace/workspace.go index da23e89..404ad0a 100644 --- a/internal/controller/workspace/workspace.go +++ b/internal/controller/workspace/workspace.go @@ -18,23 +18,29 @@ package workspace import ( "context" - "fmt" + "io/ioutil" + "os" + "path/filepath" "github.com/pkg/errors" + corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/util/workqueue" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller" + xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1" "github.com/crossplane/crossplane-runtime/pkg/event" "github.com/crossplane/crossplane-runtime/pkg/logging" + "github.com/crossplane/crossplane-runtime/pkg/meta" "github.com/crossplane/crossplane-runtime/pkg/ratelimiter" "github.com/crossplane/crossplane-runtime/pkg/reconciler/managed" "github.com/crossplane/crossplane-runtime/pkg/resource" tfv1alpha1 "github.com/negz/provider-terraform/apis/v1alpha1" "github.com/negz/provider-terraform/apis/workspace/v1alpha1" + "github.com/negz/provider-terraform/internal/terraform" ) const ( @@ -43,16 +49,37 @@ const ( errGetPC = "cannot get ProviderConfig" errGetCreds = "cannot get credentials" - errNewClient = "cannot create new Service" + errMkdir = "cannot make Terraform configuration directory" + errWriteCreds = "cannot write Terraform credentials" + errWriteMain = "cannot write Terraform configuration " + tfMain + errInit = "cannot initialize Terraform configuration" + errWorkspace = "cannot select Terraform workspace" + errResources = "cannot list Terraform resources" + errOutputs = "cannot list Terraform outputs" + errOptions = "cannot determine Terraform options" + errApply = "cannot apply Terraform configuration" + errDestroy = "cannot apply Terraform configuration" + errVarFile = "cannot get tfvars" ) -// A NoOpService does nothing. -type NoOpService struct{} - -var ( - newNoOpService = func(_ []byte) (interface{}, error) { return &NoOpService{}, nil } +const ( + // TODO(negz): Make the Terraform binary path and work dir configurable. + tfPath = "terraform" + tfDir = "/tf" + tfCreds = "credentials" + tfMain = "main.tf" ) +type tfclient interface { + Init(ctx context.Context, o ...terraform.InitOption) error + Validate(ctx context.Context) error + Workspace(ctx context.Context, name string) error + Outputs(ctx context.Context) ([]terraform.Output, error) + Resources(ctx context.Context) ([]string, error) + Apply(ctx context.Context, o ...terraform.Option) error + Destroy(ctx context.Context, o ...terraform.Option) error +} + // Setup adds a controller that reconciles Workspace managed resources. func Setup(mgr ctrl.Manager, l logging.Logger, rl workqueue.RateLimiter) error { name := managed.ControllerName(v1alpha1.WorkspaceGroupKind) @@ -64,9 +91,8 @@ func Setup(mgr ctrl.Manager, l logging.Logger, rl workqueue.RateLimiter) error { r := managed.NewReconciler(mgr, resource.ManagedKind(v1alpha1.WorkspaceGroupVersionKind), managed.WithExternalConnecter(&connector{ - kube: mgr.GetClient(), - usage: resource.NewProviderConfigUsageTracker(mgr.GetClient(), &tfv1alpha1.ProviderConfigUsage{}), - newServiceFn: newNoOpService}), + kube: mgr.GetClient(), + usage: resource.NewProviderConfigUsageTracker(mgr.GetClient(), &tfv1alpha1.ProviderConfigUsage{})}), managed.WithLogger(l.WithValues("controller", name)), managed.WithRecorder(event.NewAPIRecorder(mgr.GetEventRecorderFor(name)))) @@ -77,25 +103,23 @@ func Setup(mgr ctrl.Manager, l logging.Logger, rl workqueue.RateLimiter) error { Complete(r) } -// A connector is expected to produce an ExternalClient when its Connect method -// is called. type connector struct { - kube client.Client - usage resource.Tracker - newServiceFn func(creds []byte) (interface{}, error) + kube client.Client + usage resource.Tracker } -// Connect typically produces an ExternalClient by: -// 1. Tracking that the managed resource is using a ProviderConfig. -// 2. Getting the managed resource's ProviderConfig. -// 3. Getting the credentials specified by the ProviderConfig. -// 4. Using the credentials to form a client. func (c *connector) Connect(ctx context.Context, mg resource.Managed) (managed.ExternalClient, error) { cr, ok := mg.(*v1alpha1.Workspace) if !ok { return nil, errors.New(errNotWorkspace) } + // TODO(negz): Garbage collect this directory. + dir := filepath.Join(tfDir, string(cr.GetUID())) + if err := os.MkdirAll(dir, 0600); resource.Ignore(os.IsExist, err) != nil { + return nil, errors.Wrap(err, errMkdir) + } + if err := c.usage.Track(ctx, mg); err != nil { return nil, errors.Wrap(err, errTrackPCUsage) } @@ -111,61 +135,59 @@ func (c *connector) Connect(ctx context.Context, mg resource.Managed) (managed.E return nil, errors.Wrap(err, errGetCreds) } - svc, err := c.newServiceFn(data) - if err != nil { - return nil, errors.Wrap(err, errNewClient) + if err := ioutil.WriteFile(filepath.Join(dir, tfCreds), data, 0600); err != nil { + return nil, errors.Wrap(err, errWriteCreds) + } + + // TODO(negz): Allow this implementation to be swapped out for testing. + tf := terraform.Harness{Path: tfPath, Dir: dir} + + io := []terraform.InitOption{terraform.FromModule(cr.Spec.ForProvider.Module)} + if cr.Spec.ForProvider.Source == v1alpha1.ModuleSourceInline { + if err := ioutil.WriteFile(filepath.Join(dir, tfMain), []byte(cr.Spec.ForProvider.Module), 0600); err != nil { + return nil, errors.Wrap(err, errWriteMain) + } + io = nil + } + + if err := tf.Init(ctx, io...); err != nil { + return nil, errors.Wrap(err, errInit) } - return &external{service: svc}, nil + return &external{tf: tf, kube: c.kube}, errors.Wrap(tf.Workspace(ctx, meta.GetExternalName(cr)), errWorkspace) } -// An ExternalClient observes, then either creates, updates, or deletes an -// external resource to ensure it reflects the managed resource's desired state. type external struct { - // A 'client' used to connect to the external resource API. In practice this - // would be something like an AWS SDK client. - service interface{} + tf tfclient + kube client.Reader } -func (c *external) Observe(ctx context.Context, mg resource.Managed) (managed.ExternalObservation, error) { - cr, ok := mg.(*v1alpha1.Workspace) - if !ok { - return managed.ExternalObservation{}, errors.New(errNotWorkspace) +func (c *external) Observe(ctx context.Context, _ resource.Managed) (managed.ExternalObservation, error) { + r, err := c.tf.Resources(ctx) + if err != nil { + return managed.ExternalObservation{}, errors.Wrap(err, errResources) } - // These fmt statements should be removed in the real implementation. - fmt.Printf("Observing: %+v", cr) + // TODO(negz): Include any non-sensitive outputs in our status? + o, err := c.tf.Outputs(ctx) + if err != nil { + return managed.ExternalObservation{}, errors.Wrap(err, errOutputs) + } + // TODO(negz): Is there any value in running terraform plan to determine + // whether the workspace is up-to-date, or should we just YOLO apply? return managed.ExternalObservation{ - // Return false when the external resource does not exist. This lets - // the managed resource reconciler know that it needs to call Create to - // (re)create the resource, or that it has successfully been deleted. - ResourceExists: true, - - // Return false when the external resource exists, but it not up to date - // with the desired managed resource state. This lets the managed - // resource reconciler know that it needs to call Update. - ResourceUpToDate: true, - - // Return any details that may be required to connect to the external - // resource. These will be stored as the connection secret. - ConnectionDetails: managed.ConnectionDetails{}, + ResourceExists: len(r) > 0, + ResourceUpToDate: false, + ResourceLateInitialized: false, + ConnectionDetails: op2cd(o), }, nil } func (c *external) Create(ctx context.Context, mg resource.Managed) (managed.ExternalCreation, error) { - cr, ok := mg.(*v1alpha1.Workspace) - if !ok { - return managed.ExternalCreation{}, errors.New(errNotWorkspace) - } - - fmt.Printf("Creating: %+v", cr) - - return managed.ExternalCreation{ - // Optionally return any details that may be required to connect to the - // external resource. These will be stored as the connection secret. - ConnectionDetails: managed.ConnectionDetails{}, - }, nil + // Terraform does not have distinct 'create' and 'update' operations. + u, err := c.Update(ctx, mg) + return managed.ExternalCreation{ConnectionDetails: u.ConnectionDetails}, err } func (c *external) Update(ctx context.Context, mg resource.Managed) (managed.ExternalUpdate, error) { @@ -174,13 +196,25 @@ func (c *external) Update(ctx context.Context, mg resource.Managed) (managed.Ext return managed.ExternalUpdate{}, errors.New(errNotWorkspace) } - fmt.Printf("Updating: %+v", cr) + o, err := c.options(ctx, cr.Spec.ForProvider) + if err != nil { + return managed.ExternalUpdate{}, errors.Wrap(err, errOptions) + } + + if err := c.tf.Apply(ctx, o...); err != nil { + return managed.ExternalUpdate{}, errors.Wrap(err, errApply) + } - return managed.ExternalUpdate{ - // Optionally return any details that may be required to connect to the - // external resource. These will be stored as the connection secret. - ConnectionDetails: managed.ConnectionDetails{}, - }, nil + op, err := c.tf.Outputs(ctx) + if err != nil { + return managed.ExternalUpdate{}, errors.Wrap(err, errOutputs) + } + + // TODO(negz): Allow Workspaces to optionally derive their readiness from an + // output - similar to the logic XRs use to derive readiness from a field of + // a composed resource. + mg.SetConditions(xpv1.Available()) + return managed.ExternalUpdate{ConnectionDetails: op2cd(op)}, nil } func (c *external) Delete(ctx context.Context, mg resource.Managed) error { @@ -189,7 +223,61 @@ func (c *external) Delete(ctx context.Context, mg resource.Managed) error { return errors.New(errNotWorkspace) } - fmt.Printf("Deleting: %+v", cr) + o, err := c.options(ctx, cr.Spec.ForProvider) + if err != nil { + return errors.Wrap(err, errOptions) + } - return nil + return errors.Wrap(c.tf.Destroy(ctx, o...), errDestroy) +} + +func (c *external) options(ctx context.Context, p v1alpha1.WorkspaceParameters) ([]terraform.Option, error) { + o := make([]terraform.Option, 0, len(p.Vars)+len(p.VarFiles)) + + for _, v := range p.Vars { + o = append(o, terraform.WithVar(v.Key, v.Value)) + } + + for _, vf := range p.VarFiles { + fmt := terraform.HCL + if vf.Format == v1alpha1.VarFileFormatJSON { + fmt = terraform.JSON + } + + switch vf.Source { + case v1alpha1.VarFileSourceConfigMapKey: + cm := &corev1.ConfigMap{} + r := vf.ConfigMapKeyReference + nn := types.NamespacedName{Namespace: r.Namespace, Name: r.Name} + if err := c.kube.Get(ctx, nn, cm); err != nil { + return nil, errors.Wrap(err, errVarFile) + } + o = append(o, terraform.WithVarFile(cm.BinaryData[r.Key], fmt)) + + case v1alpha1.VarFileSourceSecretKey: + s := &corev1.Secret{} + r := vf.SecretKeyReference + nn := types.NamespacedName{Namespace: r.Namespace, Name: r.Name} + if err := c.kube.Get(ctx, nn, s); err != nil { + return nil, errors.Wrap(err, errVarFile) + } + o = append(o, terraform.WithVarFile(s.Data[r.Key], fmt)) + } + } + + return o, nil +} + +func op2cd(o []terraform.Output) managed.ConnectionDetails { + cd := managed.ConnectionDetails{} + for _, op := range o { + if op.Type == terraform.OutputTypeString { + cd[op.Name] = []byte(op.StringValue()) + continue + } + if j, err := op.JSONValue(); err == nil { + cd[op.Name] = j + } + } + return cd } diff --git a/internal/controller/workspace/workspace_test.go b/internal/controller/workspace/workspace_test.go index e68969d..d68c21f 100644 --- a/internal/controller/workspace/workspace_test.go +++ b/internal/controller/workspace/workspace_test.go @@ -37,7 +37,6 @@ import ( func TestObserve(t *testing.T) { type fields struct { - service interface{} } type args struct { @@ -61,7 +60,7 @@ func TestObserve(t *testing.T) { for name, tc := range cases { t.Run(name, func(t *testing.T) { - e := external{service: tc.fields.service} + e := external{} got, err := e.Observe(tc.args.ctx, tc.args.mg) if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { t.Errorf("\n%s\ne.Observe(...): -want error, +got error:\n%s\n", tc.reason, diff) diff --git a/internal/terraform/terraform.go b/internal/terraform/terraform.go index 48fdeaa..c3de7de 100644 --- a/internal/terraform/terraform.go +++ b/internal/terraform/terraform.go @@ -28,19 +28,15 @@ import ( "regexp" "sort" "strconv" + "strings" "github.com/pkg/errors" ) // Error strings. const ( - errInit = "cannot initialize Terraform configuration" - errValidate = "cannot validate Terraform configuration" - errWorkspace = "cannot set Terraform workspace" - errOutput = "cannot read outputs from Terraform state" + errParse = "cannot parse Terraform output" errWriteVarFile = "cannot write tfvars file" - errApply = "cannot apply Terraform configuration" - errDestroy = "cannot destroy Terraform configuration" errFmtInvalidConfig = "invalid Terraform configuration: found %d errors" ) @@ -97,13 +93,34 @@ type Harness struct { // logic that copies Stderr into an *exec.ExitError. } +type initOptions struct { + args []string +} + +// An InitOption affects how a Terraform is initialized. +type InitOption func(o *initOptions) + +// FromModule can be used to initialize a Terraform configuration from a module, +// which may be pulled from git, a local directory, a storage bucket, etc. +func FromModule(module string) InitOption { + return func(o *initOptions) { + o.args = append(o.args, "-from-module="+module) + } +} + // Init initializes a Terraform configuration. -func (h Harness) Init(ctx context.Context, fromModule string) error { - cmd := exec.CommandContext(ctx, h.Path, "init", "-input=false", "-no-color", "-from-module="+fromModule) //nolint:gosec +func (h Harness) Init(ctx context.Context, o ...InitOption) error { + io := &initOptions{} + for _, fn := range o { + fn(io) + } + + args := append([]string{"init", "-input=false", "-no-color"}, io.args...) + cmd := exec.CommandContext(ctx, h.Path, args...) //nolint:gosec cmd.Dir = h.Dir _, err := cmd.Output() - return errors.Wrap(Classify(err), errInit) + return Classify(err) } // Validate a Terraform configuration. Note that there may be interplay between @@ -128,9 +145,9 @@ func (h Harness) Validate(ctx context.Context) error { // If stdout doesn't appear to be the JSON we expected we try to extract // an error from stderr. if err != nil { - return errors.Wrap(Classify(err), errValidate) + return Classify(err) } - return errors.Wrap(jerr, errValidate) + return errors.Wrap(jerr, errParse) } if r.Valid { @@ -158,14 +175,44 @@ func (h Harness) Workspace(ctx context.Context, name string) error { cmd = exec.CommandContext(ctx, h.Path, "workspace", "new", "-no-color", name) //nolint:gosec cmd.Dir = h.Dir _, err := cmd.Output() - return errors.Wrap(Classify(err), errWorkspace) + return Classify(err) +} + +// An OutputType of Terraform. +type OutputType int + +// Terraform output types. +const ( + OutputTypeUnknown OutputType = iota + OutputTypeString + OutputTypeNumber + OutputTypeBool + OutputTypeTuple + OutputTypeObject +) + +func outputType(t string) OutputType { + switch t { + case "string": + return OutputTypeString + case "number": + return OutputTypeNumber + case "bool": + return OutputTypeBool + case "tuple": + return OutputTypeTuple + case "object": + return OutputTypeObject + default: + return OutputTypeUnknown + } } // An Output from Terraform. type Output struct { Name string Sensitive bool - Type string + Type OutputType value interface{} } @@ -203,8 +250,8 @@ func (o Output) JSONValue() ([]byte, error) { return json.Marshal(o.value) } -// Output extracts outputs from Terraform state. -func (h Harness) Output(ctx context.Context) ([]Output, error) { +// Outputs extracts outputs from Terraform state. +func (h Harness) Outputs(ctx context.Context) ([]Output, error) { cmd := exec.CommandContext(ctx, h.Path, "output", "-json") //nolint:gosec cmd.Dir = h.Dir @@ -221,9 +268,9 @@ func (h Harness) Output(ctx context.Context) ([]Output, error) { // If stdout doesn't appear to be the JSON we expected we try to extract // an error from stderr. if err != nil { - return nil, errors.Wrap(Classify(err), errOutput) + return nil, Classify(err) } - return nil, errors.Wrap(jerr, errOutput) + return nil, errors.Wrap(jerr, errParse) } o := make([]Output, 0, len(outputs)) @@ -246,7 +293,7 @@ func (h Harness) Output(ctx context.Context) ([]Output, error) { o = append(o, Output{ Name: name, Sensitive: output.Sensitive, - Type: t, + Type: outputType(t), value: output.Value}) } @@ -254,6 +301,20 @@ func (h Harness) Output(ctx context.Context) ([]Output, error) { return o, nil } +// Resources returns a list of resources in the Terraform state. +func (h Harness) Resources(ctx context.Context) ([]string, error) { + cmd := exec.CommandContext(ctx, h.Path, "state", "list") //nolint:gosec + cmd.Dir = h.Dir + + out, err := cmd.Output() + if err != nil { + return nil, Classify(err) + } + + resources := strings.Split(string(out), "\n") + return resources[:len(resources)-1], nil +} + type varFile struct { data []byte filename string @@ -315,7 +376,7 @@ func (h Harness) Apply(ctx context.Context, o ...Option) error { cmd.Dir = h.Dir _, err := cmd.Output() - return errors.Wrap(Classify(err), errApply) + return Classify(err) } // Destroy a Terraform configuration. @@ -336,5 +397,5 @@ func (h Harness) Destroy(ctx context.Context, o ...Option) error { cmd.Dir = h.Dir _, err := cmd.Output() - return errors.Wrap(Classify(err), errDestroy) + return Classify(err) } diff --git a/internal/terraform/terraform_harness_test.go b/internal/terraform/terraform_harness_test.go index 07467cc..d36a8ba 100644 --- a/internal/terraform/terraform_harness_test.go +++ b/internal/terraform/terraform_harness_test.go @@ -130,7 +130,7 @@ func TestWorkspace(t *testing.T) { } } -func TestOutput(t *testing.T) { +func TestOutputs(t *testing.T) { type want struct { outputs []Output err error @@ -147,18 +147,18 @@ func TestOutput(t *testing.T) { ctx: context.Background(), want: want{ outputs: []Output{ - {Name: "bool", Type: "bool", value: true}, - {Name: "number", Type: "number", value: float64(42)}, + {Name: "bool", Type: OutputTypeBool, value: true}, + {Name: "number", Type: OutputTypeNumber, value: float64(42)}, { Name: "object", - Type: "object", + Type: OutputTypeObject, value: map[string]interface{}{"wow": "suchobject"}, }, - {Name: "sensitive", Sensitive: true, Type: "string", value: "very"}, - {Name: "string", Type: "string", value: "very"}, + {Name: "sensitive", Sensitive: true, Type: OutputTypeString, value: "very"}, + {Name: "string", Type: OutputTypeString, value: "very"}, { Name: "tuple", - Type: "tuple", + Type: OutputTypeTuple, value: []interface{}{"a", "really", "long", "tuple"}, }, }, @@ -171,13 +171,59 @@ func TestOutput(t *testing.T) { // Reading output is a read-only operation, so we operate directly // on our test data instead of creating a temporary directory. tf := Harness{Path: tfBinaryPath, Dir: tc.module} - got, err := tf.Output(tc.ctx) + got, err := tf.Outputs(tc.ctx) if diff := cmp.Diff(tc.want.outputs, got, cmp.AllowUnexported(Output{})); diff != "" { - t.Errorf("\n%s\ntf.Output(...): -want error, +got error:\n%s", tc.reason, diff) + t.Errorf("\n%s\ntf.Outputs(...): -want error, +got error:\n%s", tc.reason, diff) } if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { - t.Errorf("\n%s\ntf.Output(...): -want error, +got error:\n%s", tc.reason, diff) + t.Errorf("\n%s\ntf.Outputs(...): -want error, +got error:\n%s", tc.reason, diff) + } + }) + } +} + +func TestResources(t *testing.T) { + type want struct { + resources []string + err error + } + cases := map[string]struct { + reason string + module string + ctx context.Context + want want + }{ + "ModuleWithResources": { + reason: "We should return resources from a module.", + module: "testdata/nullmodule", + ctx: context.Background(), + want: want{ + resources: []string{"null_resource.test", "random_id.test"}, + }, + }, + "ModuleWithoutResources": { + reason: "We should not return resources from a module when there are none.", + module: "testdata/outputmodule", + ctx: context.Background(), + want: want{ + resources: []string{}, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + // Reading output is a read-only operation, so we operate directly + // on our test data instead of creating a temporary directory. + tf := Harness{Path: tfBinaryPath, Dir: tc.module} + got, err := tf.Resources(tc.ctx) + + if diff := cmp.Diff(tc.want.resources, got, cmp.AllowUnexported(Output{})); diff != "" { + t.Errorf("\n%s\ntf.Resources(...): -want error, +got error:\n%s", tc.reason, diff) + } + if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { + t.Errorf("\n%s\ntf.Resources(...): -want error, +got error:\n%s", tc.reason, diff) } }) } @@ -185,8 +231,8 @@ func TestOutput(t *testing.T) { func TestInitApplyDestroy(t *testing.T) { type initArgs struct { - ctx context.Context - fromModule string + ctx context.Context + o []InitOption } type args struct { ctx context.Context @@ -208,8 +254,8 @@ func TestInitApplyDestroy(t *testing.T) { "Simple": { reason: "It should be possible to initialize, apply, and destroy a simple Terraform module", initArgs: initArgs{ - ctx: context.Background(), - fromModule: filepath.Join(tfTestDataPath(), "nullmodule"), + ctx: context.Background(), + o: []InitOption{FromModule(filepath.Join(tfTestDataPath(), "nullmodule"))}, }, applyArgs: args{ ctx: context.Background(), @@ -221,8 +267,8 @@ func TestInitApplyDestroy(t *testing.T) { "WithVar": { reason: "It should be possible to initialize a simple Terraform module, then apply and destroy it with a supplied variable", initArgs: initArgs{ - ctx: context.Background(), - fromModule: filepath.Join(tfTestDataPath(), "nullmodule"), + ctx: context.Background(), + o: []InitOption{FromModule(filepath.Join(tfTestDataPath(), "nullmodule"))}, }, applyArgs: args{ ctx: context.Background(), @@ -236,8 +282,8 @@ func TestInitApplyDestroy(t *testing.T) { "WithHCLVarFile": { reason: "It should be possible to initialize a simple Terraform module, then apply and destroy it with a supplied HCL file of variables", initArgs: initArgs{ - ctx: context.Background(), - fromModule: filepath.Join(tfTestDataPath(), "nullmodule"), + ctx: context.Background(), + o: []InitOption{FromModule(filepath.Join(tfTestDataPath(), "nullmodule"))}, }, applyArgs: args{ ctx: context.Background(), @@ -251,8 +297,8 @@ func TestInitApplyDestroy(t *testing.T) { "WithJSONVarFile": { reason: "It should be possible to initialize a simple Terraform module, then apply and destroy it with a supplied JSON file of variables", initArgs: initArgs{ - ctx: context.Background(), - fromModule: filepath.Join(tfTestDataPath(), "nullmodule"), + ctx: context.Background(), + o: []InitOption{FromModule(filepath.Join(tfTestDataPath(), "nullmodule"))}, }, applyArgs: args{ ctx: context.Background(), @@ -270,8 +316,8 @@ func TestInitApplyDestroy(t *testing.T) { "ModuleNotFound": { reason: "Init should return an error when asked to initialize from a module that doesn't exist", initArgs: initArgs{ - ctx: context.Background(), - fromModule: "./nonexistent", + ctx: context.Background(), + o: []InitOption{FromModule("./nonexistent")}, }, applyArgs: args{ ctx: context.Background(), @@ -280,16 +326,16 @@ func TestInitApplyDestroy(t *testing.T) { ctx: context.Background(), }, want: want{ - init: errors.Wrap(errors.New("module not found"), errInit), - apply: errors.Wrap(errors.New("no configuration files"), errApply), + init: errors.New("module not found"), + apply: errors.New("no configuration files"), // Apparently destroy 'works' in this situation ¯\_(ツ)_/¯ }, }, "UndeclaredVar": { reason: "Destroy should return an error when supplied a variable not declared by the module", initArgs: initArgs{ - ctx: context.Background(), - fromModule: filepath.Join(tfTestDataPath(), "nullmodule"), + ctx: context.Background(), + o: []InitOption{FromModule(filepath.Join(tfTestDataPath(), "nullmodule"))}, }, applyArgs: args{ ctx: context.Background(), @@ -299,13 +345,15 @@ func TestInitApplyDestroy(t *testing.T) { o: []Option{WithVar("boop", "doop!")}, }, want: want{ - destroy: errors.Wrap(errors.New("value for undeclared variable"), errDestroy), + destroy: errors.New("value for undeclared variable"), }, }, } for name, tc := range cases { t.Run(name, func(t *testing.T) { + t.Parallel() + dir, err := ioutil.TempDir("", "provider-terraform-test") if err != nil { t.Fatalf("Cannot create temporary directory: %v", err) @@ -314,7 +362,7 @@ func TestInitApplyDestroy(t *testing.T) { tf := Harness{Path: tfBinaryPath, Dir: dir} - got := tf.Init(tc.initArgs.ctx, tc.initArgs.fromModule) + got := tf.Init(tc.initArgs.ctx, tc.initArgs.o...) if diff := cmp.Diff(tc.want.init, got, test.EquateErrors()); diff != "" { t.Errorf("\n%s\ntf.Init(...): -want, +got:\n%s", tc.reason, diff) } diff --git a/internal/terraform/testdata/nullmodule/terraform.tfstate b/internal/terraform/testdata/nullmodule/terraform.tfstate new file mode 100644 index 0000000..35181a5 --- /dev/null +++ b/internal/terraform/testdata/nullmodule/terraform.tfstate @@ -0,0 +1,63 @@ +{ + "version": 4, + "terraform_version": "0.14.7", + "serial": 3, + "lineage": "7b9c5b6b-ce2c-9cb9-f940-b696b4454038", + "outputs": { + "coolness": { + "value": "very", + "type": "string" + }, + "randomness": { + "value": "f33da529", + "type": "string" + } + }, + "resources": [ + { + "mode": "managed", + "type": "null_resource", + "name": "test", + "provider": "provider[\"registry.terraform.io/hashicorp/null\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "id": "1790215571976780205", + "triggers": { + "trigger": "f33da529" + } + }, + "sensitive_attributes": [], + "private": "bnVsbA==", + "dependencies": [ + "random_id.test" + ] + } + ] + }, + { + "mode": "managed", + "type": "random_id", + "name": "test", + "provider": "provider[\"registry.terraform.io/hashicorp/random\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "b64_std": "8z2lKQ==", + "b64_url": "8z2lKQ", + "byte_length": 4, + "dec": "4080903465", + "hex": "f33da529", + "id": "8z2lKQ", + "keepers": null, + "prefix": null + }, + "sensitive_attributes": [], + "private": "bnVsbA==" + } + ] + } + ] +} diff --git a/package/crds/tf.crossplane.io_providerconfigs.yaml b/package/crds/tf.crossplane.io_providerconfigs.yaml index 12f9a24..7e7da36 100644 --- a/package/crds/tf.crossplane.io_providerconfigs.yaml +++ b/package/crds/tf.crossplane.io_providerconfigs.yaml @@ -1,3 +1,5 @@ + +--- apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: diff --git a/package/crds/tf.crossplane.io_providerconfigusages.yaml b/package/crds/tf.crossplane.io_providerconfigusages.yaml index 62400df..96a1709 100644 --- a/package/crds/tf.crossplane.io_providerconfigusages.yaml +++ b/package/crds/tf.crossplane.io_providerconfigusages.yaml @@ -1,3 +1,5 @@ + +--- apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: diff --git a/package/crds/workspace.tf.crossplane.io_workspaces.yaml b/package/crds/workspace.tf.crossplane.io_workspaces.yaml index ce3860b..0911d83 100644 --- a/package/crds/workspace.tf.crossplane.io_workspaces.yaml +++ b/package/crds/workspace.tf.crossplane.io_workspaces.yaml @@ -1,3 +1,5 @@ + +--- apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: @@ -53,7 +55,14 @@ spec: description: WorkspaceParameters are the configurable fields of a Workspace. properties: module: - description: The root module of this workspace; i.e. the path to the directory that contains the main.tf file of the Terraform configuration. + description: The root module of this workspace; i.e. the module containing its main.tf file. When the workspace's source is 'Remote' (the default) this can be any address supported by terraform init -from-module, for example a git repository or an S3 bucket. When the workspace's source is 'Inline' the content of a simple main.tf file may be written inline. + type: string + source: + default: Remote + description: Source of the root module of this workspace. + enum: + - Remote + - Inline type: string varFiles: description: Files of configuration variables. Explicitly declared vars take precedence. @@ -128,6 +137,7 @@ spec: type: array required: - module + - source type: object providerConfigRef: description: ProviderConfigReference specifies how the provider that will be used to create, observe, update, and delete this managed resource should be configured.