Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add promotion step to remove a file/directory #3086

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions internal/directives/file_deleter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package directives

import (
"context"
"fmt"

Check failure on line 5 in internal/directives/file_deleter.go

View workflow job for this annotation

GitHub Actions / lint-go

File is not `goimports`-ed with -local github.com/org/project (goimports)
kargoapi "github.com/akuity/kargo/api/v1alpha1"

Check failure on line 6 in internal/directives/file_deleter.go

View workflow job for this annotation

GitHub Actions / lint-go

File is not `gci`-ed with --skip-generated -s standard -s default -s prefix(github.com/akuity) -s dot -s blank --custom-order (gci)
securejoin "github.com/cyphar/filepath-securejoin"
"github.com/xeipuuv/gojsonschema"
"os"

Check failure on line 9 in internal/directives/file_deleter.go

View workflow job for this annotation

GitHub Actions / lint-go

File is not `gci`-ed with --skip-generated -s standard -s default -s prefix(github.com/akuity) -s dot -s blank --custom-order (gci)
)

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
}

Check warning on line 37 in internal/directives/file_deleter.go

View check run for this annotation

Codecov / codecov/patch

internal/directives/file_deleter.go#L33-L37

Added lines #L33 - L37 were not covered by tests

// 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)
}

Check warning on line 44 in internal/directives/file_deleter.go

View check run for this annotation

Codecov / codecov/patch

internal/directives/file_deleter.go#L40-L44

Added lines #L40 - L44 were not covered by tests

return f.runPromotionStep(ctx, stepCtx, cfg)

Check warning on line 46 in internal/directives/file_deleter.go

View check run for this annotation

Codecov / codecov/patch

internal/directives/file_deleter.go#L46

Added line #L46 was not covered by tests
}

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

Check warning on line 58 in internal/directives/file_deleter.go

View check run for this annotation

Codecov / codecov/patch

internal/directives/file_deleter.go#L56-L58

Added lines #L56 - L58 were not covered by tests

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)
}
88 changes: 88 additions & 0 deletions internal/directives/file_deleter_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package directives

import (
"context"
kargoapi "github.com/akuity/kargo/api/v1alpha1"

Check failure on line 5 in internal/directives/file_deleter_test.go

View workflow job for this annotation

GitHub Actions / lint-go

File is not `gci`-ed with --skip-generated -s standard -s default -s prefix(github.com/akuity) -s dot -s blank --custom-order (gci)
"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) {

Check warning on line 33 in internal/directives/file_deleter_test.go

View workflow job for this annotation

GitHub Actions / lint-go

unused-parameter: parameter 'workDir' seems to be unused, consider removing or renaming it as _ (revive)
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",
Copy link
Contributor

Choose a reason for hiding this comment

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

In addition to these tests, can you add:

  • One which removes a symlink (should remove the literal symlink, and not the file).
  • One which removes a file or directory within a symlink (should traverse into the symlink and remove it).

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)
})
}
}
14 changes: 14 additions & 0 deletions internal/directives/schemas/delete-config.json
Original file line number Diff line number Diff line change
@@ -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
}
}
}
5 changes: 5 additions & 0 deletions internal/directives/zz_config_types.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 13 additions & 0 deletions ui/src/gen/directives/delete-config.json
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Loading