github.com/crossplane/upjet@v1.3.0/pkg/terraform/store.go (about) 1 // SPDX-FileCopyrightText: 2023 The Crossplane Authors <https://crossplane.io> 2 // 3 // SPDX-License-Identifier: Apache-2.0 4 5 package terraform 6 7 import ( 8 "context" 9 "crypto/sha256" 10 "fmt" 11 "os" 12 "path/filepath" 13 "sort" 14 "strings" 15 "sync" 16 "time" 17 18 "github.com/crossplane/crossplane-runtime/pkg/feature" 19 "github.com/crossplane/crossplane-runtime/pkg/logging" 20 "github.com/crossplane/crossplane-runtime/pkg/meta" 21 xpresource "github.com/crossplane/crossplane-runtime/pkg/resource" 22 fwprovider "github.com/hashicorp/terraform-plugin-framework/provider" 23 "github.com/mitchellh/go-ps" 24 "github.com/pkg/errors" 25 "github.com/spf13/afero" 26 "k8s.io/apimachinery/pkg/types" 27 "k8s.io/utils/exec" 28 "sigs.k8s.io/controller-runtime/pkg/client" 29 30 "github.com/crossplane/upjet/pkg/config" 31 "github.com/crossplane/upjet/pkg/metrics" 32 "github.com/crossplane/upjet/pkg/resource" 33 ) 34 35 const ( 36 errGetID = "cannot get id" 37 ) 38 39 // SetupFn is a function that returns Terraform setup which contains 40 // provider requirement, configuration and Terraform version. 41 type SetupFn func(ctx context.Context, client client.Client, mg xpresource.Managed) (Setup, error) 42 43 // ProviderRequirement holds values for the Terraform HCL setup requirements 44 type ProviderRequirement struct { 45 // Source of the provider. An example value is "hashicorp/aws". 46 Source string 47 48 // Version of the provider. An example value is "4.0" 49 Version string 50 } 51 52 // ProviderConfiguration holds the setup configuration body 53 type ProviderConfiguration map[string]any 54 55 // ToProviderHandle converts a provider configuration to a handle 56 // for the provider scheduler. 57 func (pc ProviderConfiguration) ToProviderHandle() (ProviderHandle, error) { 58 h := strings.Join(getSortedKeyValuePairs("", pc), ",") 59 hash := sha256.New() 60 if _, err := hash.Write([]byte(h)); err != nil { 61 return InvalidProviderHandle, errors.Wrap(err, "cannot convert provider configuration to scheduler handle") 62 } 63 return ProviderHandle(fmt.Sprintf("%x", hash.Sum(nil))), nil 64 } 65 66 func getSortedKeyValuePairs(parent string, m map[string]any) []string { 67 result := make([]string, 0, len(m)) 68 sortedKeys := make([]string, 0, len(m)) 69 for k := range m { 70 sortedKeys = append(sortedKeys, k) 71 } 72 sort.Strings(sortedKeys) 73 for _, k := range sortedKeys { 74 v := m[k] 75 switch t := v.(type) { 76 case []string: 77 result = append(result, fmt.Sprintf("%q:%q", parent+k, strings.Join(t, ","))) 78 case map[string]any: 79 result = append(result, getSortedKeyValuePairs(parent+k+".", t)...) 80 case []map[string]any: 81 cArr := make([]string, 0, len(t)) 82 for i, e := range t { 83 cArr = append(cArr, getSortedKeyValuePairs(fmt.Sprintf("%s%s[%d].", parent, k, i), e)...) 84 } 85 result = append(result, fmt.Sprintf("%q:%q", parent+k, strings.Join(cArr, ","))) 86 case *string: 87 if t != nil { 88 result = append(result, fmt.Sprintf("%q:%q", parent+k, *t)) 89 } 90 default: 91 result = append(result, fmt.Sprintf("%q:%q", parent+k, t)) 92 } 93 } 94 return result 95 } 96 97 // Setup holds values for the Terraform version and setup 98 // requirements and configuration body 99 type Setup struct { 100 // Version is the version of Terraform that this workspace would require as 101 // minimum. 102 Version string 103 104 // Requirement contains the provider requirements of the workspace to work, 105 // which is mostly the version and source of the provider. 106 Requirement ProviderRequirement 107 108 // Configuration contains the provider configuration parameters of the given 109 // Terraform provider, such as access token. 110 Configuration ProviderConfiguration 111 112 // ClientMetadata contains arbitrary metadata that the provider would like 113 // to pass but not available as part of Terraform's provider configuration. 114 // For example, AWS account id is needed for certain ID calculations but is 115 // not part of the Terraform AWS Provider configuration, so it could be 116 // made available only by this map. 117 ClientMetadata map[string]string 118 119 // Scheduler specifies the provider scheduler to be used for the Terraform 120 // workspace being setup. If not set, no scheduler is configured and 121 // the lifecycle of Terraform provider processes will be managed by 122 // the Terraform CLI. 123 Scheduler ProviderScheduler 124 125 Meta any 126 127 FrameworkProvider fwprovider.Provider 128 } 129 130 // Map returns the Setup object in map form. The initial reason was so that 131 // we don't import the terraform package in places where GetIDFn is overridden 132 // because it can cause circular dependency. 133 func (s Setup) Map() map[string]any { 134 return map[string]any{ 135 "version": s.Version, 136 "requirement": map[string]string{ 137 "source": s.Requirement.Source, 138 "version": s.Requirement.Version, 139 }, 140 "configuration": s.Configuration, 141 "client_metadata": s.ClientMetadata, 142 } 143 } 144 145 // WorkspaceStoreOption lets you configure the workspace store. 146 type WorkspaceStoreOption func(*WorkspaceStore) 147 148 // WithFs lets you set the fs of WorkspaceStore. Used mostly for testing. 149 func WithFs(fs afero.Fs) WorkspaceStoreOption { 150 return func(ws *WorkspaceStore) { 151 ws.fs = afero.Afero{Fs: fs} 152 } 153 } 154 155 // WithProcessReportInterval enables the upjet.terraform.running_processes 156 // metric, which periodically reports the total number of Terraform CLI and 157 // Terraform provider processes in the system. 158 func WithProcessReportInterval(d time.Duration) WorkspaceStoreOption { 159 return func(ws *WorkspaceStore) { 160 ws.processReportInterval = d 161 } 162 } 163 164 // WithDisableInit disables `terraform init` invocations in case 165 // workspace initialization is not needed (e.g., when using the 166 // shared gRPC server runtime). 167 func WithDisableInit(disable bool) WorkspaceStoreOption { 168 return func(ws *WorkspaceStore) { 169 ws.disableInit = disable 170 } 171 } 172 173 // WithFeatures sets the features of the workspace store. 174 func WithFeatures(f *feature.Flags) WorkspaceStoreOption { 175 return func(ws *WorkspaceStore) { 176 ws.features = f 177 } 178 } 179 180 // NewWorkspaceStore returns a new WorkspaceStore. 181 func NewWorkspaceStore(l logging.Logger, opts ...WorkspaceStoreOption) *WorkspaceStore { 182 ws := &WorkspaceStore{ 183 store: map[types.UID]*Workspace{}, 184 logger: l, 185 mu: sync.Mutex{}, 186 fs: afero.Afero{Fs: afero.NewOsFs()}, 187 executor: exec.New(), 188 features: &feature.Flags{}, 189 } 190 for _, f := range opts { 191 f(ws) 192 } 193 ws.initMetrics() 194 if ws.processReportInterval != 0 { 195 go ws.reportTFProcesses(ws.processReportInterval) 196 } 197 return ws 198 } 199 200 // WorkspaceStore allows you to manage multiple Terraform workspaces. 201 type WorkspaceStore struct { 202 // store holds information about ongoing operations of given resource. 203 // Since there can be multiple calls that add/remove values from the map at 204 // the same time, it has to be safe for concurrency since those operations 205 // cause rehashing in some cases. 206 store map[types.UID]*Workspace 207 logger logging.Logger 208 mu sync.Mutex 209 processReportInterval time.Duration 210 fs afero.Afero 211 executor exec.Interface 212 disableInit bool 213 features *feature.Flags 214 } 215 216 // Workspace makes sure the Terraform workspace for the given resource is ready 217 // to be used and returns the Workspace object configured to work in that 218 // workspace folder in the filesystem. 219 func (ws *WorkspaceStore) Workspace(ctx context.Context, c resource.SecretClient, tr resource.Terraformed, ts Setup, cfg *config.Resource) (*Workspace, error) { //nolint:gocyclo 220 dir := filepath.Join(ws.fs.GetTempDir(""), string(tr.GetUID())) 221 if err := ws.fs.MkdirAll(dir, os.ModePerm); err != nil { 222 return nil, errors.Wrap(err, "cannot create directory for workspace") 223 } 224 ws.mu.Lock() 225 w, ok := ws.store[tr.GetUID()] 226 if !ok { 227 l := ws.logger.WithValues("workspace", dir) 228 ws.store[tr.GetUID()] = NewWorkspace(dir, WithLogger(l), WithExecutor(ws.executor), WithFilterFn(ts.filterSensitiveInformation)) 229 w = ws.store[tr.GetUID()] 230 } 231 ws.mu.Unlock() 232 // If there is an ongoing operation, no changes should be made in the 233 // workspace files. 234 if w.LastOperation.IsRunning() { 235 return w, nil 236 } 237 fp, err := NewFileProducer(ctx, c, dir, tr, ts, cfg, WithFileProducerFeatures(ws.features)) 238 if err != nil { 239 return nil, errors.Wrap(err, "cannot create a new file producer") 240 } 241 242 w.terraformID, err = fp.Config.ExternalName.GetIDFn(ctx, meta.GetExternalName(fp.Resource), fp.parameters, fp.Setup.Map()) 243 if err != nil { 244 return nil, errors.Wrap(err, errGetID) 245 } 246 247 if err := fp.EnsureTFState(ctx, w.terraformID); err != nil { 248 return nil, errors.Wrap(err, "cannot ensure tfstate file") 249 } 250 251 isNeedProviderUpgrade := false 252 if !ws.disableInit { 253 isNeedProviderUpgrade, err = fp.needProviderUpgrade() 254 if err != nil { 255 return nil, errors.Wrap(err, "cannot check if a Terraform dependency update is required") 256 } 257 } 258 259 if w.ProviderHandle, err = fp.WriteMainTF(); err != nil { 260 return nil, errors.Wrap(err, "cannot write main tf file") 261 } 262 if isNeedProviderUpgrade { 263 out, err := w.runTF(ctx, ModeSync, "init", "-upgrade", "-input=false") 264 w.logger.Debug("init -upgrade ended", "out", ts.filterSensitiveInformation(string(out))) 265 if err != nil { 266 return w, errors.Wrapf(err, "cannot upgrade workspace: %s", ts.filterSensitiveInformation(string(out))) 267 } 268 } 269 if ws.disableInit { 270 return w, nil 271 } 272 _, err = ws.fs.Stat(filepath.Join(dir, ".terraform.lock.hcl")) 273 if xpresource.Ignore(os.IsNotExist, err) != nil { 274 return nil, errors.Wrap(err, "cannot stat init lock file") 275 } 276 // We need to initialize only if the workspace hasn't been initialized yet. 277 if !os.IsNotExist(err) { 278 return w, nil 279 } 280 out, err := w.runTF(ctx, ModeSync, "init", "-input=false") 281 w.logger.Debug("init ended", "out", ts.filterSensitiveInformation(string(out))) 282 return w, errors.Wrapf(err, "cannot init workspace: %s", ts.filterSensitiveInformation(string(out))) 283 } 284 285 // Remove deletes the workspace directory from the filesystem and erases its 286 // record from the store. 287 func (ws *WorkspaceStore) Remove(obj xpresource.Object) error { 288 ws.mu.Lock() 289 defer ws.mu.Unlock() 290 w, ok := ws.store[obj.GetUID()] 291 if !ok { 292 return nil 293 } 294 if err := ws.fs.RemoveAll(w.dir); err != nil { 295 return errors.Wrap(err, "cannot remove workspace folder") 296 } 297 delete(ws.store, obj.GetUID()) 298 return nil 299 } 300 301 func (ws *WorkspaceStore) initMetrics() { 302 for _, mode := range []ExecMode{ModeSync, ModeASync} { 303 for _, subcommand := range []string{"init", "apply", "destroy", "plan"} { 304 metrics.CLIExecutions.WithLabelValues(subcommand, mode.String()).Set(0) 305 } 306 } 307 } 308 309 func (ts Setup) filterSensitiveInformation(s string) string { 310 for _, v := range ts.Configuration { 311 if str, ok := v.(string); ok && str != "" { 312 s = strings.ReplaceAll(s, str, "REDACTED") 313 } 314 } 315 return s 316 } 317 318 func (ws *WorkspaceStore) reportTFProcesses(interval time.Duration) { 319 for _, t := range []string{"cli", "provider"} { 320 metrics.TFProcesses.WithLabelValues(t).Set(0) 321 } 322 t := time.NewTicker(interval) 323 for range t.C { 324 processes, err := ps.Processes() 325 if err != nil { 326 ws.logger.Debug("Failed to list processes", "err", err) 327 continue 328 } 329 cliCount, providerCount := 0.0, 0.0 330 for _, p := range processes { 331 e := p.Executable() 332 switch { 333 case e == "terraform": 334 cliCount++ 335 case strings.HasPrefix(e, "terraform-"): 336 providerCount++ 337 } 338 } 339 metrics.TFProcesses.WithLabelValues("cli").Set(cliCount) 340 metrics.TFProcesses.WithLabelValues("provider").Set(providerCount) 341 } 342 }