mirror of https://github.com/coder/coder.git
266 lines
7.6 KiB
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]),
|
|
})
|
|
}
|
|
}
|
|
}
|