Repository: crossplane-contrib/provider-terraform (go)

Issue:
problem using modules with a relative path
We found a problem using modules with a relative path in the workspace remote like this.

module "db_option_group" {
  source = "../db_option_group"

When try to install we get this error \:

2022-08-16T21:03:15.466Z DEBUG controller-runtime.manager.events Warning {"object": {"kind":"Workspace","name":"rds-remote","uid":"62c9fe78-b7df-4796-9eba-ba9700f271dd","apiVersion":"tf.crossplane.io/v1alpha1","resourceVersion":"18459415"}, "reason": "CannotConnectToProvider", "message": "cannot initialize Terraform configuration: unreadable module directory"}

We saw  is made a change to don't use --from-module on previous release, but It seen don't working


What environment did it happen in?
Crossplane version: last version


Cloud provider: AWS
Kubernetes version: v1.21.12
Kubernetes distribution:  EKS
OS VERSION: 18.04.6 LTS (Bionic Beaver)
Kernel: Linux runway-devenv 4.15.0-176-generic #185-Ubuntu SMP Tue Mar 29 17:40:04 UTC 2022 x86_64 x86_64 x86_64 GNU/Linux

Pull request:
Introduce entrypoint to enable working with relative path modules


<!--
Thank you for helping to improve Crossplane!

Please read through https://git.io/fj2m9 if this is your first time opening a
Crossplane pull request. Find us in https://slack.crossplane.io/messages/dev if
you need any help contributing.
-->

### Description of your changes

<!--
Briefly describe what this pull request does. Be sure to direct your reviewers'
attention to anything that needs special consideration.

We love pull requests that resolve an open Crossplane issue. If yours does, you
can uncomment the below line to indicate which issue your PR fixes, for example
"Fixes #500":

-->
Fixes #83

Signed-off-by: Yury Tsarev <yury@upbound.io>

I have:

- [x] Read and followed Crossplane's [contribution process].
- [x] Run `make reviewable test` to ensure this PR is ready for review.

### How has this code been tested

Used https://github.com/ytsarev/provider-terraform-test-module for e2e testing

```
$ cat tf.yaml
apiVersion: tf.crossplane.io/v1alpha1
kind: Workspace
metadata:
  name: relative-path-test
spec:
  forProvider:
    module: git::https://github.com/ytsarev/provider-terraform-test-module.git
    source: Remote
    entrypoint: relative-path-iam
    vars:
      - key: iamRole
        value: relative-path-test

$ cat tf-regression.yaml
apiVersion: tf.crossplane.io/v1alpha1
kind: Workspace
metadata:
  name: regression-test
spec:
  forProvider:
    module: git::https://github.com/ytsarev/provider-terraform-test-module.git//iam?ref=main
    source: Remote
    vars:
      - key: iamRole
        value: regression-test

k apply -f tf.yaml

k apply -f tf-regression.yaml

k get -f tf-regression.yaml
NAME              READY   SYNCED   AGE
regression-test   True    True     4m52s

k get -f tf.yaml
NAME                 READY   SYNCED   AGE
relative-path-test   True    True     4m52s
```



[contribution process]: https://git.io/fj2m9


Patch:
diff --git a/README.md b/README.md
index b9523dd..78bb43b 100644
--- a/README.md
+++ b/README.md
@@ -218,6 +218,43 @@ the terraform _apply_ and _destroy_ commands are run with the
 run with the "-no-color", "-input=false", and "-detailed-exitcode" arguments.  Arguments specified in
 applyArgs, destroyArgs and planArgs will be added to these default arguments.
 
+## Custom Entrypoint for Terraform Invocation
+
+In some cases, you might want to initialize and apply terraform in the
+subdirectory of the repository checkout. It is most relevant for the cases when
+your terraform modules contain inline [relative paths](#83).
+
+To enable it, the `Workspace` spec has an **optional** `Entrypoint` field.
+
+Consider this example:
+
+```yml
+apiVersion: tf.crossplane.io/v1alpha1
+kind: Workspace
+metadata:
+  name: relative-path-test
+spec:
+  forProvider:
+    module: git::https://github.com/ytsarev/provider-terraform-test-module.git
+    source: Remote
+    entrypoint: relative-path-iam
+    vars:
+      - key: iamRole
+        value: relative-path-test
+```
+
+In this case, the whole repository will be checked out but terraform will be
+initialized in the `relative-path-iam` subdirectory with the module that
+contains relative path reference to the `iam` module located in the root of the
+tree.
+
+```HCL
+module "relative-path-iam" {
+  source  = "../iam"
+  iamRole = var.iamRole
+}
+```
+
 ## Known limitations:
 
 * You must either use remote state or ensure the provider container's `/tf`
diff --git a/apis/v1alpha1/workspace_types.go b/apis/v1alpha1/workspace_types.go
index 0422d2e..18f479b 100644
--- a/apis/v1alpha1/workspace_types.go
+++ b/apis/v1alpha1/workspace_types.go
@@ -100,6 +100,11 @@ type WorkspaceParameters struct {
 	// Source of the root module of this workspace.
 	Source ModuleSource `json:"source"`
 
+	// Entrypoint for `terraform init` within the module
+	// +kubebuilder:default=""
+	// +optional
+	Entrypoint string `json:"entrypoint"`
+
 	// Configuration variables.
 	// +optional
 	Vars []Var `json:"vars,omitempty"`
diff --git a/internal/controller/workspace/workspace.go b/internal/controller/workspace/workspace.go
index 147fc19..59aa64b 100644
--- a/internal/controller/workspace/workspace.go
+++ b/internal/controller/workspace/workspace.go
@@ -20,6 +20,7 @@ import (
 	"context"
 	"os"
 	"path/filepath"
+	"strings"
 	"time"
 
 	"github.com/pkg/errors"
@@ -52,6 +53,7 @@ const (
 
 	errMkdir           = "cannot make Terraform configuration directory"
 	errRemoteModule    = "cannot get remote Terraform module"
+	errSetGitCredDir   = "cannot set GIT_CRED_DIR environment variable"
 	errWriteCreds      = "cannot write Terraform credentials"
 	errWriteGitCreds   = "cannot write .git-credentials to /tmp dir"
 	errWriteConfig     = "cannot write Terraform configuration " + tfConfig
@@ -183,7 +185,7 @@ func (c *connector) Connect(ctx context.Context, mg resource.Managed) (managed.E
 		// NOTE(ytsarev): Make go-getter pick up .git-credentials, see /.gitconfig in the container image
 		err = os.Setenv("GIT_CRED_DIR", gitCredDir)
 		if err != nil {
-			return nil, errors.Wrap(err, errRemoteModule)
+			return nil, errors.Wrap(err, errSetGitCredDir)
 		}
 		p := filepath.Clean(filepath.Join(gitCredDir, filepath.Base(cd.Filename)))
 		if err := c.fs.WriteFile(p, data, 0600); err != nil {
@@ -209,12 +211,18 @@ func (c *connector) Connect(ctx context.Context, mg resource.Managed) (managed.E
 		if err != nil {
 			return nil, errors.Wrap(err, errRemoteModule)
 		}
+
 	case v1alpha1.ModuleSourceInline:
 		if err := c.fs.WriteFile(filepath.Join(dir, tfMain), []byte(cr.Spec.ForProvider.Module), 0600); err != nil {
 			return nil, errors.Wrap(err, errWriteMain)
 		}
 	}
 
+	if len(cr.Spec.ForProvider.Entrypoint) > 0 {
+		entrypoint := strings.ReplaceAll(cr.Spec.ForProvider.Entrypoint, "../", "")
+		dir = filepath.Join(dir, entrypoint)
+	}
+
 	for _, cd := range pc.Spec.Credentials {
 		data, err := resource.CommonCredentialExtractor(ctx, cd.Source, c.kube, cd.CommonCredentialSelectors)
 		if err != nil {
diff --git a/package/crds/tf.crossplane.io_workspaces.yaml b/package/crds/tf.crossplane.io_workspaces.yaml
index 6bd0c91..ead139e 100644
--- a/package/crds/tf.crossplane.io_workspaces.yaml
+++ b/package/crds/tf.crossplane.io_workspaces.yaml
@@ -70,6 +70,10 @@ spec:
                     items:
                       type: string
                     type: array
+                  entrypoint:
+                    default: ""
+                    description: Entrypoint for `terraform init` within the module
+                    type: string
                   initArgs:
                     description: Arguments to be included in the terraform init CLI
                       command


Test patch:
diff --git a/internal/controller/workspace/workspace_test.go b/internal/controller/workspace/workspace_test.go
index 2d21e47..821d6c4 100644
--- a/internal/controller/workspace/workspace_test.go
+++ b/internal/controller/workspace/workspace_test.go
@@ -261,6 +261,50 @@ func TestConnect(t *testing.T) {
 			},
 			want: errors.Wrap(errBoom, errWriteCreds),
 		},
+		"WriteProviderConfigCredentialsEntrypointError": {
+			reason: "We should return any error encountered while writing our ProviderConfig credentials to a file with entrypoint subdir",
+			fields: fields{
+				kube: &test.MockClient{
+					MockGet: test.NewMockGetFn(nil, func(obj client.Object) error {
+						if pc, ok := obj.(*v1alpha1.ProviderConfig); ok {
+							pc.Spec.Credentials = []v1alpha1.ProviderCredentials{{
+								Filename: tfCreds,
+								Source:   xpv1.CredentialsSourceNone,
+							}}
+						}
+						return nil
+					}),
+				},
+				usage: resource.TrackerFn(func(_ context.Context, _ resource.Managed) error { return nil }),
+				fs: afero.Afero{
+					Fs: &ErrFs{
+						Fs:   afero.NewMemMapFs(),
+						errs: map[string]error{filepath.Join(tfDir, string(uid), "subdir", tfCreds): errBoom},
+					},
+				},
+				terraform: func(_ string) tfclient {
+					return &MockTf{
+						MockInit: func(ctx context.Context, o ...terraform.InitOption) error { return nil },
+					}
+				},
+			},
+			args: args{
+				mg: &v1alpha1.Workspace{
+					ObjectMeta: metav1.ObjectMeta{UID: uid},
+					Spec: v1alpha1.WorkspaceSpec{
+						ResourceSpec: xpv1.ResourceSpec{
+							ProviderConfigReference: &xpv1.Reference{},
+						},
+						ForProvider: v1alpha1.WorkspaceParameters{
+							Module:     "I'm HCL!",
+							Source:     v1alpha1.ModuleSourceInline,
+							Entrypoint: "subdir",
+						},
+					},
+				},
+			},
+			want: errors.Wrap(errBoom, errWriteCreds),
+		},
 		"WriteProviderGitCredentialsError": {
 			reason: "We should return any error encountered while writing our git credentials to a file",
 			fields: fields{
@@ -388,6 +432,48 @@ func TestConnect(t *testing.T) {
 			},
 			want: errors.Wrap(errBoom, errWriteConfig),
 		},
+		"WriteConfigEntrypointError": {
+			reason: "We should return any error encountered while writing our crossplane-provider-config.tf file to entrypoint subdir location",
+			fields: fields{
+				kube: &test.MockClient{
+					MockGet: test.NewMockGetFn(nil, func(obj client.Object) error {
+						if pc, ok := obj.(*v1alpha1.ProviderConfig); ok {
+							cfg := "I'm HCL!"
+							pc.Spec.Configuration = &cfg
+						}
+						return nil
+					}),
+				},
+				usage: resource.TrackerFn(func(_ context.Context, _ resource.Managed) error { return nil }),
+				fs: afero.Afero{
+					Fs: &ErrFs{
+						Fs:   afero.NewMemMapFs(),
+						errs: map[string]error{filepath.Join(tfDir, string(uid), "subdir", tfConfig): errBoom},
+					},
+				},
+				terraform: func(_ string) tfclient {
+					return &MockTf{
+						MockInit: func(ctx context.Context, o ...terraform.InitOption) error { return nil },
+					}
+				},
+			},
+			args: args{
+				mg: &v1alpha1.Workspace{
+					ObjectMeta: metav1.ObjectMeta{UID: uid},
+					Spec: v1alpha1.WorkspaceSpec{
+						ResourceSpec: xpv1.ResourceSpec{
+							ProviderConfigReference: &xpv1.Reference{},
+						},
+						ForProvider: v1alpha1.WorkspaceParameters{
+							Module:     "I'm HCL!",
+							Source:     v1alpha1.ModuleSourceInline,
+							Entrypoint: "subdir",
+						},
+					},
+				},
+			},
+			want: errors.Wrap(errBoom, errWriteConfig),
+		},
 		"WriteMainError": {
 			reason: "We should return any error encountered while writing our main.tf file",
 			fields: fields{