diff --git a/pkg/terraform/files.go b/pkg/terraform/files.go index 8f63793c..2b73067b 100644 --- a/pkg/terraform/files.go +++ b/pkg/terraform/files.go @@ -7,6 +7,7 @@ package terraform import ( "context" "fmt" + iofs "io/fs" "path/filepath" "strings" @@ -19,6 +20,20 @@ import ( "github.com/upbound/upjet/pkg/resource/json" ) +const ( + errWriteTFStateFile = "cannot write terraform.tfstate file" + errWriteMainTFFile = "cannot write main.tf.json file" + errCheckIfStateEmpty = "cannot check whether the state is empty" + errGetID = "cannot get id" + errMarshalAttributes = "cannot marshal produced state attributes" + errInsertTimeouts = "cannot insert timeouts metadata to private raw" + errReadTFState = "cannot read terraform.tfstate file" + errMarshalState = "cannot marshal state object" + errUnmarshalAttr = "cannot unmarshal state attributes" + errUnmarshalTFState = "cannot unmarshal tfstate file" + errFmtNonString = "cannot work with a non-string id: %s" +) + // FileProducerOption allows you to configure FileProducer type FileProducerOption func(*FileProducer) @@ -76,9 +91,58 @@ type FileProducer struct { fs afero.Afero } -// WriteTFState writes the Terraform state that should exist in the filesystem to -// start any Terraform operation. -func (fp *FileProducer) WriteTFState(ctx context.Context) error { +// WriteMainTF writes the content main configuration file that has the desired +// state configuration for Terraform. +func (fp *FileProducer) WriteMainTF() error { + // If the resource is in a deletion process, we need to remove the deletion + // protection. + fp.parameters["lifecycle"] = map[string]bool{ + "prevent_destroy": !meta.WasDeleted(fp.Resource), + } + + // Add operation timeouts if any timeout configured for the resource + if tp := timeouts(fp.Config.OperationTimeouts).asParameter(); len(tp) != 0 { + fp.parameters["timeouts"] = tp + } + + // Note(turkenh): To use third party providers, we need to configure + // provider name in required_providers. + providerSource := strings.Split(fp.Setup.Requirement.Source, "/") + m := map[string]any{ + "terraform": map[string]any{ + "required_providers": map[string]any{ + providerSource[len(providerSource)-1]: map[string]string{ + "source": fp.Setup.Requirement.Source, + "version": fp.Setup.Requirement.Version, + }, + }, + }, + "provider": map[string]any{ + providerSource[len(providerSource)-1]: fp.Setup.Configuration, + }, + "resource": map[string]any{ + fp.Resource.GetTerraformResourceType(): map[string]any{ + fp.Resource.GetName(): fp.parameters, + }, + }, + } + rawMainTF, err := json.JSParser.Marshal(m) + if err != nil { + return errors.Wrap(err, "cannot marshal main hcl object") + } + return errors.Wrap(fp.fs.WriteFile(filepath.Join(fp.Dir, "main.tf.json"), rawMainTF, 0600), errWriteMainTFFile) +} + +// EnsureTFState writes the Terraform state that should exist in the filesystem +// to start any Terraform operation. +func (fp *FileProducer) EnsureTFState(ctx context.Context) error { + empty, err := fp.isStateEmpty() + if err != nil { + return errors.Wrap(err, errCheckIfStateEmpty) + } + if !empty { + return nil + } base := make(map[string]any) // NOTE(muvaf): Since we try to produce the current state, observation // takes precedence over parameters. @@ -90,19 +154,19 @@ func (fp *FileProducer) WriteTFState(ctx context.Context) error { } id, err := fp.Config.ExternalName.GetIDFn(ctx, meta.GetExternalName(fp.Resource), fp.parameters, fp.Setup.Map()) if err != nil { - return errors.Wrap(err, "cannot get id") + return errors.Wrap(err, errGetID) } base["id"] = id attr, err := json.JSParser.Marshal(base) if err != nil { - return errors.Wrap(err, "cannot marshal produced state attributes") + return errors.Wrap(err, errMarshalAttributes) } var privateRaw []byte if pr, ok := fp.Resource.GetAnnotations()[resource.AnnotationKeyPrivateRawAttribute]; ok { privateRaw = []byte(pr) } if privateRaw, err = insertTimeoutsMeta(privateRaw, timeouts(fp.Config.OperationTimeouts)); err != nil { - return errors.Wrap(err, "cannot insert timeouts metadata to private raw") + return errors.Wrap(err, errInsertTimeouts) } s := json.NewStateV4() s.TerraformVersion = fp.Setup.Version @@ -127,49 +191,39 @@ func (fp *FileProducer) WriteTFState(ctx context.Context) error { rawState, err := json.JSParser.Marshal(s) if err != nil { - return errors.Wrap(err, "cannot marshal state object") + return errors.Wrap(err, errMarshalState) } - return errors.Wrap(fp.fs.WriteFile(filepath.Join(fp.Dir, "terraform.tfstate"), rawState, 0600), "cannot write tfstate file") + return errors.Wrap(fp.fs.WriteFile(filepath.Join(fp.Dir, "terraform.tfstate"), rawState, 0600), errWriteTFStateFile) } -// WriteMainTF writes the content main configuration file that has the desired -// state configuration for Terraform. -func (fp *FileProducer) WriteMainTF() error { - // If the resource is in a deletion process, we need to remove the deletion - // protection. - fp.parameters["lifecycle"] = map[string]bool{ - "prevent_destroy": !meta.WasDeleted(fp.Resource), +// isStateEmpty returns whether the Terraform state includes a resource or not. +func (fp *FileProducer) isStateEmpty() (bool, error) { + data, err := fp.fs.ReadFile(filepath.Join(fp.Dir, "terraform.tfstate")) + if errors.Is(err, iofs.ErrNotExist) { + return true, nil } - - // Add operation timeouts if any timeout configured for the resource - if tp := timeouts(fp.Config.OperationTimeouts).asParameter(); len(tp) != 0 { - fp.parameters["timeouts"] = tp + if err != nil { + return false, errors.Wrap(err, errReadTFState) } - - // Note(turkenh): To use third party providers, we need to configure - // provider name in required_providers. - providerSource := strings.Split(fp.Setup.Requirement.Source, "/") - m := map[string]any{ - "terraform": map[string]any{ - "required_providers": map[string]any{ - providerSource[len(providerSource)-1]: map[string]string{ - "source": fp.Setup.Requirement.Source, - "version": fp.Setup.Requirement.Version, - }, - }, - }, - "provider": map[string]any{ - providerSource[len(providerSource)-1]: fp.Setup.Configuration, - }, - "resource": map[string]any{ - fp.Resource.GetTerraformResourceType(): map[string]any{ - fp.Resource.GetName(): fp.parameters, - }, - }, + s := &json.StateV4{} + if err := json.JSParser.Unmarshal(data, s); err != nil { + return false, errors.Wrap(err, errUnmarshalTFState) } - rawMainTF, err := json.JSParser.Marshal(m) - if err != nil { - return errors.Wrap(err, "cannot marshal main hcl object") + attrData := s.GetAttributes() + if attrData == nil { + return true, nil + } + attr := map[string]any{} + if err := json.JSParser.Unmarshal(attrData, &attr); err != nil { + return false, errors.Wrap(err, errUnmarshalAttr) + } + id, ok := attr["id"] + if !ok { + return true, nil + } + sid, ok := id.(string) + if !ok { + return false, errors.Errorf(errFmtNonString, fmt.Sprint(id)) } - return errors.Wrap(fp.fs.WriteFile(filepath.Join(fp.Dir, "main.tf.json"), rawMainTF, 0600), "cannot write tfstate file") + return sid == "", nil } diff --git a/pkg/terraform/files_test.go b/pkg/terraform/files_test.go index e112f552..2e3a4d88 100644 --- a/pkg/terraform/files_test.go +++ b/pkg/terraform/files_test.go @@ -6,6 +6,7 @@ package terraform import ( "context" + "fmt" "path/filepath" "testing" "time" @@ -14,23 +15,26 @@ import ( xpfake "github.com/crossplane/crossplane-runtime/pkg/resource/fake" "github.com/crossplane/crossplane-runtime/pkg/test" "github.com/google/go-cmp/cmp" + "github.com/pkg/errors" "github.com/spf13/afero" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "github.com/upbound/upjet/pkg/config" "github.com/upbound/upjet/pkg/resource" "github.com/upbound/upjet/pkg/resource/fake" + "github.com/upbound/upjet/pkg/resource/json" ) const ( dir = "random-dir" ) -func TestWriteTFState(t *testing.T) { +func TestEnsureTFState(t *testing.T) { type args struct { tr resource.Terraformed cfg *config.Resource s Setup + fs afero.Afero } type want struct { tfstate string @@ -41,8 +45,8 @@ func TestWriteTFState(t *testing.T) { args want }{ - "Success": { - reason: "Standard resources should be able to write everything it has into tfstate file", + "SuccessWrite": { + reason: "Standard resources should be able to write everything it has into tfstate file when state is empty", args: args{ tr: &fake.Terraformed{ Managed: xpfake.Managed{ @@ -61,6 +65,7 @@ func TestWriteTFState(t *testing.T) { }}, }, cfg: config.DefaultResource("upjet_resource", nil, nil), + fs: afero.Afero{Fs: afero.NewMemMapFs()}, }, want: want{ tfstate: `{"version":4,"terraform_version":"","serial":1,"lineage":"","outputs":null,"resources":[{"mode":"managed","type":"","name":"","provider":"provider[\"registry.terraform.io/\"]","instances":[{"schema_version":0,"attributes":{"id":"some-id","name":"some-id","obs":"obsval","param":"paramval"},"private":"cHJpdmF0ZXJhdw=="}]}]}`, @@ -88,6 +93,7 @@ func TestWriteTFState(t *testing.T) { cfg: config.DefaultResource("upjet_resource", nil, nil, func(r *config.Resource) { r.OperationTimeouts.Read = 2 * time.Minute }), + fs: afero.Afero{Fs: afero.NewMemMapFs()}, }, want: want{ tfstate: `{"version":4,"terraform_version":"","serial":1,"lineage":"","outputs":null,"resources":[{"mode":"managed","type":"","name":"","provider":"provider[\"registry.terraform.io/\"]","instances":[{"schema_version":0,"attributes":{"id":"some-id","name":"some-id","obs":"obsval","param":"paramval"},"private":"eyJlMmJmYjczMC1lY2FhLTExZTYtOGY4OC0zNDM2M2JjN2M0YzAiOnsicmVhZCI6MTIwMDAwMDAwMDAwfX0="}]}]}`, @@ -96,17 +102,16 @@ func TestWriteTFState(t *testing.T) { } for name, tc := range cases { t.Run(name, func(t *testing.T) { - fs := afero.NewMemMapFs() ctx := context.TODO() - fp, err := NewFileProducer(ctx, nil, dir, tc.args.tr, tc.args.s, tc.args.cfg, WithFileSystem(fs)) + fp, err := NewFileProducer(ctx, nil, dir, tc.args.tr, tc.args.s, tc.args.cfg, WithFileSystem(tc.args.fs)) if err != nil { t.Errorf("cannot initialize a file producer: %s", err.Error()) } - err = fp.WriteTFState(ctx) + err = fp.EnsureTFState(ctx) if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { t.Errorf("\n%s\nWriteTFState(...): -want error, +got error:\n%s", tc.reason, diff) } - s, _ := afero.Afero{Fs: fs}.ReadFile(filepath.Join(dir, "terraform.tfstate")) + s, _ := tc.args.fs.ReadFile(filepath.Join(dir, "terraform.tfstate")) if diff := cmp.Diff(tc.want.tfstate, string(s)); diff != "" { t.Errorf("\n%s\nWriteTFState(...): -want tfstate, +got tfstate:\n%s", tc.reason, diff) } @@ -114,6 +119,139 @@ func TestWriteTFState(t *testing.T) { } } +func TestIsStateEmpty(t *testing.T) { + type args struct { + fs func() afero.Afero + } + type want struct { + empty bool + err error + } + cases := map[string]struct { + reason string + args + want + }{ + "FileDoesNotExist": { + reason: "If the tfstate file is not there, it should return true.", + args: args{ + fs: func() afero.Afero { + return afero.Afero{Fs: afero.NewMemMapFs()} + }, + }, + want: want{ + empty: true, + }, + }, + "NoAttributes": { + reason: "If there is no attributes, that means the state is empty.", + args: args{ + fs: func() afero.Afero { + f := afero.Afero{Fs: afero.NewMemMapFs()} + s := json.NewStateV4() + s.Resources = []json.ResourceStateV4{} + d, _ := json.JSParser.Marshal(s) + _ = f.WriteFile(filepath.Join(dir, "terraform.tfstate"), d, 0600) + return f + }, + }, + want: want{ + empty: true, + }, + }, + "NoID": { + reason: "If there is no ID in the state, that means state is empty", + args: args{ + fs: func() afero.Afero { + f := afero.Afero{Fs: afero.NewMemMapFs()} + s := json.NewStateV4() + s.Resources = []json.ResourceStateV4{ + { + Instances: []json.InstanceObjectStateV4{ + { + AttributesRaw: []byte(`{}`), + }, + }, + }, + } + d, _ := json.JSParser.Marshal(s) + _ = f.WriteFile(filepath.Join(dir, "terraform.tfstate"), d, 0600) + return f + }, + }, + want: want{ + empty: true, + }, + }, + "NonStringID": { + reason: "If the ID is there but not string, return true.", + args: args{ + fs: func() afero.Afero { + f := afero.Afero{Fs: afero.NewMemMapFs()} + s := json.NewStateV4() + s.Resources = []json.ResourceStateV4{ + { + Instances: []json.InstanceObjectStateV4{ + { + AttributesRaw: []byte(`{"id": 0}`), + }, + }, + }, + } + d, _ := json.JSParser.Marshal(s) + _ = f.WriteFile(filepath.Join(dir, "terraform.tfstate"), d, 0600) + return f + }, + }, + want: want{ + err: errors.Errorf(errFmtNonString, fmt.Sprint(0)), + }, + }, + "NotEmpty": { + reason: "If there is a string ID at minimum, state file is workable", + args: args{ + fs: func() afero.Afero { + f := afero.Afero{Fs: afero.NewMemMapFs()} + s := json.NewStateV4() + s.Resources = []json.ResourceStateV4{ + { + Instances: []json.InstanceObjectStateV4{ + { + AttributesRaw: []byte(`{"id": "someid"}`), + }, + }, + }, + } + d, _ := json.JSParser.Marshal(s) + _ = f.WriteFile(filepath.Join(dir, "terraform.tfstate"), d, 0600) + return f + }, + }, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + fp, _ := NewFileProducer( + context.TODO(), + nil, + dir, + &fake.Terraformed{ + Parameterizable: fake.Parameterizable{Parameters: map[string]any{}}, + }, + Setup{}, + config.DefaultResource("upjet_resource", nil, nil), WithFileSystem(tc.args.fs()), + ) + empty, err := fp.isStateEmpty() + if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { + t.Errorf("\n%s\nisStateEmpty(...): -want error, +got error:\n%s", tc.reason, diff) + } + if diff := cmp.Diff(tc.want.empty, empty); diff != "" { + t.Errorf("\n%s\nisStateEmpty(...): -want empty, +got empty:\n%s", tc.reason, diff) + } + }) + } +} + func TestWriteMainTF(t *testing.T) { type args struct { tr resource.Terraformed diff --git a/pkg/terraform/store.go b/pkg/terraform/store.go index 04751b70..eb73f68e 100644 --- a/pkg/terraform/store.go +++ b/pkg/terraform/store.go @@ -138,34 +138,33 @@ func (ws *WorkspaceStore) Workspace(ctx context.Context, c resource.SecretClient if err := ws.fs.MkdirAll(dir, os.ModePerm); err != nil { return nil, errors.Wrap(err, "cannot create directory for workspace") } + ws.mu.Lock() + w, ok := ws.store[tr.GetUID()] + if !ok { + l := ws.logger.WithValues("workspace", dir) + ws.store[tr.GetUID()] = NewWorkspace(dir, WithLogger(l), WithExecutor(ws.executor), WithFilterFn(ts.filterSensitiveInformation)) + w = ws.store[tr.GetUID()] + } + ws.mu.Unlock() + // If there is an ongoing operation, no changes should be made in the + // workspace files. + if w.LastOperation.IsRunning() { + return w, nil + } fp, err := NewFileProducer(ctx, c, dir, tr, ts, cfg) if err != nil { return nil, errors.Wrap(err, "cannot create a new file producer") } - _, err = ws.fs.Stat(filepath.Join(fp.Dir, "terraform.tfstate")) - if xpresource.Ignore(os.IsNotExist, err) != nil { - return nil, errors.Wrap(err, "cannot stat terraform.tfstate file") - } - if os.IsNotExist(err) { - if err := fp.WriteTFState(ctx); err != nil { - return nil, errors.Wrap(err, "cannot reproduce tfstate file") - } + if err := fp.EnsureTFState(ctx); err != nil { + return nil, errors.Wrap(err, "cannot ensure tfstate file") } if err := fp.WriteMainTF(); err != nil { return nil, errors.Wrap(err, "cannot write main tf file") } - l := ws.logger.WithValues("workspace", dir) attachmentConfig, err := ws.providerRunner.Start() if err != nil { return nil, err } - ws.mu.Lock() - w, ok := ws.store[tr.GetUID()] - if !ok { - ws.store[tr.GetUID()] = NewWorkspace(dir, WithLogger(l), WithExecutor(ws.executor), WithFilterFn(ts.filterSensitiveInformation)) - w = ws.store[tr.GetUID()] - } - ws.mu.Unlock() _, err = ws.fs.Stat(filepath.Join(dir, ".terraform.lock.hcl")) if xpresource.Ignore(os.IsNotExist, err) != nil { return nil, errors.Wrap(err, "cannot stat init lock file") @@ -179,7 +178,7 @@ func (ws *WorkspaceStore) Workspace(ctx context.Context, c resource.SecretClient cmd := w.executor.CommandContext(ctx, "terraform", "init", "-input=false") cmd.SetDir(w.dir) out, err := cmd.CombinedOutput() - l.Debug("init ended", "out", string(out)) + w.logger.Debug("init ended", "out", string(out)) return w, errors.Wrapf(err, "cannot init workspace: %s", string(out)) }