chore: implement persistent terraform directories (experimental) (#20563)

Prior to this, every workspace build ran `terraform init` in a fresh
directory. This would mean the `modules` are downloaded fresh. If the
module is not pinned, subsequent workspace builds would have different
modules.
This commit is contained in:
Steven Masley
2025-11-13 07:50:17 -06:00
committed by GitHub
parent 14f08444a9
commit 9ca5b44b56
11 changed files with 428 additions and 81 deletions
@@ -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,
@@ -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,
+6 -2
View File
@@ -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")
}
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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,
+3 -2
View File
@@ -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{
-48
View File
@@ -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
}
+10 -4
View File
@@ -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)
+13 -14
View File
@@ -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
+61 -1
View File
@@ -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
}
+320
View File
@@ -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 <name>`. 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
}