Skip to content

Commit

Permalink
feat: add promotion step to remove a file/directory (akuity#3086)
Browse files Browse the repository at this point in the history
Signed-off-by: nitishfy <[email protected]>
Signed-off-by: Kent Rancourt <[email protected]>
Co-authored-by: Hidde Beydals <[email protected]>
Co-authored-by: Kent Rancourt <[email protected]>
  • Loading branch information
3 people authored and fykaa committed Jan 16, 2025
1 parent 2073d4a commit aa9f32f
Show file tree
Hide file tree
Showing 7 changed files with 380 additions and 0 deletions.
40 changes: 40 additions & 0 deletions docs/docs/35-references/10-promotion-steps.md
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,46 @@ steps:
# Render manifests to ./out, commit, push, etc...
```

### `delete`

`delete` deletes a file or directory.

#### `delete` Configuration

| Name | Type | Required | Description |
|-----------|------|----------|------------------------------------------|
| `path` | `string` | Y | Path to the file or directory to delete. |

#### `delete` Example

One, common usage of this step is to remove intermediate files
produced by the promotion process prior to a `git-commit` step:

```yaml
vars:
- name: gitRepo
value: https://github.com/example/repo.git
steps:
- uses: git-clone
config:
repoURL: ${{ vars.gitRepo }}
checkout:
- commit: ${{ commitFrom(vars.gitRepo) }}
path: ./src
- branch: stage/${{ ctx.stage }}
create: true
path: ./out
# Steps that produce intermediate files in ./out...
- uses: delete
config:
path: ./out/unwanted/file
- uses: git-commit
config:
path: ./out
```

### `kustomize-set-image`

`kustomize-set-image` updates the `kustomization.yaml` file in a specified
Expand Down
151 changes: 151 additions & 0 deletions internal/directives/file_deleter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
package directives

import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"

securejoin "github.com/cyphar/filepath-securejoin"
"github.com/xeipuuv/gojsonschema"

kargoapi "github.com/akuity/kargo/api/v1alpha1"
)

func init() {
builtins.RegisterPromotionStepRunner(newFileDeleter(), nil)
}

// fileDeleter is an implementation of the PromotionStepRunner interface that
// deletes a file or directory.
type fileDeleter struct {
schemaLoader gojsonschema.JSONLoader
}

// newFileDeleter returns an implementation of the PromotionStepRunner interface
// that deletes a file or directory.
func newFileDeleter() PromotionStepRunner {
r := &fileDeleter{}
r.schemaLoader = getConfigSchemaLoader(r.Name())
return r
}

// Name implements the PromotionStepRunner interface
func (f *fileDeleter) Name() string {
return "delete"
}

// RunPromotionStep implements the PromotionStepRunner interface.
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) {
absPath, err := f.resolveAbsPath(stepCtx.WorkDir, cfg.Path)
if err != nil {
return PromotionStepResult{Status: kargoapi.PromotionPhaseErrored},
fmt.Errorf("could not secure join path %q: %w", cfg.Path, err)
}

symlink, err := f.isSymlink(absPath)
if err != nil {
return PromotionStepResult{Status: kargoapi.PromotionPhaseErrored}, err
}

if symlink {
err = os.Remove(absPath)
if err != nil {
return PromotionStepResult{Status: kargoapi.PromotionPhaseErrored}, err
}
} else {
// Secure join the paths to prevent path traversal.
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 (f *fileDeleter) isSymlink(path string) (bool, error) {
fi, err := os.Lstat(path)
if err != nil {
// if file doesn't exist, it's not a symlink
if os.IsNotExist(err) {
return false, nil
}
return false, err
}

return fi.Mode()&os.ModeSymlink != 0, nil
}

// resolveAbsPath resolves the absolute path from the workDir base path
func (f *fileDeleter) resolveAbsPath(workDir string, path string) (string, error) {
absBase, err := filepath.Abs(workDir)
if err != nil {
return "", err
}

fullPath := filepath.Join(workDir, path)
absPath, err := filepath.Abs(fullPath)
if err != nil {
return "", err
}

// Get the relative path from base to the requested path
// If the requested path tries to escape, this will return
// an error or a path starting with "../"
relPath, err := filepath.Rel(absBase, absPath)
if err != nil {
return "", err
}

// Check if path attempts to escape
if strings.HasPrefix(relPath, "..") {
return "", errors.New("path attempts to traverse outside the working directory")
}

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

import (
"context"
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

kargoapi "github.com/akuity/kargo/api/v1alpha1"
)

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, _ string, result PromotionStepResult, err error) {
assert.NoError(t, err)
assert.Equal(t, PromotionStepResult{Status: kargoapi.PromotionPhaseSucceeded}, result)

_, statError := os.Stat("input.txt")
assert.True(t, os.IsNotExist(statError))
},
},
{
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.True(t, os.IsNotExist(statErr))
},
},
{
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)
},
},
{
name: "removes symlink only",
setupFiles: func(t *testing.T) string {
tmpDir := t.TempDir()

inDir := filepath.Join(tmpDir, "input")
require.NoError(t, os.Mkdir(inDir, 0o755))

filePath := filepath.Join(inDir, "input.txt")
require.NoError(t, os.WriteFile(filePath, []byte("test content"), 0o600))

symlinkPath := filepath.Join(inDir, "symlink.txt")
require.NoError(t, os.Symlink("input.txt", symlinkPath))

return tmpDir
},
cfg: DeleteConfig{
Path: "input/symlink.txt",
},
assertions: func(t *testing.T, workDir string, result PromotionStepResult, err error) {
assert.NoError(t, err)
require.Equal(t, PromotionStepResult{Status: kargoapi.PromotionPhaseSucceeded}, result)

_, statErr := os.Stat(filepath.Join(workDir, "input", "input.txt"))
assert.NoError(t, statErr)

_, statErr = os.Lstat(filepath.Join(workDir, "input", "symlink.txt"))
assert.Error(t, statErr)
assert.True(t, os.IsNotExist(statErr))
},
},
{
name: "removes a file within a symlink",
setupFiles: func(t *testing.T) string {
tmpDir := t.TempDir()

inDir := filepath.Join(tmpDir, "bar")
require.NoError(t, os.Mkdir(inDir, 0o755))

filePath := filepath.Join(inDir, "file.txt")
require.NoError(t, os.WriteFile(filePath, []byte("test content"), 0o600))

symlinkPath := filepath.Join(tmpDir, "foo")
require.NoError(t, os.Symlink(inDir, symlinkPath))

return tmpDir
},
cfg: DeleteConfig{
Path: "foo/",
},
assertions: func(t *testing.T, workDir string, result PromotionStepResult, err error) {
assert.NoError(t, err)
require.Equal(t, PromotionStepResult{Status: kargoapi.PromotionPhaseSucceeded}, result)

_, statErr := os.Stat(filepath.Join(workDir, "foo", "file.txt"))
assert.Error(t, statErr)
assert.True(t, os.IsNotExist(statErr))

_, statErr = os.Stat(filepath.Join(workDir, "bar", "file.txt"))
assert.NoError(t, statErr)
},
},
}
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.

Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { JSONSchema7 } from 'json-schema';
// IMPORTANT(Marvin9): this must be replaced with proper discovery mechanism
import argocdUpdateConfig from '@ui/gen/directives/argocd-update-config.json';
import copyConfig from '@ui/gen/directives/copy-config.json';
import deleteConfig from '@ui/gen/directives/delete-config.json';
import gitOverwriteConfig from '@ui/gen/directives/git-clear-config.json';
import gitCloneConfig from '@ui/gen/directives/git-clone-config.json';
import gitCommitConfig from '@ui/gen/directives/git-commit-config.json';
Expand Down Expand Up @@ -33,6 +34,11 @@ export const useDiscoverPromotionDirectivesRegistries = (): PromotionDirectivesR
identifier: 'copy',
config: copyConfig as JSONSchema7
},
{
identifier: 'delete',
unstable_icons: [],
config: deleteConfig as JSONSchema7
},
{
identifier: 'git-clone',
config: gitCloneConfig as JSONSchema7
Expand Down
Loading

0 comments on commit aa9f32f

Please sign in to comment.