From d4b269cafa7b0ed3403394e26a1c113f2ea2bdef Mon Sep 17 00:00:00 2001 From: Louis Garman Date: Sat, 2 Jan 2021 11:20:48 +0000 Subject: [PATCH 1/2] Report terraform outputs in workspace status --- api/etok.dev/v1alpha1/workspace_types.go | 19 +++++++++ config/crd/bases/etok.dev_all.yaml | 16 ++++++++ config/crd/bases/etok.dev_workspaces.yaml | 16 ++++++++ pkg/controllers/testdata/tfstate.yaml | 18 ++++++++ pkg/controllers/workspace_controller.go | 43 ++++++++++++++++++++ pkg/controllers/workspace_controller_test.go | 35 ++++++++++++++++ pkg/testobj/k8s.go | 3 -- test/e2e/terraform_test.go | 8 ++++ 8 files changed, 155 insertions(+), 3 deletions(-) create mode 100644 pkg/controllers/testdata/tfstate.yaml diff --git a/api/etok.dev/v1alpha1/workspace_types.go b/api/etok.dev/v1alpha1/workspace_types.go index f16d413d..0e750f52 100644 --- a/api/etok.dev/v1alpha1/workspace_types.go +++ b/api/etok.dev/v1alpha1/workspace_types.go @@ -1,6 +1,8 @@ package v1alpha1 import ( + "fmt" + "github.com/leg100/etok/pkg/util/slice" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -100,6 +102,9 @@ type WorkspaceStatus struct { // True if resource has been reconciled at least once. Reconciled bool `json:"reconciled,omitempty"` + + // Outputs from state file + Outputs []*Output `json:"outputs,omitempty"` } // Variable denotes an input to the module @@ -115,6 +120,14 @@ type Variable struct { EnvironmentVariable bool `json:"environmentVariable,omitempty"` } +// Output outputs the values of Terraform output +type Output struct { + // Attribute name in module + Key string `json:"key"` + // Value + Value string `json:"value"` +} + func (ws *Workspace) IsReconciled() bool { return ws.Status.Reconciled } @@ -127,6 +140,12 @@ func (ws *Workspace) PVCName() string { return ws.Name } +// StateSecretName retrieves the name of the secret containing the terraform +// state for this workspace. +func (ws *Workspace) StateSecretName() string { + return fmt.Sprintf("tfstate-default-%s", ws.Name) +} + func (ws *Workspace) VariablesConfigMapName() string { return ws.Name + "-variables" } diff --git a/config/crd/bases/etok.dev_all.yaml b/config/crd/bases/etok.dev_all.yaml index 6c5a2962..af066004 100644 --- a/config/crd/bases/etok.dev_all.yaml +++ b/config/crd/bases/etok.dev_all.yaml @@ -333,6 +333,22 @@ spec: status: description: WorkspaceStatus defines the observed state of Workspace properties: + outputs: + description: Outputs from state file + items: + description: Output outputs the values of Terraform output + properties: + key: + description: Attribute name in module + type: string + value: + description: Value + type: string + required: + - key + - value + type: object + type: array phase: description: Lifecycle phase of workspace. type: string diff --git a/config/crd/bases/etok.dev_workspaces.yaml b/config/crd/bases/etok.dev_workspaces.yaml index 203c0fb7..ba2a88e5 100644 --- a/config/crd/bases/etok.dev_workspaces.yaml +++ b/config/crd/bases/etok.dev_workspaces.yaml @@ -197,6 +197,22 @@ spec: status: description: WorkspaceStatus defines the observed state of Workspace properties: + outputs: + description: Outputs from state file + items: + description: Output outputs the values of Terraform output + properties: + key: + description: Attribute name in module + type: string + value: + description: Value + type: string + required: + - key + - value + type: object + type: array phase: description: Lifecycle phase of workspace. type: string diff --git a/pkg/controllers/testdata/tfstate.yaml b/pkg/controllers/testdata/tfstate.yaml new file mode 100644 index 00000000..69e9ff1c --- /dev/null +++ b/pkg/controllers/testdata/tfstate.yaml @@ -0,0 +1,18 @@ +apiVersion: v1 +data: + tfstate: H4sIAAAAAAAA/3SS0a6iMBCG732Kptfi8SBH0cSLfYS92WTjMaTQASYLLZkO5pgN775pDyJq9oKk9Pvnn58Z/i6EkBcgh9bIg0iW/p2BSJWW2uxO5Hr1nqw2MggcEKpm0jdoQFXgVWmSlJu83ERr2OaRLtNd9J7G+2gH+zjfF+t8s9t+e9ieu56dPAifQQhJymjbZo4JTTVd+3iq6YN5+ZEmkYZS9Q1HpbX+CWZBxtcuqEaDcD0shBhCOwJneyrANzwFNvm3VofCVhlVgX51HJPhDBnVBsTg+H7bkb2gBvLkdj59SoIKHdN1NQ12hfatVq7GwlL39u3/Kc93JzSOlZnHnUcOElfU0KrZitbLOVbMhHnP4GazHFm+TTLH2sfc//55lMtX3FMz4hd6ZcgaMBXX8iDiJ6qh8HXbOP2Inytr+Lqt8Rmh/k+3PwAdkP8G0zfNE+wISvwa2QwND5NwYBwyXiB7mMnp/KDqCC+Kw1Jz88vlP45HOfFhPJ2nv+q8GBb/AgAA//+GknaoPQMAAA== +kind: Secret +metadata: + annotations: + encoding: gzip + labels: + app.kubernetes.io/managed-by: terraform + tfstate: "true" + tfstateSecretSuffix: foo + tfstateWorkspace: default + manager: HashiCorp + operation: Update + time: "2020-12-31T18:32:26Z" + name: tfstate-default-foo + namespace: default +type: Opaque diff --git a/pkg/controllers/workspace_controller.go b/pkg/controllers/workspace_controller.go index 4f0a35ad..d5ddb8ab 100644 --- a/pkg/controllers/workspace_controller.go +++ b/pkg/controllers/workspace_controller.go @@ -1,7 +1,10 @@ package controllers import ( + "bytes" + "compress/gzip" "context" + "encoding/json" "reflect" "regexp" "strings" @@ -105,6 +108,46 @@ func (r *WorkspaceReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( return r.success(ctx, &ws) } + // Retrieve state file + var state corev1.Secret + if err := r.Get(ctx, types.NamespacedName{Namespace: req.Namespace, Name: ws.StateSecretName()}, &state); err != nil { + // Ignore not found errors and keep on reconciling - the state file + // might not yet have been created + if !errors.IsNotFound(err) { + log.Error(err, "unable to get state secret") + return ctrl.Result{}, err + } + } else { + // State file exists + if val, ok := state.Data["tfstate"]; ok { + gr, err := gzip.NewReader(bytes.NewBuffer(val)) + if err != nil { + log.Error(err, "unable to decompress state file") + return ctrl.Result{}, err + } + var sfile struct { + Outputs map[string]struct { + Type string + Value string + } + } + if err := json.NewDecoder(gr).Decode(&sfile); err != nil { + log.Error(err, "unable to decode state file") + return ctrl.Result{}, err + } + var outputs []*v1alpha1.Output + for k, v := range sfile.Outputs { + outputs = append(outputs, &v1alpha1.Output{Key: k, Value: v.Value}) + } + if !reflect.DeepEqual(ws.Status.Outputs, outputs) { + ws.Status.Outputs = outputs + if err := r.Status().Update(ctx, &ws); err != nil { + return ctrl.Result{}, err + } + } + } + } + // Manage ConfigMap containing variables for workspace var variables corev1.ConfigMap if err := r.Get(ctx, types.NamespacedName{Namespace: req.Namespace, Name: ws.VariablesConfigMapName()}, &variables); err != nil { diff --git a/pkg/controllers/workspace_controller_test.go b/pkg/controllers/workspace_controller_test.go index 3a738461..cbdf29b1 100644 --- a/pkg/controllers/workspace_controller_test.go +++ b/pkg/controllers/workspace_controller_test.go @@ -2,6 +2,7 @@ package controllers import ( "context" + "os" "testing" v1alpha1 "github.com/leg100/etok/api/etok.dev/v1alpha1" @@ -12,6 +13,7 @@ import ( corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/yaml" "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/reconcile" ) @@ -329,3 +331,36 @@ func TestReconcileWorkspaceVariables(t *testing.T) { }) } } + +func TestReconcileWorkspaceState(t *testing.T) { + var state corev1.Secret + var workspace = testobj.Workspace("default", "foo") + + f, err := os.Open("testdata/tfstate.yaml") + require.NoError(t, yaml.NewYAMLOrJSONDecoder(f, 999).Decode(&state)) + + cl := fake.NewFakeClientWithScheme(scheme.Scheme, workspace, &state) + + r := NewWorkspaceReconciler(cl, "a.b.c.d:v1") + + req := reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: workspace.Name, + Namespace: workspace.Namespace, + }, + } + _, err = r.Reconcile(context.Background(), req) + require.NoError(t, err) + + // Fetch fresh workspace for assertions + ws := &v1alpha1.Workspace{} + require.NoError(t, r.Get(context.TODO(), req.NamespacedName, ws)) + + assert.Equal(t, []*v1alpha1.Output{ + { + Key: "random_string", + Value: "f584-default-foo-foo", + }, + }, ws.Status.Outputs) + +} diff --git a/pkg/testobj/k8s.go b/pkg/testobj/k8s.go index 8169c941..a6dc6df6 100644 --- a/pkg/testobj/k8s.go +++ b/pkg/testobj/k8s.go @@ -237,9 +237,6 @@ func Secret(namespace, name string, opts ...func(*corev1.Secret)) *corev1.Secret Name: name, Namespace: namespace, }, - StringData: map[string]string{ - "google_application_credentials.json": "abc", - }, } for _, o := range opts { o(secret) diff --git a/test/e2e/terraform_test.go b/test/e2e/terraform_test.go index 91f970fa..53381c5d 100644 --- a/test/e2e/terraform_test.go +++ b/test/e2e/terraform_test.go @@ -79,6 +79,8 @@ func TestTerraform(t *testing.T) { })) }) + // Check that both `etok output` works and that the workspace resource + // has the correct output in its status t.Run("output", func(t *testing.T) { require.NoError(t, step(t, name, []string{buildPath, "output", @@ -88,6 +90,12 @@ func TestTerraform(t *testing.T) { []expect.Batcher{ &expect.BExp{R: `random_string = "[0-9a-f]{4}-bar-e2e-terraform-foo"`}, })) + + ws, err := client.WorkspacesClient(namespace).Get(context.Background(), "foo", metav1.GetOptions{}) + require.NoError(t, err) + + require.Equal(t, "random_string", ws.Status.Outputs[0].Key) + require.Regexp(t, `[0-9a-f]{4}-bar-e2e-terraform-foo`, ws.Status.Outputs[0].Value) }) t.Run("destroy", func(t *testing.T) { From aaaa335508e12264f134779347c15b7d3d775a4a Mon Sep 17 00:00:00 2001 From: Louis Garman Date: Sat, 2 Jan 2021 11:27:08 +0000 Subject: [PATCH 2/2] Add comment --- pkg/controllers/workspace_controller.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/controllers/workspace_controller.go b/pkg/controllers/workspace_controller.go index d5ddb8ab..23323068 100644 --- a/pkg/controllers/workspace_controller.go +++ b/pkg/controllers/workspace_controller.go @@ -125,6 +125,8 @@ func (r *WorkspaceReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( log.Error(err, "unable to decompress state file") return ctrl.Result{}, err } + + // Persist outputs from state file to workspace status var sfile struct { Outputs map[string]struct { Type string