coder/provisioner/terraform/provision.go

266 lines
7.6 KiB
Go

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 forcefull 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() {
<-stream.Context().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.
select {
case <-time.After(s.exitTimeout):
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] = "<value redacted>"
}
sink.Log(&proto.Log{
Level: proto.LogLevel_WARN,
Output: fmt.Sprintf("terraform environment variable: %s=%s", parts[0], parts[1]),
})
}
}
}