Skip to content

Conversation

@Emyrk
Copy link
Member

@Emyrk Emyrk commented Oct 29, 2025

Refactors all Terraform file path logic into a centralized tfpath package. This consolidates all path construction into a single, testable Layout type.

Instead of passing around string for directories, pass around the Layout which has the file location methods on it.

This design is also to support an experiment where the filepaths will be changed if the experiment is enabled. Allowing for a singular struct to determine filepath locations based on some config. The code that uses said paths will remain unchanged.

Prior Art

Inspired from our cli handling of config files

type Root string

Naming

I was going back and forth on names. Some others I thought about:

  • tfpath
  • tfdir
  • tflayout
  • terradir

Thoughts

Should possibly add afero.FS to this type. Then our tests can run without using the actual FS 🤷

@Emyrk Emyrk marked this pull request as ready for review October 30, 2025 15:51
@Emyrk Emyrk force-pushed the stevenmasley/tf_workingdirectory branch from b7321d6 to 52064b7 Compare October 30, 2025 16:25
@github-actions github-actions bot added stale This issue is like stale bread. and removed stale This issue is like stale bread. labels Nov 8, 2025
@Emyrk Emyrk force-pushed the stevenmasley/tf_workingdirectory branch from 52064b7 to bbf6058 Compare November 10, 2025 15:30
Comment on lines 75 to 192
func (l 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(l.WorkDirectory(), 0o700)
if err != nil {
return xerrors.Errorf("create work directory %q: %w", l.WorkDirectory(), 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(l.WorkDirectory(), header.Name)
if !strings.HasPrefix(headerPath, filepath.Clean(l.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:
file, err := fs.OpenFile(headerPath, os.O_CREATE|os.O_RDWR, 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
}

// Cleanup removes the work directory and all of its contents.
func (l Layout) Cleanup(ctx context.Context, logger slog.Logger, fs afero.Fs) {
var err error
path := l.WorkDirectory()

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")
return
}

// Returning an error at this point cannot do any good. The caller cannot resolve
// this. There is a routine cleanup task that will remove old work directories
// when this fails.
logger.Error(ctx, "failed to clean up work directory after multiple attempts",
slog.F("path", path), slog.Error(err))
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extract and Cleanup are moved to here so the experiment can implement a different implementation for these.

I think this data-structure would benefit from also containing the fs.FS, but that was a larger refactor than I wanted to do.

The structure handles building paths for all relevant files.
@Emyrk Emyrk force-pushed the stevenmasley/tf_workingdirectory branch from bbf6058 to fc6bb0a Compare November 10, 2025 17:15
@Emyrk Emyrk requested a review from dannykopping November 10, 2025 18:01
Copy link
Contributor

@dannykopping dannykopping left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is really nice!

}

reader := tar.NewReader(bytes.NewBuffer(cfg.TemplateSourceArchive))
// for safety, nil out the reference on Config, since the reader now owns it.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why pass a reference at all?
It doesn't seem like we need it, and it introduces some racy potential.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code was just moved, not changed. So I'm going to leave it as is for now.

if !filepath.IsLocal(header.Name) {
return xerrors.Errorf("refusing to extract to non-local path")
}
// nolint: gosec
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needs a comment please

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

More moved code, but why do we even need this comment?

Going to remove it

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Error: provisionersdk/tfpath/tfpath.go:107:17: G305: File traversal when extracting zip/tar archive (gosec)

TIL

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am going to leave the no lint and make an issue to follow up on this.

@Emyrk Emyrk force-pushed the stevenmasley/tf_workingdirectory branch from fc6bb0a to b88ee78 Compare November 12, 2025 14:52
@Emyrk Emyrk force-pushed the stevenmasley/tf_workingdirectory branch from b88ee78 to e8bf6de Compare November 12, 2025 15:01
@Emyrk Emyrk merged commit 7c8deaf into main Nov 12, 2025
26 checks passed
@Emyrk Emyrk deleted the stevenmasley/tf_workingdirectory branch November 12, 2025 15:24
@github-actions github-actions bot locked and limited conversation to collaborators Nov 12, 2025
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants