Skip to content

Commit ac16b45

Browse files
committed
chore: pass through file locations more cleanly
1 parent 621163e commit ac16b45

File tree

2 files changed

+263
-0
lines changed

2 files changed

+263
-0
lines changed

provisionersdk/session.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"golang.org/x/xerrors"
1414

1515
"cdr.dev/slog"
16+
"github.com/coder/coder/v2/codersdk"
1617
"github.com/coder/coder/v2/codersdk/drpcsdk"
1718
"github.com/coder/coder/v2/provisionersdk/tfpath"
1819

@@ -67,6 +68,17 @@ func (p *protoServer) Session(stream proto.DRPCProvisioner_SessionStream) error
6768
s.logLevel = proto.LogLevel_value[strings.ToUpper(s.Config.ProvisionerLogLevel)]
6869
}
6970

71+
if p.opts.Experiments.Enabled(codersdk.ExperimentTerraformWorkspace) {
72+
// TODO: Also indicate if opted into the feature via config
73+
s.Files = x.SessionDir(p.opts.WorkDirectory, sessID, config)
74+
75+
err = s.Files.CleanInactiveTemplateVersions(s.Context(), s.Logger, afero.NewOsFs())
76+
if err != nil {
77+
return xerrors.Errorf("unable to clean inactive versions %q: %w", s.Files.WorkDirectory(), err)
78+
}
79+
}
80+
81+
7082
// Extract the template source archive into the work directory.
7183
err = s.Files.ExtractArchive(s.Context(), s.Logger, afero.NewOsFs(), s.Config)
7284
if err != nil {

provisionersdk/x/directories.go

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
package x
2+
3+
import (
4+
"archive/tar"
5+
"bytes"
6+
"context"
7+
"fmt"
8+
"hash/crc32"
9+
"io"
10+
"os"
11+
"path/filepath"
12+
"strings"
13+
"time"
14+
15+
"github.com/google/uuid"
16+
"github.com/spf13/afero"
17+
"golang.org/x/xerrors"
18+
19+
"cdr.dev/slog"
20+
"github.com/coder/coder/v2/provisionersdk/proto"
21+
)
22+
23+
func SessionDir(parentDir, sessID string, config *proto.Config) TerraformDirectory {
24+
if config.TemplateId == "" || config.TemplateId == uuid.Nil.String() ||
25+
config.TemplateVersionId == "" || config.TemplateVersionId == uuid.Nil.String() {
26+
return EphemeralSessionDir(parentDir, sessID)
27+
}
28+
29+
return TerraformDirectory{
30+
workDirectory: filepath.Join(parentDir, config.TemplateId, config.TemplateVersionId),
31+
sessionID: sessID,
32+
ephemeral: false,
33+
}
34+
}
35+
36+
// EphemeralSessionDir returns the directory name with mandatory prefix. These
37+
// directories are created for each provisioning session and are meant to be
38+
// ephemeral.
39+
func EphemeralSessionDir(parentDir, sessID string) TerraformDirectory {
40+
return TerraformDirectory{
41+
workDirectory: filepath.Join(parentDir, sessionDirPrefix+sessID),
42+
sessionID: sessID,
43+
ephemeral: true,
44+
}
45+
}
46+
47+
type TerraformDirectory struct {
48+
workDirectory string
49+
sessionID string
50+
ephemeral bool
51+
}
52+
53+
const (
54+
// ReadmeFile is the location we look for to extract documentation from template versions.
55+
ReadmeFile = "README.md"
56+
57+
sessionDirPrefix = "Session"
58+
)
59+
60+
func (td TerraformDirectory) Cleanup(ctx context.Context, logger slog.Logger) {
61+
var err error
62+
path := td.workDirectory
63+
if !td.ephemeral {
64+
// Non-ephemeral directories only clean up the session subdirectory.
65+
// Leaving in place the wider work directory for reuse.
66+
path = td.StateSessionDirectory()
67+
}
68+
for attempt := 0; attempt < 5; attempt++ {
69+
err := os.RemoveAll(path)
70+
if err != nil {
71+
// On Windows, open files cannot be removed.
72+
// When the provisioner daemon is shutting down,
73+
// it may take a few milliseconds for processes to exit.
74+
// See: https://github.com/golang/go/issues/50510
75+
logger.Debug(ctx, "failed to clean work directory; trying again", slog.Error(err))
76+
// TODO: Should we abort earlier if the context is done?
77+
time.Sleep(250 * time.Millisecond)
78+
continue
79+
}
80+
logger.Debug(ctx, "cleaned up work directory")
81+
return
82+
}
83+
84+
logger.Error(ctx, "failed to clean up work directory after multiple attempts",
85+
slog.F("path", path), slog.Error(err))
86+
87+
return
88+
}
89+
90+
func (td TerraformDirectory) WorkDirectory() string {
91+
return td.workDirectory
92+
}
93+
94+
// StateSessionDirectory follows the same directory structure as Terraform
95+
// workspaces. All build specific state is stored within this directory.
96+
//
97+
// These files should be cleaned up on exit. In the case of a failure, they will
98+
// not collide with other builds since each build uses a unique session ID.
99+
func (td TerraformDirectory) StateSessionDirectory() string {
100+
return filepath.Join(td.workDirectory, "terraform.tfstate.d", td.sessionID)
101+
}
102+
103+
func (td TerraformDirectory) StateFilePath() string {
104+
return filepath.Join(td.StateSessionDirectory(), "terraform.tfstate")
105+
}
106+
107+
func (td TerraformDirectory) PlanFilePath() string {
108+
return filepath.Join(td.StateSessionDirectory(), "terraform.tfplan")
109+
}
110+
111+
func (td TerraformDirectory) TerraformLockFile() string {
112+
return filepath.Join(td.WorkDirectory(), ".terraform.lock.hcl")
113+
}
114+
115+
func (td TerraformDirectory) ReadmeFilePath() string {
116+
return filepath.Join(td.WorkDirectory(), ReadmeFile)
117+
}
118+
119+
func (td TerraformDirectory) ModulesDirectory() string {
120+
return filepath.Join(td.WorkDirectory(), ".terraform", "modules")
121+
}
122+
123+
func (td TerraformDirectory) ModulesFilePath() string {
124+
return filepath.Join(td.ModulesDirectory(), "modules.json")
125+
}
126+
127+
func (td TerraformDirectory) ExtractArchive(ctx context.Context, logger slog.Logger, cfg *proto.Config) error {
128+
logger.Info(ctx, "unpacking template source archive",
129+
slog.F("size_bytes", len(cfg.TemplateSourceArchive)),
130+
)
131+
132+
err := os.MkdirAll(td.WorkDirectory(), 0o700)
133+
if err != nil {
134+
return xerrors.Errorf("create work directory %q: %w", td.WorkDirectory(), err)
135+
}
136+
137+
reader := tar.NewReader(bytes.NewBuffer(cfg.TemplateSourceArchive))
138+
// for safety, nil out the reference on Config, since the reader now owns it.
139+
cfg.TemplateSourceArchive = nil
140+
for {
141+
header, err := reader.Next()
142+
if err != nil {
143+
if xerrors.Is(err, io.EOF) {
144+
break
145+
}
146+
return xerrors.Errorf("read template source archive: %w", err)
147+
}
148+
logger.Debug(context.Background(), "read archive entry",
149+
slog.F("name", header.Name),
150+
slog.F("mod_time", header.ModTime),
151+
slog.F("size", header.Size))
152+
153+
// Security: don't untar absolute or relative paths, as this can allow a malicious tar to overwrite
154+
// files outside the workdir.
155+
if !filepath.IsLocal(header.Name) {
156+
return xerrors.Errorf("refusing to extract to non-local path")
157+
}
158+
// nolint: gosec
159+
headerPath := filepath.Join(td.WorkDirectory(), header.Name)
160+
if !strings.HasPrefix(headerPath, filepath.Clean(td.WorkDirectory())) {
161+
return xerrors.New("tar attempts to target relative upper directory")
162+
}
163+
mode := header.FileInfo().Mode()
164+
if mode == 0 {
165+
mode = 0o600
166+
}
167+
168+
// Always check for context cancellation before reading the next header.
169+
// This is mainly important for unit tests, since a canceled context means
170+
// the underlying directory is going to be deleted. There still exists
171+
// the small race condition that the context is canceled after this, and
172+
// before the disk write.
173+
if ctx.Err() != nil {
174+
return xerrors.Errorf("context canceled: %w", ctx.Err())
175+
}
176+
switch header.Typeflag {
177+
case tar.TypeDir:
178+
err = os.MkdirAll(headerPath, mode)
179+
if err != nil {
180+
return xerrors.Errorf("mkdir %q: %w", headerPath, err)
181+
}
182+
logger.Debug(context.Background(), "extracted directory",
183+
slog.F("path", headerPath),
184+
slog.F("mode", fmt.Sprintf("%O", mode)))
185+
case tar.TypeReg:
186+
// TODO: If we are overwriting an existing file, that means we are reusing
187+
// the terraform directory. In that case, we should check the file content
188+
// matches what already exists on disk.
189+
file, err := os.OpenFile(headerPath, os.O_CREATE|os.O_RDWR|os.O_TRUNC, mode)
190+
if err != nil {
191+
return xerrors.Errorf("create file %q (mode %s): %w", headerPath, mode, err)
192+
}
193+
194+
hash := crc32.NewIEEE()
195+
hashReader := io.TeeReader(reader, hash)
196+
// Max file size of 10MiB.
197+
size, err := io.CopyN(file, hashReader, 10<<20)
198+
if xerrors.Is(err, io.EOF) {
199+
err = nil
200+
}
201+
if err != nil {
202+
_ = file.Close()
203+
return xerrors.Errorf("copy file %q: %w", headerPath, err)
204+
}
205+
err = file.Close()
206+
if err != nil {
207+
return xerrors.Errorf("close file %q: %s", headerPath, err)
208+
}
209+
logger.Debug(context.Background(), "extracted file",
210+
slog.F("size_bytes", size),
211+
slog.F("path", headerPath),
212+
slog.F("mode", mode),
213+
slog.F("checksum", fmt.Sprintf("%x", hash.Sum(nil))))
214+
}
215+
}
216+
217+
return nil
218+
}
219+
220+
// CleanInactiveTemplateVersions assumes this TerraformDirectory is the latest
221+
// active template version. Assuming that, any other template version directories
222+
// found alongside it are considered inactive and can be removed. Inactive
223+
// template versions should use ephemeral TerraformDirectories.
224+
func (td TerraformDirectory) CleanInactiveTemplateVersions(ctx context.Context, logger slog.Logger, fs afero.Fs) error {
225+
if td.ephemeral {
226+
return nil
227+
}
228+
229+
wd := td.WorkDirectory()
230+
templateDir := filepath.Dir(wd)
231+
versionDir := filepath.Base(wd)
232+
233+
entries, err := afero.ReadDir(fs, templateDir)
234+
if err != nil {
235+
return xerrors.Errorf("can't read %q directory: %w", templateDir, err)
236+
}
237+
238+
for _, fi := range entries {
239+
if fi.IsDir() && fi.Name() == versionDir {
240+
continue
241+
}
242+
243+
oldVerDir := filepath.Join(wd, fi.Name())
244+
logger.Info(ctx, "remove inactive template version directory", slog.F("version_path", oldVerDir))
245+
err = fs.RemoveAll(oldVerDir)
246+
if err != nil {
247+
return xerrors.Errorf("can't remove inactive template version %q: %w", fi.Name(), err)
248+
}
249+
}
250+
return nil
251+
}

0 commit comments

Comments
 (0)