diff --git a/coderd/provisionerdserver/provisionerdserver.go b/coderd/provisionerdserver/provisionerdserver.go index f5ffadb29fc9e..ce58d90468026 100644 --- a/coderd/provisionerdserver/provisionerdserver.go +++ b/coderd/provisionerdserver/provisionerdserver.go @@ -699,16 +699,19 @@ func (s *server) acquireProtoJob(ctx context.Context, job database.ProvisionerJo } } + activeVersion := template.ActiveVersionID == templateVersion.ID protoJob.Type = &proto.AcquiredJob_WorkspaceBuild_{ WorkspaceBuild: &proto.AcquiredJob_WorkspaceBuild{ - WorkspaceBuildId: workspaceBuild.ID.String(), - WorkspaceName: workspace.Name, - State: workspaceBuild.ProvisionerState, - RichParameterValues: convertRichParameterValues(workspaceBuildParameters), - PreviousParameterValues: convertRichParameterValues(lastWorkspaceBuildParameters), - VariableValues: asVariableValues(templateVariables), - ExternalAuthProviders: externalAuthProviders, - ExpReuseTerraformWorkspace: ptr.Ref(false), // TODO: Toggle based on experiment + WorkspaceBuildId: workspaceBuild.ID.String(), + WorkspaceName: workspace.Name, + State: workspaceBuild.ProvisionerState, + RichParameterValues: convertRichParameterValues(workspaceBuildParameters), + PreviousParameterValues: convertRichParameterValues(lastWorkspaceBuildParameters), + VariableValues: asVariableValues(templateVariables), + ExternalAuthProviders: externalAuthProviders, + // If active and experiment is enabled, allow workspace reuse existing TF + // workspaces (directories) for a faster startup. + ExpReuseTerraformWorkspace: ptr.Ref(activeVersion && s.Experiments.Enabled(codersdk.ExperimentTerraformWorkspace)), Metadata: &sdkproto.Metadata{ CoderUrl: s.AccessURL.String(), WorkspaceTransition: transition, @@ -722,6 +725,7 @@ func (s *server) acquireProtoJob(ctx context.Context, job database.ProvisionerJo WorkspaceOwnerId: owner.ID.String(), TemplateId: template.ID.String(), TemplateName: template.Name, + TemplateVersionId: templateVersion.ID.String(), TemplateVersion: templateVersion.Name, WorkspaceOwnerSessionToken: sessionToken, WorkspaceOwnerSshPublicKey: ownerSSHPublicKey, diff --git a/coderd/provisionerdserver/provisionerdserver_test.go b/coderd/provisionerdserver/provisionerdserver_test.go index 3d31251fcdb5d..4dc8621736b5c 100644 --- a/coderd/provisionerdserver/provisionerdserver_test.go +++ b/coderd/provisionerdserver/provisionerdserver_test.go @@ -452,6 +452,7 @@ func TestAcquireJob(t *testing.T) { TemplateId: template.ID.String(), TemplateName: template.Name, TemplateVersion: version.Name, + TemplateVersionId: version.ID.String(), WorkspaceOwnerSessionToken: sessionToken, WorkspaceOwnerSshPublicKey: sshKey.PublicKey, WorkspaceOwnerSshPrivateKey: sshKey.PrivateKey, diff --git a/provisioner/terraform/executor.go b/provisioner/terraform/executor.go index 345b0e72fb90c..2a4fbb3d1808d 100644 --- a/provisioner/terraform/executor.go +++ b/provisioner/terraform/executor.go @@ -41,7 +41,7 @@ type executor struct { // cachePath and files must not be used by multiple processes at once. cachePath string cliConfigPath string - files tfpath.Layout + files tfpath.Layouter // used to capture execution times at various stages timings *timingAggregator } @@ -536,7 +536,11 @@ func (e *executor) graph(ctx, killCtx context.Context) (string, error) { if err != nil { return "", err } - args := []string{"graph"} + args := []string{ + "graph", + // TODO: When the plan is present, we should probably use it? + // "-plan=" + e.files.PlanFilePath(), + } if ver.GreaterThanOrEqual(version170) { args = append(args, "-type=plan") } diff --git a/provisioner/terraform/modules.go b/provisioner/terraform/modules.go index 38bfd65e84d6c..048a5b3314a2c 100644 --- a/provisioner/terraform/modules.go +++ b/provisioner/terraform/modules.go @@ -58,7 +58,7 @@ func parseModulesFile(filePath string) ([]*proto.Module, error) { // getModules returns the modules from the modules file if it exists. // It returns nil if the file does not exist. // Modules become available after terraform init. -func getModules(files tfpath.Layout) ([]*proto.Module, error) { +func getModules(files tfpath.Layouter) ([]*proto.Module, error) { filePath := files.ModulesFilePath() if _, err := os.Stat(filePath); os.IsNotExist(err) { return nil, nil diff --git a/provisioner/terraform/serve.go b/provisioner/terraform/serve.go index 60951a8da136b..6b14282f9f472 100644 --- a/provisioner/terraform/serve.go +++ b/provisioner/terraform/serve.go @@ -161,7 +161,7 @@ func (s *server) startTrace(ctx context.Context, name string, opts ...trace.Span ))...) } -func (s *server) executor(files tfpath.Layout, stage database.ProvisionerJobTimingStage) *executor { +func (s *server) executor(files tfpath.Layouter, stage database.ProvisionerJobTimingStage) *executor { return &executor{ server: s, mut: s.execMut, diff --git a/provisionerd/provisionerd_test.go b/provisionerd/provisionerd_test.go index f9977d0e8eb1a..fc4d069a88597 100644 --- a/provisionerd/provisionerd_test.go +++ b/provisionerd/provisionerd_test.go @@ -26,6 +26,7 @@ import ( "github.com/coder/coder/v2/provisionerd/proto" "github.com/coder/coder/v2/provisionersdk" sdkproto "github.com/coder/coder/v2/provisionersdk/proto" + "github.com/coder/coder/v2/provisionersdk/tfpath" "github.com/coder/coder/v2/testutil" ) @@ -318,8 +319,8 @@ func TestProvisionerd(t *testing.T) { JobId: "test", Provisioner: "someprovisioner", TemplateSourceArchive: testutil.CreateTar(t, map[string]string{ - "test.txt": "content", - provisionersdk.ReadmeFile: "# A cool template 😎\n", + "test.txt": "content", + tfpath.ReadmeFile: "# A cool template 😎\n", }), Type: &proto.AcquiredJob_TemplateImport_{ TemplateImport: &proto.AcquiredJob_TemplateImport{ diff --git a/provisionersdk/cleanup.go b/provisionersdk/cleanup.go deleted file mode 100644 index b515c636b4eba..0000000000000 --- a/provisionersdk/cleanup.go +++ /dev/null @@ -1,48 +0,0 @@ -package provisionersdk - -import ( - "context" - "path/filepath" - "time" - - "github.com/spf13/afero" - "golang.org/x/xerrors" - - "cdr.dev/slog" -) - -// CleanStaleSessions browses the work directory searching for stale session -// directories. Coder provisioner is supposed to remove them once after finishing the provisioning, -// but there is a risk of keeping them in case of a failure. -func CleanStaleSessions(ctx context.Context, workDirectory string, fs afero.Fs, now time.Time, logger slog.Logger) error { - entries, err := afero.ReadDir(fs, workDirectory) - if err != nil { - return xerrors.Errorf("can't read %q directory", workDirectory) - } - - for _, fi := range entries { - dirName := fi.Name() - - if fi.IsDir() && isValidSessionDir(dirName) { - sessionDirPath := filepath.Join(workDirectory, dirName) - - modTime := fi.ModTime() // fallback to modTime if modTime is not available (afero) - - if modTime.Add(staleSessionRetention).After(now) { - continue - } - - logger.Info(ctx, "remove stale session directory", slog.F("session_path", sessionDirPath)) - err = fs.RemoveAll(sessionDirPath) - if err != nil { - return xerrors.Errorf("can't remove %q directory: %w", sessionDirPath, err) - } - } - } - return nil -} - -func isValidSessionDir(dirName string) bool { - match, err := filepath.Match(sessionDirPrefix+"*", dirName) - return err == nil && match -} diff --git a/provisionersdk/cleanup_test.go b/provisionersdk/cleanup_test.go index d60ef55a7c6d4..3bc0064f88132 100644 --- a/provisionersdk/cleanup_test.go +++ b/provisionersdk/cleanup_test.go @@ -11,7 +11,6 @@ import ( "github.com/stretchr/testify/require" "cdr.dev/slog" - "github.com/coder/coder/v2/provisionersdk" "github.com/coder/coder/v2/provisionersdk/tfpath" "github.com/coder/coder/v2/testutil" ) @@ -47,9 +46,12 @@ func TestStaleSessions(t *testing.T) { addSessionFolder(t, fs, second, now.Add(-8*24*time.Hour)) third := tfpath.Session(workDirectory, uuid.NewString()) addSessionFolder(t, fs, third, now.Add(-9*24*time.Hour)) + // tfDir is a fake session that will clean up the others + tfDir := tfpath.Session(workDirectory, uuid.NewString()) // when - provisionersdk.CleanStaleSessions(ctx, workDirectory, fs, now, logger) + err := tfDir.CleanStaleSessions(ctx, logger, fs, now) + require.NoError(t, err) // then entries, err := afero.ReadDir(fs, workDirectory) @@ -70,9 +72,11 @@ func TestStaleSessions(t *testing.T) { addSessionFolder(t, fs, first, now.Add(-7*24*time.Hour)) second := tfpath.Session(workDirectory, uuid.NewString()) addSessionFolder(t, fs, second, now.Add(-6*24*time.Hour)) + tfDir := tfpath.Session(workDirectory, uuid.NewString()) // when - provisionersdk.CleanStaleSessions(ctx, workDirectory, fs, now, logger) + err := tfDir.CleanStaleSessions(ctx, logger, fs, now) + require.NoError(t, err) // then entries, err := afero.ReadDir(fs, workDirectory) @@ -94,9 +98,11 @@ func TestStaleSessions(t *testing.T) { addSessionFolder(t, fs, first, now.Add(-6*24*time.Hour)) second := tfpath.Session(workDirectory, uuid.NewString()) addSessionFolder(t, fs, second, now.Add(-5*24*time.Hour)) + tfDir := tfpath.Session(workDirectory, uuid.NewString()) // when - provisionersdk.CleanStaleSessions(ctx, workDirectory, fs, now, logger) + err := tfDir.CleanStaleSessions(ctx, logger, fs, now) + require.NoError(t, err) // then entries, err := afero.ReadDir(fs, workDirectory) diff --git a/provisionersdk/session.go b/provisionersdk/session.go index 68a72190071d9..59034a761e09d 100644 --- a/provisionersdk/session.go +++ b/provisionersdk/session.go @@ -13,22 +13,16 @@ import ( "golang.org/x/xerrors" "cdr.dev/slog" + "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/drpcsdk" "github.com/coder/coder/v2/provisionersdk/tfpath" + "github.com/coder/coder/v2/provisionersdk/tfpath/x" protobuf "google.golang.org/protobuf/proto" "github.com/coder/coder/v2/provisionersdk/proto" ) -const ( - // ReadmeFile is the location we look for to extract documentation from template versions. - ReadmeFile = "README.md" - - sessionDirPrefix = "Session" - staleSessionRetention = 7 * 24 * time.Hour -) - // protoServer is a wrapper that translates the dRPC protocol into a Session with method calls into the Server. type protoServer struct { server Server @@ -43,11 +37,6 @@ func (p *protoServer) Session(stream proto.DRPCProvisioner_SessionStream) error server: p.server, } - err := CleanStaleSessions(s.Context(), p.opts.WorkDirectory, afero.NewOsFs(), time.Now(), s.Logger) - if err != nil { - return xerrors.Errorf("unable to clean stale sessions %q: %w", s.Files, err) - } - s.Files = tfpath.Session(p.opts.WorkDirectory, sessID) defer func() { @@ -67,6 +56,16 @@ func (p *protoServer) Session(stream proto.DRPCProvisioner_SessionStream) error s.logLevel = proto.LogLevel_value[strings.ToUpper(s.Config.ProvisionerLogLevel)] } + if p.opts.Experiments.Enabled(codersdk.ExperimentTerraformWorkspace) { + s.Files = x.SessionDir(p.opts.WorkDirectory, sessID, config) + } + + // Cleanup any previously left stale sessions. + err = s.Files.CleanStaleSessions(s.Context(), s.Logger, afero.NewOsFs(), time.Now()) + if err != nil { + return xerrors.Errorf("unable to clean stale sessions %q: %w", s.Files, err) + } + err = s.Files.ExtractArchive(s.Context(), s.Logger, afero.NewOsFs(), s.Config) if err != nil { return xerrors.Errorf("extract archive: %w", err) @@ -199,7 +198,7 @@ func (s *Session) handleRequests() error { type Session struct { Logger slog.Logger - Files tfpath.Layout + Files tfpath.Layouter Config *proto.Config server Server diff --git a/provisionersdk/tfpath/tfpath.go b/provisionersdk/tfpath/tfpath.go index 8662a5f096950..019552e48d0de 100644 --- a/provisionersdk/tfpath/tfpath.go +++ b/provisionersdk/tfpath/tfpath.go @@ -19,11 +19,28 @@ import ( "github.com/coder/coder/v2/provisionersdk/proto" ) +type Layouter interface { + WorkDirectory() string + StateFilePath() string + PlanFilePath() string + TerraformLockFile() string + ReadmeFilePath() string + TerraformMetadataDir() string + ModulesDirectory() string + ModulesFilePath() string + ExtractArchive(ctx context.Context, logger slog.Logger, fs afero.Fs, cfg *proto.Config) error + Cleanup(ctx context.Context, logger slog.Logger, fs afero.Fs) + CleanStaleSessions(ctx context.Context, logger slog.Logger, fs afero.Fs, now time.Time) error +} + +var _ Layouter = (*Layout)(nil) + const ( // ReadmeFile is the location we look for to extract documentation from template versions. ReadmeFile = "README.md" - sessionDirPrefix = "Session" + sessionDirPrefix = "Session" + staleSessionRetention = 7 * 24 * time.Hour ) // Session creates a directory structure layout for terraform execution. The @@ -34,6 +51,10 @@ func Session(parentDirPath, sessionID string) Layout { return Layout(filepath.Join(parentDirPath, sessionDirPrefix+sessionID)) } +func FromWorkingDirectory(workDir string) Layout { + return Layout(workDir) +} + // Layout is the terraform execution working directory structure. // It also contains some methods for common file operations within that layout. // Such as "Cleanup" and "ExtractArchive". @@ -82,6 +103,8 @@ func (l Layout) ExtractArchive(ctx context.Context, logger slog.Logger, fs afero return xerrors.Errorf("create work directory %q: %w", l.WorkDirectory(), err) } + // TODO: Pass in cfg.TemplateSourceArchive, not the full config. + // niling out the config field is a bit hacky. reader := tar.NewReader(bytes.NewBuffer(cfg.TemplateSourceArchive)) // for safety, nil out the reference on Config, since the reader now owns it. cfg.TemplateSourceArchive = nil @@ -190,3 +213,40 @@ func (l Layout) Cleanup(ctx context.Context, logger slog.Logger, fs afero.Fs) { logger.Error(ctx, "failed to clean up work directory after multiple attempts", slog.F("path", path), slog.Error(err)) } + +// CleanStaleSessions browses the work directory searching for stale session +// directories. Coder provisioner is supposed to remove them once after finishing the provisioning, +// but there is a risk of keeping them in case of a failure. +func (l Layout) CleanStaleSessions(ctx context.Context, logger slog.Logger, fs afero.Fs, now time.Time) error { + parent := filepath.Dir(l.WorkDirectory()) + entries, err := afero.ReadDir(fs, filepath.Dir(l.WorkDirectory())) + if err != nil { + return xerrors.Errorf("can't read %q directory", parent) + } + + for _, fi := range entries { + dirName := fi.Name() + + if fi.IsDir() && isValidSessionDir(dirName) { + sessionDirPath := filepath.Join(parent, dirName) + + modTime := fi.ModTime() // fallback to modTime if modTime is not available (afero) + + if modTime.Add(staleSessionRetention).After(now) { + continue + } + + logger.Info(ctx, "remove stale session directory", slog.F("session_path", sessionDirPath)) + err = fs.RemoveAll(sessionDirPath) + if err != nil { + return xerrors.Errorf("can't remove %q directory: %w", sessionDirPath, err) + } + } + } + return nil +} + +func isValidSessionDir(dirName string) bool { + match, err := filepath.Match(sessionDirPrefix+"*", dirName) + return err == nil && match +} diff --git a/provisionersdk/tfpath/x/tfpath.go b/provisionersdk/tfpath/x/tfpath.go new file mode 100644 index 0000000000000..bc91315324158 --- /dev/null +++ b/provisionersdk/tfpath/x/tfpath.go @@ -0,0 +1,320 @@ +package x + +// This file will replace the `tfpath.go` in the parent `tfpath` package when the +// `terraform-workspace` experiment is graduated. + +import ( + "archive/tar" + "bytes" + "context" + "fmt" + "hash/crc32" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/google/uuid" + "github.com/spf13/afero" + "golang.org/x/xerrors" + + "cdr.dev/slog" + "github.com/coder/coder/v2/provisionersdk/proto" + "github.com/coder/coder/v2/provisionersdk/tfpath" +) + +var _ tfpath.Layouter = (*Layout)(nil) + +func SessionDir(parentDir, sessID string, config *proto.Config) Layout { + // TODO: These conditionals are messy. nil, "", or uuid.Nil are all considered the same. Maybe a helper function? + missingID := config.TemplateId == nil || *config.TemplateId == "" || *config.TemplateId == uuid.Nil.String() || + config.TemplateVersionId == nil || *config.TemplateVersionId == "" || *config.TemplateVersionId == uuid.Nil.String() + + // Both templateID and templateVersionID must be set to reuse workspace. + if config.ExpReuseTerraformWorkspace == nil || !*config.ExpReuseTerraformWorkspace || missingID { + return EphemeralSessionDir(parentDir, sessID) + } + + return Layout{ + workDirectory: filepath.Join(parentDir, *config.TemplateId, *config.TemplateVersionId), + sessionID: sessID, + ephemeral: false, + } +} + +// EphemeralSessionDir returns the directory name with mandatory prefix. These +// directories are created for each provisioning session and are meant to be +// ephemeral. +func EphemeralSessionDir(parentDir, sessID string) Layout { + return Layout{ + workDirectory: filepath.Join(parentDir, sessionDirPrefix+sessID), + sessionID: sessID, + ephemeral: true, + } +} + +type Layout struct { + workDirectory string + sessionID string + ephemeral bool +} + +const ( + // ReadmeFile is the location we look for to extract documentation from template versions. + ReadmeFile = "README.md" + + sessionDirPrefix = "Session" +) + +func (td Layout) WorkDirectory() string { + return td.workDirectory +} + +// StateSessionDirectory follows the same directory structure as Terraform +// workspaces. All build specific state is stored within this directory. +// +// These files should be cleaned up on exit. In the case of a failure, they will +// not collide with other builds since each build uses a unique session ID. +func (td Layout) StateSessionDirectory() string { + return filepath.Join(td.workDirectory, "terraform.tfstate.d", td.sessionID) +} + +func (td Layout) StateFilePath() string { + return filepath.Join(td.StateSessionDirectory(), "terraform.tfstate") +} + +func (td Layout) PlanFilePath() string { + return filepath.Join(td.StateSessionDirectory(), "terraform.tfplan") +} + +func (td Layout) TerraformLockFile() string { + return filepath.Join(td.WorkDirectory(), ".terraform.lock.hcl") +} + +func (td Layout) ReadmeFilePath() string { + return filepath.Join(td.WorkDirectory(), ReadmeFile) +} + +func (td Layout) TerraformMetadataDir() string { + return filepath.Join(td.WorkDirectory(), ".terraform") +} + +func (td Layout) ModulesDirectory() string { + return filepath.Join(td.TerraformMetadataDir(), "modules") +} + +func (td Layout) ModulesFilePath() string { + return filepath.Join(td.ModulesDirectory(), "modules.json") +} + +func (td Layout) WorkspaceEnvironmentFilePath() string { + return filepath.Join(td.TerraformMetadataDir(), "environment") +} + +func (td Layout) Cleanup(ctx context.Context, logger slog.Logger, fs afero.Fs) { + var err error + path := td.WorkDirectory() + if !td.ephemeral { + // Non-ephemeral directories only clean up the session subdirectory. + // Leaving in place the wider work directory for reuse. + path = td.StateSessionDirectory() + } + for attempt := 0; attempt < 5; attempt++ { + err := fs.RemoveAll(path) + if err != nil { + // On Windows, open files cannot be removed. + // When the provisioner daemon is shutting down, + // it may take a few milliseconds for processes to exit. + // See: https://github.com/golang/go/issues/50510 + logger.Debug(ctx, "failed to clean work directory; trying again", slog.Error(err)) + // TODO: Should we abort earlier if the context is done? + time.Sleep(250 * time.Millisecond) + continue + } + logger.Debug(ctx, "cleaned up work directory", slog.F("path", path)) + return + } + + logger.Error(ctx, "failed to clean up work directory after multiple attempts", + slog.F("path", path), slog.Error(err)) +} + +func (td Layout) ExtractArchive(ctx context.Context, logger slog.Logger, fs afero.Fs, cfg *proto.Config) error { + logger.Info(ctx, "unpacking template source archive", + slog.F("size_bytes", len(cfg.TemplateSourceArchive)), + ) + + err := fs.MkdirAll(td.WorkDirectory(), 0o700) + if err != nil { + return xerrors.Errorf("create work directory %q: %w", td.WorkDirectory(), err) + } + + err = fs.MkdirAll(td.StateSessionDirectory(), 0o700) + if err != nil { + return xerrors.Errorf("create state directory %q: %w", td.WorkDirectory(), err) + } + + // TODO: This is a bit hacky. We should use `terraform workspace select` to create this + // environment file. However, since we know the backend is `local`, this is a quicker + // way to accomplish the same thing. + err = td.SelectWorkspace(fs) + if err != nil { + return xerrors.Errorf("select terraform workspace: %w", err) + } + + reader := tar.NewReader(bytes.NewBuffer(cfg.TemplateSourceArchive)) + // for safety, nil out the reference on Config, since the reader now owns it. + cfg.TemplateSourceArchive = nil + for { + header, err := reader.Next() + if err != nil { + if xerrors.Is(err, io.EOF) { + break + } + return xerrors.Errorf("read template source archive: %w", err) + } + logger.Debug(context.Background(), "read archive entry", + slog.F("name", header.Name), + slog.F("mod_time", header.ModTime), + slog.F("size", header.Size)) + + // Security: don't untar absolute or relative paths, as this can allow a malicious tar to overwrite + // files outside the workdir. + if !filepath.IsLocal(header.Name) { + return xerrors.Errorf("refusing to extract to non-local path") + } + // nolint: gosec + headerPath := filepath.Join(td.WorkDirectory(), header.Name) + if !strings.HasPrefix(headerPath, filepath.Clean(td.WorkDirectory())) { + return xerrors.New("tar attempts to target relative upper directory") + } + mode := header.FileInfo().Mode() + if mode == 0 { + mode = 0o600 + } + + // Always check for context cancellation before reading the next header. + // This is mainly important for unit tests, since a canceled context means + // the underlying directory is going to be deleted. There still exists + // the small race condition that the context is canceled after this, and + // before the disk write. + if ctx.Err() != nil { + return xerrors.Errorf("context canceled: %w", ctx.Err()) + } + switch header.Typeflag { + case tar.TypeDir: + err = fs.MkdirAll(headerPath, mode) + if err != nil { + return xerrors.Errorf("mkdir %q: %w", headerPath, err) + } + logger.Debug(context.Background(), "extracted directory", + slog.F("path", headerPath), + slog.F("mode", fmt.Sprintf("%O", mode))) + case tar.TypeReg: + // TODO: If we are overwriting an existing file, that means we are reusing + // the terraform directory. In that case, we should check the file content + // matches what already exists on disk. Or just continue to overwrite it. + file, err := fs.OpenFile(headerPath, os.O_CREATE|os.O_RDWR|os.O_TRUNC, mode) + if err != nil { + return xerrors.Errorf("create file %q (mode %s): %w", headerPath, mode, err) + } + + hash := crc32.NewIEEE() + hashReader := io.TeeReader(reader, hash) + // Max file size of 10MiB. + size, err := io.CopyN(file, hashReader, 10<<20) + if xerrors.Is(err, io.EOF) { + err = nil + } + if err != nil { + _ = file.Close() + return xerrors.Errorf("copy file %q: %w", headerPath, err) + } + err = file.Close() + if err != nil { + return xerrors.Errorf("close file %q: %s", headerPath, err) + } + logger.Debug(context.Background(), "extracted file", + slog.F("size_bytes", size), + slog.F("path", headerPath), + slog.F("mode", mode), + slog.F("checksum", fmt.Sprintf("%x", hash.Sum(nil)))) + } + } + + return nil +} + +// CleanStaleSessions assumes this Layout is the latest active template version. +// Assuming that, any other template version directories found alongside it are +// considered inactive and can be removed. Inactive template versions should use +// ephemeral TerraformDirectories. +func (td Layout) CleanStaleSessions(ctx context.Context, logger slog.Logger, fs afero.Fs, now time.Time) error { + if td.ephemeral { + // Use the existing cleanup for ephemeral sessions. + return tfpath.FromWorkingDirectory(td.workDirectory).CleanStaleSessions(ctx, logger, fs, now) + } + + // All template versions share the same parent directory. Since only the latest + // active version should remain, remove all other version directories. + wd := td.WorkDirectory() + templateDir := filepath.Dir(wd) + versionDir := filepath.Base(wd) + + entries, err := afero.ReadDir(fs, templateDir) + if xerrors.Is(err, os.ErrNotExist) { + // Nothing to clean, this template dir does not exist. + return nil + } + if err != nil { + return xerrors.Errorf("can't read %q directory: %w", templateDir, err) + } + + for _, fi := range entries { + if !fi.IsDir() { + continue + } + + if fi.Name() == versionDir { + continue + } + + // Note: There is a .coder directory here with a pprof unix file. + // This is from the previous provisioner run, and will be removed here. + // TODO: Add more explicit pprof cleanup/handling. + + oldVerDir := filepath.Join(templateDir, fi.Name()) + logger.Info(ctx, "remove inactive template version directory", slog.F("version_path", oldVerDir)) + err = fs.RemoveAll(oldVerDir) + if err != nil { + logger.Error(ctx, "failed to remove inactive template version directory", slog.F("version_path", oldVerDir), slog.Error(err)) + } + } + return nil +} + +// SelectWorkspace writes the terraform workspace environment file, which acts as +// `terraform workspace select `. It is quicker than using the cli command. +// More importantly this code can be written without changing the executor +// behavior, which is nice encapsulation for this experiment. +func (td Layout) SelectWorkspace(fs afero.Fs) error { + // Also set up the terraform workspace to use + err := fs.MkdirAll(td.TerraformMetadataDir(), 0o700) + if err != nil { + return xerrors.Errorf("create terraform metadata directory %q: %w", td.TerraformMetadataDir(), err) + } + + file, err := fs.OpenFile(td.WorkspaceEnvironmentFilePath(), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) + if err != nil { + return xerrors.Errorf("create workspace environment file: %w", err) + } + defer file.Close() + + _, err = file.WriteString(td.sessionID) + if err != nil { + _ = file.Close() + return xerrors.Errorf("write workspace environment file: %w", err) + } + return nil +}