package terraform import ( "context" "fmt" "os" "path/filepath" "strings" "time" "golang.org/x/xerrors" "github.com/coder/coder/coderd/tracing" "github.com/coder/coder/provisionersdk" "github.com/coder/coder/provisionersdk/proto" "github.com/coder/terraform-provider-coder/provider" ) // Provision executes `terraform apply` or `terraform plan` for dry runs. func (s *server) Provision(stream proto.DRPCProvisioner_ProvisionStream) error { ctx, span := s.startTrace(stream.Context(), tracing.FuncName()) defer span.End() request, err := stream.Recv() if err != nil { return err } if request.GetCancel() != nil { return nil } var ( applyRequest = request.GetApply() planRequest = request.GetPlan() ) var config *proto.Provision_Config if applyRequest == nil && planRequest == nil { return nil } else if applyRequest != nil { config = applyRequest.Config } else if planRequest != nil { config = planRequest.Config } // Create a context for graceful cancellation bound to the stream // context. This ensures that we will perform graceful cancellation // even on connection loss. ctx, cancel := context.WithCancel(ctx) defer cancel() // Create a separate context for forceful cancellation not tied to // the stream so that we can control when to terminate the process. killCtx, kill := context.WithCancel(context.Background()) defer kill() // Ensure processes are eventually cleaned up on graceful // cancellation or disconnect. go func() { <-ctx.Done() // TODO(mafredri): We should track this provision request as // part of graceful server shutdown procedure. Waiting on a // process here should delay provisioner/coder shutdown. t := time.NewTimer(s.exitTimeout) defer t.Stop() select { case <-t.C: kill() case <-killCtx.Done(): } }() go func() { for { request, err := stream.Recv() if err != nil { return } if request.GetCancel() == nil { // We only process cancellation requests here. continue } cancel() return } }() sink := streamLogSink{ logger: s.logger.Named("execution_logs"), stream: stream, } e := s.executor(config.Directory) if err = e.checkMinVersion(ctx); err != nil { return err } logTerraformEnvVars(sink) statefilePath := filepath.Join(config.Directory, "terraform.tfstate") if len(config.State) > 0 { err = os.WriteFile(statefilePath, config.State, 0o600) if err != nil { return xerrors.Errorf("write statefile %q: %w", statefilePath, err) } } // If we're destroying, exit early if there's no state. This is necessary to // avoid any cases where a workspace is "locked out" of terraform due to // e.g. bad template param values and cannot be deleted. This is just for // contingency, in the future we will try harder to prevent workspaces being // broken this hard. if config.Metadata.WorkspaceTransition == proto.WorkspaceTransition_DESTROY && len(config.State) == 0 { _ = stream.Send(&proto.Provision_Response{ Type: &proto.Provision_Response_Log{ Log: &proto.Log{ Level: proto.LogLevel_INFO, Output: "The terraform state does not exist, there is nothing to do", }, }, }) return stream.Send(&proto.Provision_Response{ Type: &proto.Provision_Response_Complete{ Complete: &proto.Provision_Complete{}, }, }) } s.logger.Debug(ctx, "running initialization") err = e.init(ctx, killCtx, sink) if err != nil { if ctx.Err() != nil { return stream.Send(&proto.Provision_Response{ Type: &proto.Provision_Response_Complete{ Complete: &proto.Provision_Complete{ Error: err.Error(), }, }, }) } return xerrors.Errorf("initialize terraform: %w", err) } s.logger.Debug(ctx, "ran initialization") env, err := provisionEnv(config, request.GetPlan().GetRichParameterValues(), request.GetPlan().GetGitAuthProviders()) if err != nil { return err } var resp *proto.Provision_Response if planRequest != nil { vars, err := planVars(planRequest) if err != nil { return err } resp, err = e.plan( ctx, killCtx, env, vars, sink, config.Metadata.WorkspaceTransition == proto.WorkspaceTransition_DESTROY, ) if err != nil { if ctx.Err() != nil { return stream.Send(&proto.Provision_Response{ Type: &proto.Provision_Response_Complete{ Complete: &proto.Provision_Complete{ Error: err.Error(), }, }, }) } return xerrors.Errorf("plan terraform: %w", err) } return stream.Send(resp) } // Must be apply resp, err = e.apply( ctx, killCtx, applyRequest.Plan, env, sink, ) if err != nil { errorMessage := err.Error() // Terraform can fail and apply and still need to store it's state. // In this case, we return Complete with an explicit error message. stateData, _ := os.ReadFile(statefilePath) return stream.Send(&proto.Provision_Response{ Type: &proto.Provision_Response_Complete{ Complete: &proto.Provision_Complete{ State: stateData, Error: errorMessage, }, }, }) } return stream.Send(resp) } func planVars(plan *proto.Provision_Plan) ([]string, error) { vars := []string{} for _, variable := range plan.VariableValues { vars = append(vars, fmt.Sprintf("%s=%s", variable.Name, variable.Value)) } return vars, nil } func provisionEnv(config *proto.Provision_Config, richParams []*proto.RichParameterValue, gitAuth []*proto.GitAuthProvider) ([]string, error) { env := safeEnviron() env = append(env, "CODER_AGENT_URL="+config.Metadata.CoderUrl, "CODER_WORKSPACE_TRANSITION="+strings.ToLower(config.Metadata.WorkspaceTransition.String()), "CODER_WORKSPACE_NAME="+config.Metadata.WorkspaceName, "CODER_WORKSPACE_OWNER="+config.Metadata.WorkspaceOwner, "CODER_WORKSPACE_OWNER_EMAIL="+config.Metadata.WorkspaceOwnerEmail, "CODER_WORKSPACE_OWNER_OIDC_ACCESS_TOKEN="+config.Metadata.WorkspaceOwnerOidcAccessToken, "CODER_WORKSPACE_ID="+config.Metadata.WorkspaceId, "CODER_WORKSPACE_OWNER_ID="+config.Metadata.WorkspaceOwnerId, "CODER_WORKSPACE_OWNER_SESSION_TOKEN="+config.Metadata.WorkspaceOwnerSessionToken, ) for key, value := range provisionersdk.AgentScriptEnv() { env = append(env, key+"="+value) } for _, param := range richParams { env = append(env, provider.ParameterEnvironmentVariable(param.Name)+"="+param.Value) } for _, gitAuth := range gitAuth { env = append(env, provider.GitAuthAccessTokenEnvironmentVariable(gitAuth.Id)+"="+gitAuth.AccessToken) } if config.ProvisionerLogLevel != "" { // TF_LOG=JSON enables all kind of logging: trace-debug-info-warn-error. // The idea behind using TF_LOG=JSON instead of TF_LOG=debug is ensuring the proper log format. env = append(env, "TF_LOG=JSON") } return env, nil } // tfEnvSafeToPrint is the set of terraform environment variables that we are quite sure won't contain secrets, // and therefore it's ok to log their values var tfEnvSafeToPrint = map[string]bool{ "TF_LOG": true, "TF_LOG_PATH": true, "TF_INPUT": true, "TF_DATA_DIR": true, "TF_WORKSPACE": true, "TF_IN_AUTOMATION": true, "TF_REGISTRY_DISCOVERY_RETRY": true, "TF_REGISTRY_CLIENT_TIMEOUT": true, "TF_CLI_CONFIG_FILE": true, "TF_IGNORE": true, } func logTerraformEnvVars(sink logSink) { env := safeEnviron() for _, e := range env { if strings.HasPrefix(e, "TF_") { parts := strings.SplitN(e, "=", 2) if len(parts) != 2 { panic("safeEnviron() returned vars not in key=value form") } if !tfEnvSafeToPrint[parts[0]] { parts[1] = "" } sink.Log(&proto.Log{ Level: proto.LogLevel_WARN, Output: fmt.Sprintf("terraform environment variable: %s=%s", parts[0], parts[1]), }) } } }