diff --git a/internal/directives/file_deleter.go b/internal/directives/file_deleter.go new file mode 100644 index 0000000000..de012d5a5a --- /dev/null +++ b/internal/directives/file_deleter.go @@ -0,0 +1,79 @@ +package directives + +import ( + "context" + "fmt" + kargoapi "github.com/akuity/kargo/api/v1alpha1" + securejoin "github.com/cyphar/filepath-securejoin" + "github.com/xeipuuv/gojsonschema" + "os" +) + +func init() { + builtins.RegisterPromotionStepRunner(newFileDeleter(), nil) +} + +type fileDeleter struct { + schemaLoader gojsonschema.JSONLoader +} + +func newFileDeleter() PromotionStepRunner { + r := &fileDeleter{} + r.schemaLoader = getConfigSchemaLoader(r.Name()) + return r +} + +func (f *fileDeleter) Name() string { + return "delete" +} + +func (f *fileDeleter) RunPromotionStep( + ctx context.Context, + stepCtx *PromotionStepContext, +) (PromotionStepResult, error) { + // Validate the configuration against the JSON Schema. + if err := validate(f.schemaLoader, gojsonschema.NewGoLoader(stepCtx.Config), f.Name()); err != nil { + return PromotionStepResult{Status: kargoapi.PromotionPhaseErrored}, err + } + + // Convert the configuration into a typed object. + cfg, err := ConfigToStruct[DeleteConfig](stepCtx.Config) + if err != nil { + return PromotionStepResult{Status: kargoapi.PromotionPhaseErrored}, + fmt.Errorf("could not convert config into %s config: %w", f.Name(), err) + } + + return f.runPromotionStep(ctx, stepCtx, cfg) +} + +func (f *fileDeleter) runPromotionStep( + _ context.Context, + stepCtx *PromotionStepContext, + cfg DeleteConfig, +) (PromotionStepResult, error) { + pathToDelete, err := securejoin.SecureJoin(stepCtx.WorkDir, cfg.Path) + if err != nil { + return PromotionStepResult{Status: kargoapi.PromotionPhaseErrored}, + fmt.Errorf("could not secure join path %q: %w", cfg.Path, err) + } + + if err = removePath(pathToDelete); err != nil { + return PromotionStepResult{Status: kargoapi.PromotionPhaseErrored}, + fmt.Errorf("failed to delete %q: %w", cfg.Path, sanitizePathError(err, stepCtx.WorkDir)) + } + + return PromotionStepResult{Status: kargoapi.PromotionPhaseSucceeded}, nil +} + +func removePath(path string) error { + fi, err := os.Lstat(path) + if err != nil { + return err + } + + if fi.IsDir() { + return os.RemoveAll(path) + } + + return os.Remove(path) +} diff --git a/internal/directives/file_deleter_test.go b/internal/directives/file_deleter_test.go new file mode 100644 index 0000000000..b044ef81b3 --- /dev/null +++ b/internal/directives/file_deleter_test.go @@ -0,0 +1,88 @@ +package directives + +import ( + "context" + kargoapi "github.com/akuity/kargo/api/v1alpha1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "os" + "path/filepath" + "testing" +) + +func Test_fileDeleter_runPromotionStep(t *testing.T) { + tests := []struct { + name string + setupFiles func(*testing.T) string + cfg DeleteConfig + assertions func(*testing.T, string, PromotionStepResult, error) + }{ + { + name: "succeeds deleting file", + setupFiles: func(t *testing.T) string { + tmpDir := t.TempDir() + + path := filepath.Join(tmpDir, "input.txt") + require.NoError(t, os.WriteFile(path, []byte("test content"), 0o600)) + + return tmpDir + }, + cfg: DeleteConfig{ + Path: "input.txt", + }, + assertions: func(t *testing.T, workDir string, result PromotionStepResult, err error) { + assert.NoError(t, err) + assert.Equal(t, PromotionStepResult{Status: kargoapi.PromotionPhaseSucceeded}, result) + + _, statError := os.Stat("input.txt") + assert.ErrorIs(t, statError, os.ErrNotExist) + }, + }, + { + name: "succeeds deleting directory", + setupFiles: func(t *testing.T) string { + tmpDir := t.TempDir() + dirPath := filepath.Join(tmpDir, "dirToDelete") + require.NoError(t, os.Mkdir(dirPath, 0o700)) + return tmpDir + }, + cfg: DeleteConfig{ + Path: "dirToDelete", + }, + assertions: func(t *testing.T, workDir string, result PromotionStepResult, err error) { + assert.NoError(t, err) + assert.Equal(t, PromotionStepResult{Status: kargoapi.PromotionPhaseSucceeded}, result) + + _, statErr := os.Stat(filepath.Join(workDir, "dirToDelete")) + assert.ErrorIs(t, statErr, os.ErrNotExist) + }, + }, + { + name: "fails for non-existent path", + setupFiles: func(t *testing.T) string { + return t.TempDir() + }, + cfg: DeleteConfig{ + Path: "nonExistentFile.txt", + }, + assertions: func(t *testing.T, _ string, result PromotionStepResult, err error) { + assert.Error(t, err) + assert.Equal(t, PromotionStepResult{Status: kargoapi.PromotionPhaseErrored}, result) + }, + }, + } + + runner := &fileDeleter{} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + workDir := tt.setupFiles(t) + result, err := runner.runPromotionStep( + context.Background(), + &PromotionStepContext{WorkDir: workDir}, + tt.cfg, + ) + tt.assertions(t, workDir, result, err) + }) + } +} diff --git a/internal/directives/schemas/delete-config.json b/internal/directives/schemas/delete-config.json new file mode 100644 index 0000000000..0a0265bf15 --- /dev/null +++ b/internal/directives/schemas/delete-config.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "DeleteConfig", + "type": "object", + "additionalProperties": false, + "required": ["Path"], + "properties": { + "Path": { + "type": "string", + "description": "Path is the path to the file or directory to delete.", + "minLength": 1 + } + } +} diff --git a/internal/directives/zz_config_types.go b/internal/directives/zz_config_types.go index 6a9f964de0..959cfd5a24 100644 --- a/internal/directives/zz_config_types.go +++ b/internal/directives/zz_config_types.go @@ -117,6 +117,11 @@ type CopyConfig struct { OutPath string `json:"outPath"` } +type DeleteConfig struct { + // Path is the path to the file or directory to delete. + Path string `json:"Path"` +} + type GitClearConfig struct { // Path to a working directory of a local repository from which to remove all files, // excluding the .git/ directory. diff --git a/ui/src/gen/directives/delete-config.json b/ui/src/gen/directives/delete-config.json new file mode 100644 index 0000000000..fef437ae50 --- /dev/null +++ b/ui/src/gen/directives/delete-config.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "DeleteConfig", + "type": "object", + "additionalProperties": false, + "properties": { + "Path": { + "type": "string", + "description": "Path is the path to the file or directory to delete.", + "minLength": 1 + } + } +} \ No newline at end of file