mirror of https://github.com/coder/coder.git
feat: clean stale provisioner files (#9545)
This commit is contained in:
parent
d055f93706
commit
67fe3ae8d6
6
Makefile
6
Makefile
|
@ -570,7 +570,7 @@ coderd/apidoc/swagger.json: $(shell find ./scripts/apidocgen $(FIND_EXCLUSIONS)
|
|||
./scripts/apidocgen/generate.sh
|
||||
pnpm run format:write:only ./docs/api ./docs/manifest.json ./coderd/apidoc/swagger.json
|
||||
|
||||
update-golden-files: cli/testdata/.gen-golden helm/coder/tests/testdata/.gen-golden helm/provisioner/tests/testdata/.gen-golden scripts/ci-report/testdata/.gen-golden enterprise/cli/testdata/.gen-golden coderd/.gen-golden
|
||||
update-golden-files: cli/testdata/.gen-golden helm/coder/tests/testdata/.gen-golden helm/provisioner/tests/testdata/.gen-golden scripts/ci-report/testdata/.gen-golden enterprise/cli/testdata/.gen-golden coderd/.gen-golden provisioner/terraform/testdata/.gen-golden
|
||||
.PHONY: update-golden-files
|
||||
|
||||
cli/testdata/.gen-golden: $(wildcard cli/testdata/*.golden) $(wildcard cli/*.tpl) $(GO_SRC_FILES) $(wildcard cli/*_test.go)
|
||||
|
@ -593,6 +593,10 @@ coderd/.gen-golden: $(wildcard coderd/testdata/*/*.golden) $(GO_SRC_FILES) $(wil
|
|||
go test ./coderd -run="Test.*Golden$$" -update
|
||||
touch "$@"
|
||||
|
||||
provisioner/terraform/testdata/.gen-golden: $(wildcard provisioner/terraform/testdata/*/*.golden) $(GO_SRC_FILES) $(wildcard provisioner/terraform/*_test.go)
|
||||
go test ./provisioner/terraform -run="Test.*Golden$$" -update
|
||||
touch "$@"
|
||||
|
||||
scripts/ci-report/testdata/.gen-golden: $(wildcard scripts/ci-report/testdata/*) $(wildcard scripts/ci-report/*.go)
|
||||
go test ./scripts/ci-report -run=TestOutputMatchesGoldenFile -update
|
||||
touch "$@"
|
||||
|
|
2
go.mod
2
go.mod
|
@ -196,6 +196,8 @@ require (
|
|||
tailscale.com v1.46.1
|
||||
)
|
||||
|
||||
require github.com/djherbis/times v1.5.0
|
||||
|
||||
require (
|
||||
cloud.google.com/go/compute v1.23.0 // indirect
|
||||
cloud.google.com/go/logging v1.8.1 // indirect
|
||||
|
|
2
go.sum
2
go.sum
|
@ -267,6 +267,8 @@ github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WA
|
|||
github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48 h1:fRzb/w+pyskVMQ+UbP35JkH8yB7MYb4q/qhBarqZE6g=
|
||||
github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
|
||||
github.com/dhui/dktest v0.3.16 h1:i6gq2YQEtcrjKbeJpBkWjE8MmLZPYllcjOFbTZuPDnw=
|
||||
github.com/djherbis/times v1.5.0 h1:79myA211VwPhFTqUk8xehWrsEO+zcIZj0zT8mXPVARU=
|
||||
github.com/djherbis/times v1.5.0/go.mod h1:5q7FDLvbNg1L/KaBmPcWlVR9NmoKo3+ucqUA3ijQhA0=
|
||||
github.com/dlclark/regexp2 v1.7.0 h1:7lJfhqlPssTb1WQx4yvTHN0uElPEv52sbaECrAQxjAo=
|
||||
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/docker/cli v23.0.5+incompatible h1:ufWmAOuD3Vmr7JP2G5K3cyuNC4YZWiAsuDEvFVVDafE=
|
||||
|
|
|
@ -0,0 +1,153 @@
|
|||
package terraform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/djherbis/times"
|
||||
"github.com/spf13/afero"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
)
|
||||
|
||||
// CleanStaleTerraformPlugins browses the Terraform cache directory
|
||||
// and remove stale plugins that haven't been used for a while.
|
||||
// Additionally, it sweeps empty, old directory trees.
|
||||
//
|
||||
// Sample cachePath:
|
||||
//
|
||||
// /Users/john.doe/Library/Caches/coder/provisioner-1/tf
|
||||
// /tmp/coder/provisioner-0/tf
|
||||
func CleanStaleTerraformPlugins(ctx context.Context, cachePath string, fs afero.Fs, now time.Time, logger slog.Logger) error {
|
||||
cachePath, err := filepath.Abs(cachePath) // sanity check in case the path is e.g. ../../../cache
|
||||
if err != nil {
|
||||
return xerrors.Errorf("unable to determine absolute path %q: %w", cachePath, err)
|
||||
}
|
||||
|
||||
// Firstly, check if the cache path exists.
|
||||
_, err = fs.Stat(cachePath)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
} else if err != nil {
|
||||
return xerrors.Errorf("unable to stat cache path %q: %w", cachePath, err)
|
||||
}
|
||||
|
||||
logger.Info(ctx, "clean stale Terraform plugins", slog.F("cache_path", cachePath))
|
||||
|
||||
// Filter directory trees matching pattern: <repositoryURL>/<company>/<plugin>/<version>/<distribution>
|
||||
filterFunc := func(path string, info os.FileInfo) bool {
|
||||
if !info.IsDir() {
|
||||
return false
|
||||
}
|
||||
|
||||
relativePath, err := filepath.Rel(cachePath, path)
|
||||
if err != nil {
|
||||
logger.Error(ctx, "unable to evaluate a relative path", slog.F("base", cachePath), slog.F("target", path), slog.Error(err))
|
||||
return false
|
||||
}
|
||||
|
||||
parts := strings.Split(relativePath, string(filepath.Separator))
|
||||
return len(parts) == 5
|
||||
}
|
||||
|
||||
// Review cached Terraform plugins
|
||||
var pluginPaths []string
|
||||
err = afero.Walk(fs, cachePath, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !filterFunc(path, info) {
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Debug(ctx, "plugin directory discovered", slog.F("path", path))
|
||||
pluginPaths = append(pluginPaths, path)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("unable to walk through cache directory %q: %w", cachePath, err)
|
||||
}
|
||||
|
||||
// Identify stale plugins
|
||||
var stalePlugins []string
|
||||
for _, pluginPath := range pluginPaths {
|
||||
accessTime, err := latestAccessTime(fs, pluginPath)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("unable to evaluate latest access time for directory %q: %w", pluginPath, err)
|
||||
}
|
||||
|
||||
if accessTime.Add(staleTerraformPluginRetention).Before(now) {
|
||||
logger.Info(ctx, "plugin directory is stale and will be removed", slog.F("plugin_path", pluginPath))
|
||||
stalePlugins = append(stalePlugins, pluginPath)
|
||||
} else {
|
||||
logger.Debug(ctx, "plugin directory is not stale", slog.F("plugin_path", pluginPath))
|
||||
}
|
||||
}
|
||||
|
||||
// Remove stale plugins
|
||||
for _, stalePluginPath := range stalePlugins {
|
||||
// Remove the plugin directory
|
||||
err = fs.RemoveAll(stalePluginPath)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("unable to remove stale plugin %q: %w", stalePluginPath, err)
|
||||
}
|
||||
|
||||
// Compact the plugin structure by removing empty directories.
|
||||
wd := stalePluginPath
|
||||
level := 5 // <repositoryURL>/<company>/<plugin>/<version>/<distribution>
|
||||
for {
|
||||
level--
|
||||
if level == 0 {
|
||||
break // do not compact further
|
||||
}
|
||||
|
||||
wd = filepath.Dir(wd)
|
||||
|
||||
files, err := afero.ReadDir(fs, wd)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("unable to read directory content %q: %w", wd, err)
|
||||
}
|
||||
|
||||
if len(files) > 0 {
|
||||
break // there are still other plugins
|
||||
}
|
||||
|
||||
logger.Debug(ctx, "remove empty directory", slog.F("path", wd))
|
||||
err = fs.Remove(wd)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("unable to remove directory %q: %w", wd, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// latestAccessTime walks recursively through the directory content, and locates
|
||||
// the last accessed file.
|
||||
func latestAccessTime(fs afero.Fs, pluginPath string) (time.Time, error) {
|
||||
var latest time.Time
|
||||
err := afero.Walk(fs, pluginPath, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
accessTime := info.ModTime() // fallback to modTime if accessTime is not available (afero)
|
||||
if info.Sys() != nil {
|
||||
timeSpec := times.Get(info)
|
||||
accessTime = timeSpec.AccessTime()
|
||||
}
|
||||
if latest.Before(accessTime) {
|
||||
latest = accessTime
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return time.Time{}, xerrors.Errorf("unable to walk the plugin path %q: %w", pluginPath, err)
|
||||
}
|
||||
return latest, nil
|
||||
}
|
|
@ -0,0 +1,186 @@
|
|||
//go:build linux || darwin
|
||||
|
||||
package terraform_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"flag"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
"github.com/coder/coder/v2/provisioner/terraform"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
const cachePath = "/tmp/coder/provisioner-0/tf"
|
||||
|
||||
// updateGoldenFiles is a flag that can be set to update golden files.
|
||||
var updateGoldenFiles = flag.Bool("update", false, "Update golden files")
|
||||
|
||||
var (
|
||||
coderPluginPath = filepath.Join("registry.terraform.io", "coder", "coder", "0.11.1", "darwin_arm64")
|
||||
dockerPluginPath = filepath.Join("registry.terraform.io", "kreuzwerker", "docker", "2.25.0", "darwin_arm64")
|
||||
)
|
||||
|
||||
func TestPluginCache_Golden(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
prepare := func() (afero.Fs, time.Time, slog.Logger) {
|
||||
fs := afero.NewMemMapFs()
|
||||
now := time.Date(2023, time.June, 3, 4, 5, 6, 0, time.UTC)
|
||||
logger := slogtest.Make(t, nil).
|
||||
Leveled(slog.LevelDebug).
|
||||
Named("cleanup-test")
|
||||
return fs, now, logger
|
||||
}
|
||||
|
||||
t.Run("all plugins are stale", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||
defer cancel()
|
||||
|
||||
fs, now, logger := prepare()
|
||||
|
||||
// given
|
||||
// This plugin is older than 30 days.
|
||||
addPluginFile(t, fs, coderPluginPath, "terraform-provider-coder_v0.11.1", now.Add(-63*24*time.Hour))
|
||||
addPluginFile(t, fs, coderPluginPath, "LICENSE", now.Add(-33*24*time.Hour))
|
||||
addPluginFile(t, fs, coderPluginPath, "README.md", now.Add(-31*24*time.Hour))
|
||||
addPluginFolder(t, fs, coderPluginPath, "new_folder", now.Add(-31*24*time.Hour))
|
||||
addPluginFile(t, fs, coderPluginPath, filepath.Join("new_folder", "foobar.tf"), now.Add(-43*24*time.Hour))
|
||||
|
||||
// This plugin is older than 30 days.
|
||||
addPluginFile(t, fs, dockerPluginPath, "terraform-provider-docker_v2.25.0", now.Add(-31*24*time.Hour))
|
||||
addPluginFile(t, fs, dockerPluginPath, "LICENSE", now.Add(-32*24*time.Hour))
|
||||
addPluginFile(t, fs, dockerPluginPath, "README.md", now.Add(-33*24*time.Hour))
|
||||
|
||||
// when
|
||||
terraform.CleanStaleTerraformPlugins(ctx, cachePath, fs, now, logger)
|
||||
|
||||
// then
|
||||
diffFileSystem(t, fs)
|
||||
})
|
||||
|
||||
t.Run("one plugin is stale", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||
defer cancel()
|
||||
|
||||
fs, now, logger := prepare()
|
||||
|
||||
// given
|
||||
addPluginFile(t, fs, coderPluginPath, "terraform-provider-coder_v0.11.1", now.Add(-2*time.Hour))
|
||||
addPluginFile(t, fs, coderPluginPath, "LICENSE", now.Add(-3*time.Hour))
|
||||
addPluginFile(t, fs, coderPluginPath, "README.md", now.Add(-4*time.Hour))
|
||||
addPluginFolder(t, fs, coderPluginPath, "new_folder", now.Add(-5*time.Hour))
|
||||
addPluginFile(t, fs, coderPluginPath, filepath.Join("new_folder", "foobar.tf"), now.Add(-4*time.Hour))
|
||||
|
||||
// This plugin is older than 30 days.
|
||||
addPluginFile(t, fs, dockerPluginPath, "terraform-provider-docker_v2.25.0", now.Add(-31*24*time.Hour))
|
||||
addPluginFile(t, fs, dockerPluginPath, "LICENSE", now.Add(-32*24*time.Hour))
|
||||
addPluginFile(t, fs, dockerPluginPath, "README.md", now.Add(-33*24*time.Hour))
|
||||
|
||||
// when
|
||||
terraform.CleanStaleTerraformPlugins(ctx, cachePath, fs, now, logger)
|
||||
|
||||
// then
|
||||
diffFileSystem(t, fs)
|
||||
})
|
||||
|
||||
t.Run("one plugin file is touched", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||
defer cancel()
|
||||
|
||||
fs, now, logger := prepare()
|
||||
|
||||
// given
|
||||
addPluginFile(t, fs, coderPluginPath, "terraform-provider-coder_v0.11.1", now.Add(-63*24*time.Hour))
|
||||
addPluginFile(t, fs, coderPluginPath, "LICENSE", now.Add(-33*24*time.Hour))
|
||||
addPluginFile(t, fs, coderPluginPath, "README.md", now.Add(-31*24*time.Hour))
|
||||
addPluginFolder(t, fs, coderPluginPath, "new_folder", now.Add(-4*time.Hour)) // touched
|
||||
addPluginFile(t, fs, coderPluginPath, filepath.Join("new_folder", "foobar.tf"), now.Add(-43*24*time.Hour))
|
||||
|
||||
addPluginFile(t, fs, dockerPluginPath, "terraform-provider-docker_v2.25.0", now.Add(-31*24*time.Hour))
|
||||
addPluginFile(t, fs, dockerPluginPath, "LICENSE", now.Add(-2*time.Hour))
|
||||
addPluginFile(t, fs, dockerPluginPath, "README.md", now.Add(-33*24*time.Hour))
|
||||
|
||||
// when
|
||||
terraform.CleanStaleTerraformPlugins(ctx, cachePath, fs, now, logger)
|
||||
|
||||
// then
|
||||
diffFileSystem(t, fs)
|
||||
})
|
||||
}
|
||||
|
||||
func addPluginFile(t *testing.T, fs afero.Fs, pluginPath string, resourcePath string, accessTime time.Time) {
|
||||
err := fs.MkdirAll(filepath.Join(cachePath, pluginPath), 0o755)
|
||||
require.NoError(t, err, "can't create test folder for plugin file")
|
||||
|
||||
err = fs.Chtimes(filepath.Join(cachePath, pluginPath), accessTime, accessTime)
|
||||
require.NoError(t, err, "can't set times")
|
||||
|
||||
err = afero.WriteFile(fs, filepath.Join(cachePath, pluginPath, resourcePath), []byte("foo"), 0o644)
|
||||
require.NoError(t, err, "can't create test file")
|
||||
|
||||
err = fs.Chtimes(filepath.Join(cachePath, pluginPath, resourcePath), accessTime, accessTime)
|
||||
require.NoError(t, err, "can't set times")
|
||||
}
|
||||
|
||||
func addPluginFolder(t *testing.T, fs afero.Fs, pluginPath string, folderPath string, accessTime time.Time) {
|
||||
err := fs.MkdirAll(filepath.Join(cachePath, pluginPath, folderPath), 0o755)
|
||||
require.NoError(t, err, "can't create plugin folder")
|
||||
|
||||
err = fs.Chtimes(filepath.Join(cachePath, pluginPath, folderPath), accessTime, accessTime)
|
||||
require.NoError(t, err, "can't set times")
|
||||
}
|
||||
|
||||
func diffFileSystem(t *testing.T, fs afero.Fs) {
|
||||
actual := dumpFileSystem(t, fs)
|
||||
|
||||
partialName := strings.Join(strings.Split(t.Name(), "/")[1:], "_")
|
||||
goldenFile := filepath.Join("testdata", "cleanup-stale-plugins", partialName+".txt.golden")
|
||||
if *updateGoldenFiles {
|
||||
err := os.MkdirAll(filepath.Dir(goldenFile), 0o755)
|
||||
require.NoError(t, err, "want no error creating golden file directory")
|
||||
|
||||
err = os.WriteFile(goldenFile, actual, 0o600)
|
||||
require.NoError(t, err, "want no error creating golden file")
|
||||
return
|
||||
}
|
||||
|
||||
want, err := os.ReadFile(goldenFile)
|
||||
require.NoError(t, err, "open golden file, run \"make update-golden-files\" and commit the changes")
|
||||
assert.Empty(t, cmp.Diff(want, actual), "golden file mismatch (-want +got): %s, run \"make update-golden-files\", verify and commit the changes", goldenFile)
|
||||
}
|
||||
|
||||
func dumpFileSystem(t *testing.T, fs afero.Fs) []byte {
|
||||
var buffer bytes.Buffer
|
||||
err := afero.Walk(fs, "/", func(path string, info os.FileInfo, err error) error {
|
||||
_, _ = buffer.WriteString(path)
|
||||
_ = buffer.WriteByte(' ')
|
||||
if info.IsDir() {
|
||||
_ = buffer.WriteByte('d')
|
||||
} else {
|
||||
_ = buffer.WriteByte('f')
|
||||
}
|
||||
_ = buffer.WriteByte('\n')
|
||||
return nil
|
||||
})
|
||||
require.NoError(t, err, "can't dump the file system")
|
||||
return buffer.Bytes()
|
||||
}
|
|
@ -7,14 +7,18 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"github.com/coder/terraform-provider-coder/provider"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/tracing"
|
||||
"github.com/coder/coder/v2/provisionersdk"
|
||||
"github.com/coder/coder/v2/provisionersdk/proto"
|
||||
"github.com/coder/terraform-provider-coder/provider"
|
||||
)
|
||||
|
||||
const staleTerraformPluginRetention = 30 * 24 * time.Hour
|
||||
|
||||
func (s *server) setupContexts(parent context.Context, canceledOrComplete <-chan struct{}) (
|
||||
ctx context.Context, cancel func(), killCtx context.Context, kill func(),
|
||||
) {
|
||||
|
@ -89,8 +93,13 @@ func (s *server) Plan(
|
|||
}
|
||||
}
|
||||
|
||||
err := CleanStaleTerraformPlugins(sess.Context(), s.cachePath, afero.NewOsFs(), time.Now(), s.logger)
|
||||
if err != nil {
|
||||
return provisionersdk.PlanErrorf("unable to clean stale Terraform plugins: %s", err)
|
||||
}
|
||||
|
||||
s.logger.Debug(ctx, "running initialization")
|
||||
err := e.init(ctx, killCtx, sess)
|
||||
err = e.init(ctx, killCtx, sess)
|
||||
if err != nil {
|
||||
s.logger.Debug(ctx, "init failed", slog.Error(err))
|
||||
return provisionersdk.PlanErrorf("initialize terraform: %s", err)
|
||||
|
|
5
provisioner/terraform/testdata/cleanup-stale-plugins/all_plugins_are_stale.txt.golden
vendored
Normal file
5
provisioner/terraform/testdata/cleanup-stale-plugins/all_plugins_are_stale.txt.golden
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
/ d
|
||||
/tmp d
|
||||
/tmp/coder d
|
||||
/tmp/coder/provisioner-0 d
|
||||
/tmp/coder/provisioner-0/tf d
|
22
provisioner/terraform/testdata/cleanup-stale-plugins/one_plugin_file_is_touched.txt.golden
vendored
Normal file
22
provisioner/terraform/testdata/cleanup-stale-plugins/one_plugin_file_is_touched.txt.golden
vendored
Normal file
|
@ -0,0 +1,22 @@
|
|||
/ d
|
||||
/tmp d
|
||||
/tmp/coder d
|
||||
/tmp/coder/provisioner-0 d
|
||||
/tmp/coder/provisioner-0/tf d
|
||||
/tmp/coder/provisioner-0/tf/registry.terraform.io d
|
||||
/tmp/coder/provisioner-0/tf/registry.terraform.io/coder d
|
||||
/tmp/coder/provisioner-0/tf/registry.terraform.io/coder/coder d
|
||||
/tmp/coder/provisioner-0/tf/registry.terraform.io/coder/coder/0.11.1 d
|
||||
/tmp/coder/provisioner-0/tf/registry.terraform.io/coder/coder/0.11.1/darwin_arm64 d
|
||||
/tmp/coder/provisioner-0/tf/registry.terraform.io/coder/coder/0.11.1/darwin_arm64/LICENSE f
|
||||
/tmp/coder/provisioner-0/tf/registry.terraform.io/coder/coder/0.11.1/darwin_arm64/README.md f
|
||||
/tmp/coder/provisioner-0/tf/registry.terraform.io/coder/coder/0.11.1/darwin_arm64/new_folder d
|
||||
/tmp/coder/provisioner-0/tf/registry.terraform.io/coder/coder/0.11.1/darwin_arm64/new_folder/foobar.tf f
|
||||
/tmp/coder/provisioner-0/tf/registry.terraform.io/coder/coder/0.11.1/darwin_arm64/terraform-provider-coder_v0.11.1 f
|
||||
/tmp/coder/provisioner-0/tf/registry.terraform.io/kreuzwerker d
|
||||
/tmp/coder/provisioner-0/tf/registry.terraform.io/kreuzwerker/docker d
|
||||
/tmp/coder/provisioner-0/tf/registry.terraform.io/kreuzwerker/docker/2.25.0 d
|
||||
/tmp/coder/provisioner-0/tf/registry.terraform.io/kreuzwerker/docker/2.25.0/darwin_arm64 d
|
||||
/tmp/coder/provisioner-0/tf/registry.terraform.io/kreuzwerker/docker/2.25.0/darwin_arm64/LICENSE f
|
||||
/tmp/coder/provisioner-0/tf/registry.terraform.io/kreuzwerker/docker/2.25.0/darwin_arm64/README.md f
|
||||
/tmp/coder/provisioner-0/tf/registry.terraform.io/kreuzwerker/docker/2.25.0/darwin_arm64/terraform-provider-docker_v2.25.0 f
|
15
provisioner/terraform/testdata/cleanup-stale-plugins/one_plugin_is_stale.txt.golden
vendored
Normal file
15
provisioner/terraform/testdata/cleanup-stale-plugins/one_plugin_is_stale.txt.golden
vendored
Normal file
|
@ -0,0 +1,15 @@
|
|||
/ d
|
||||
/tmp d
|
||||
/tmp/coder d
|
||||
/tmp/coder/provisioner-0 d
|
||||
/tmp/coder/provisioner-0/tf d
|
||||
/tmp/coder/provisioner-0/tf/registry.terraform.io d
|
||||
/tmp/coder/provisioner-0/tf/registry.terraform.io/coder d
|
||||
/tmp/coder/provisioner-0/tf/registry.terraform.io/coder/coder d
|
||||
/tmp/coder/provisioner-0/tf/registry.terraform.io/coder/coder/0.11.1 d
|
||||
/tmp/coder/provisioner-0/tf/registry.terraform.io/coder/coder/0.11.1/darwin_arm64 d
|
||||
/tmp/coder/provisioner-0/tf/registry.terraform.io/coder/coder/0.11.1/darwin_arm64/LICENSE f
|
||||
/tmp/coder/provisioner-0/tf/registry.terraform.io/coder/coder/0.11.1/darwin_arm64/README.md f
|
||||
/tmp/coder/provisioner-0/tf/registry.terraform.io/coder/coder/0.11.1/darwin_arm64/new_folder d
|
||||
/tmp/coder/provisioner-0/tf/registry.terraform.io/coder/coder/0.11.1/darwin_arm64/new_folder/foobar.tf f
|
||||
/tmp/coder/provisioner-0/tf/registry.terraform.io/coder/coder/0.11.1/darwin_arm64/terraform-provider-coder_v0.11.1 f
|
|
@ -13,6 +13,12 @@ for d in */; do
|
|||
continue
|
||||
fi
|
||||
|
||||
# This directory is used for a different purpose (quick workaround).
|
||||
if [[ $name == "cleanup-stale-plugins" ]]; then
|
||||
popd
|
||||
continue
|
||||
fi
|
||||
|
||||
terraform init -upgrade
|
||||
terraform plan -out terraform.tfplan
|
||||
terraform show -json ./terraform.tfplan | jq >"$name".tfplan.json
|
||||
|
|
|
@ -0,0 +1,53 @@
|
|||
package provisionersdk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/djherbis/times"
|
||||
"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)
|
||||
|
||||
accessTime := fi.ModTime() // fallback to modTime if accessTime is not available (afero)
|
||||
if fi.Sys() != nil {
|
||||
timeSpec := times.Get(fi)
|
||||
accessTime = timeSpec.AccessTime()
|
||||
}
|
||||
|
||||
if accessTime.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
|
||||
}
|
|
@ -0,0 +1,112 @@
|
|||
package provisionersdk_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
|
||||
"github.com/coder/coder/v2/provisionersdk"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
const workDirectory = "/tmp/coder/provisioner-34/work"
|
||||
|
||||
func TestStaleSessions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
prepare := func() (afero.Fs, time.Time, slog.Logger) {
|
||||
fs := afero.NewMemMapFs()
|
||||
now := time.Date(2023, time.June, 3, 4, 5, 6, 0, time.UTC)
|
||||
logger := slogtest.Make(t, nil).
|
||||
Leveled(slog.LevelDebug).
|
||||
Named("cleanup-test")
|
||||
return fs, now, logger
|
||||
}
|
||||
|
||||
t.Run("all sessions are stale", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||
defer cancel()
|
||||
|
||||
fs, now, logger := prepare()
|
||||
|
||||
// given
|
||||
first := provisionersdk.SessionDir(uuid.NewString())
|
||||
addSessionFolder(t, fs, first, now.Add(-7*24*time.Hour))
|
||||
second := provisionersdk.SessionDir(uuid.NewString())
|
||||
addSessionFolder(t, fs, second, now.Add(-8*24*time.Hour))
|
||||
third := provisionersdk.SessionDir(uuid.NewString())
|
||||
addSessionFolder(t, fs, third, now.Add(-9*24*time.Hour))
|
||||
|
||||
// when
|
||||
provisionersdk.CleanStaleSessions(ctx, workDirectory, fs, now, logger)
|
||||
|
||||
// then
|
||||
entries, err := afero.ReadDir(fs, workDirectory)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, entries, "all session leftovers should be removed")
|
||||
})
|
||||
|
||||
t.Run("one session is stale", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||
defer cancel()
|
||||
|
||||
fs, now, logger := prepare()
|
||||
|
||||
// given
|
||||
first := provisionersdk.SessionDir(uuid.NewString())
|
||||
addSessionFolder(t, fs, first, now.Add(-7*24*time.Hour))
|
||||
second := provisionersdk.SessionDir(uuid.NewString())
|
||||
addSessionFolder(t, fs, second, now.Add(-6*24*time.Hour))
|
||||
|
||||
// when
|
||||
provisionersdk.CleanStaleSessions(ctx, workDirectory, fs, now, logger)
|
||||
|
||||
// then
|
||||
entries, err := afero.ReadDir(fs, workDirectory)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, entries, 1, "one session should be present")
|
||||
require.Equal(t, second, entries[0].Name(), 1)
|
||||
})
|
||||
|
||||
t.Run("no stale sessions", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||
defer cancel()
|
||||
|
||||
fs, now, logger := prepare()
|
||||
|
||||
// given
|
||||
first := provisionersdk.SessionDir(uuid.NewString())
|
||||
addSessionFolder(t, fs, first, now.Add(-6*24*time.Hour))
|
||||
second := provisionersdk.SessionDir(uuid.NewString())
|
||||
addSessionFolder(t, fs, second, now.Add(-5*24*time.Hour))
|
||||
|
||||
// when
|
||||
provisionersdk.CleanStaleSessions(ctx, workDirectory, fs, now, logger)
|
||||
|
||||
// then
|
||||
entries, err := afero.ReadDir(fs, workDirectory)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, entries, 2, "both sessions should be present")
|
||||
})
|
||||
}
|
||||
|
||||
func addSessionFolder(t *testing.T, fs afero.Fs, sessionName string, accessTime time.Time) {
|
||||
err := fs.MkdirAll(filepath.Join(workDirectory, sessionName), 0o755)
|
||||
require.NoError(t, err, "can't create session folder")
|
||||
fs.Chtimes(filepath.Join(workDirectory, sessionName), accessTime, accessTime)
|
||||
require.NoError(t, err, "can't set times")
|
||||
}
|
|
@ -12,15 +12,21 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/spf13/afero"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
|
||||
"github.com/coder/coder/v2/provisionersdk/proto"
|
||||
)
|
||||
|
||||
// ReadmeFile is the location we look for to extract documentation from template
|
||||
// versions.
|
||||
const ReadmeFile = "README.md"
|
||||
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 {
|
||||
|
@ -35,9 +41,14 @@ func (p *protoServer) Session(stream proto.DRPCProvisioner_SessionStream) error
|
|||
stream: stream,
|
||||
server: p.server,
|
||||
}
|
||||
sessDir := fmt.Sprintf("Session%s", sessID)
|
||||
s.WorkDirectory = filepath.Join(p.opts.WorkDirectory, sessDir)
|
||||
err := os.MkdirAll(s.WorkDirectory, 0o700)
|
||||
|
||||
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.WorkDirectory, err)
|
||||
}
|
||||
|
||||
s.WorkDirectory = filepath.Join(p.opts.WorkDirectory, SessionDir(sessID))
|
||||
err = os.MkdirAll(s.WorkDirectory, 0o700)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create work directory %q: %w", s.WorkDirectory, err)
|
||||
}
|
||||
|
@ -316,3 +327,8 @@ func (r *request[R, C]) do() (C, error) {
|
|||
return c, nil
|
||||
}
|
||||
}
|
||||
|
||||
// SessionDir returns the directory name with mandatory prefix.
|
||||
func SessionDir(sessID string) string {
|
||||
return sessionDirPrefix + sessID
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue