Skip to content
Merged
Show file tree
Hide file tree
Changes from 45 commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
4f5044c
docs: add spec for data transformation CEL functions (#14)
michaelmcnees Mar 25, 2026
a07483a
docs: add implementation plan for data transformation CEL functions (…
michaelmcnees Mar 25, 2026
d1b4959
test(cel): add coverage for built-in map/filter/exists/all macros
michaelmcnees Mar 25, 2026
c1b7879
feat(cel): add toLower, toUpper, trim string functions
michaelmcnees Mar 25, 2026
a4f6744
feat(cel): add replace and split string functions
michaelmcnees Mar 25, 2026
3913d47
feat(cel): add parseInt, parseFloat, toString type coercion functions
michaelmcnees Mar 25, 2026
0e5d8e9
feat(cel): add obj() map construction function
michaelmcnees Mar 25, 2026
ccb63e5
feat(cel): add default() null coalescing and flatten() functions
michaelmcnees Mar 25, 2026
b0e4d64
feat(cel): add jsonEncode and jsonDecode functions
michaelmcnees Mar 25, 2026
a636201
feat(cel): add timestamp and formatTimestamp date/time functions
michaelmcnees Mar 25, 2026
3f42c17
docs: add custom CEL functions and macros to expressions reference (#14)
michaelmcnees Mar 25, 2026
e735241
feat: add data transformation and AI enrichment example workflows (#14)
michaelmcnees Mar 25, 2026
7085f68
docs: add data transformation patterns guide (#14)
michaelmcnees Mar 25, 2026
ee7f873
fix: address CodeRabbit review — null handling, date formats, docs, t…
michaelmcnees Mar 25, 2026
3473d2a
fix: address PR #21 review — non-strict default, jsonDecode precision…
michaelmcnees Mar 25, 2026
2d826be
fix: address PR #21 review round 3 — test coverage, trailing JSON, do…
michaelmcnees Mar 25, 2026
9a70a0e
fix: address PR #21 review round 4 — EOF trailing check, docs, plan c…
michaelmcnees Mar 25, 2026
ed5845c
fix: address PR #21 review round 5 — precision, flatten, plan accuracy
michaelmcnees Mar 25, 2026
7ca272a
fix(plan): correct test selector to TestFunc_ParseTimestamp
michaelmcnees Mar 25, 2026
2fd37e9
chore: remove force-added superpowers docs from tracking
michaelmcnees Mar 25, 2026
c4788c3
📝 CodeRabbit Chat: Add generated unit tests
coderabbitai[bot] Mar 25, 2026
6c1064e
feat(site): add Docker connector to homepage, fix tablet layout issues
michaelmcnees Mar 25, 2026
29ae600
feat: add execution_artifacts table (migration 013)
michaelmcnees Mar 25, 2026
e988295
feat: add ArtifactDecl and RegistryCredential to Step struct
michaelmcnees Mar 25, 2026
d9592f4
feat: add docker credential type (all fields optional)
michaelmcnees Mar 25, 2026
e1d6e3e
feat: validate artifact declarations (unique names, required fields)
michaelmcnees Mar 25, 2026
75cf239
feat: add TmpStorage interface with filesystem backend and artifact c…
michaelmcnees Mar 25, 2026
5167ec8
feat: add artifacts variable to CEL environment
michaelmcnees Mar 25, 2026
abfe339
feat: add artifact metadata store with Postgres backend
michaelmcnees Mar 25, 2026
02d631a
feat: add docker/run param parsing and types
michaelmcnees Mar 25, 2026
03f5b4d
feat: add artifact reaper for TTL-based cleanup
michaelmcnees Mar 25, 2026
6e7207f
docs: add Docker volume backup and data transform example workflows
michaelmcnees Mar 25, 2026
4c03535
docs: add docker/run connector reference documentation
michaelmcnees Mar 25, 2026
7dc73d8
feat: implement docker/run connector with Moby client
michaelmcnees Mar 25, 2026
e83d30c
feat: wire artifact lifecycle and registry credential into engine
michaelmcnees Mar 25, 2026
aa0e380
fix: address CodeRabbit review findings
michaelmcnees Mar 25, 2026
4015b58
fix: address CodeRabbit review findings (site/examples)
michaelmcnees Mar 25, 2026
5e680f6
fix: update example workflow count in Hero (17 → 22)
michaelmcnees Mar 25, 2026
fe4c688
docs: add Docker workflows guide, artifacts concept page, and config/…
michaelmcnees Mar 25, 2026
25edd31
merge: resolve conflicts with main (cel.go, Nav.astro)
michaelmcnees Mar 25, 2026
e6cc139
fix: address CI failures — deprecated ImageInspectWithRaw, gosec G118
michaelmcnees Mar 25, 2026
27cac6d
docs: add Go doc comments to exported symbols for docstring coverage
michaelmcnees Mar 25, 2026
5899033
fix: move #nosec G118 annotation to goroutine line where gosec flags it
michaelmcnees Mar 25, 2026
0314876
fix: address CodeRabbit review round 2
michaelmcnees Mar 25, 2026
2e2a457
fix: address CodeRabbit review round 3
michaelmcnees Mar 25, 2026
0bd9a9b
fix: address CodeRabbit review round 4
michaelmcnees Mar 25, 2026
b798402
fix: address CodeRabbit review round 5
michaelmcnees Mar 25, 2026
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
32 changes: 32 additions & 0 deletions examples/docker-data-transform.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: docker-data-transform
description: >
Query a database for recent records, process them through
a containerized tool, and post the results to Slack.
Demonstrates the docker/run connector with stdin/stdout
data passing.

steps:
- name: fetch-records
action: postgres/query
credential: my-db
timeout: "15s"
params:
query: "SELECT id, name, status, updated_at FROM tasks WHERE updated_at > now() - interval '24 hours' ORDER BY updated_at DESC"

- name: transform-data
action: docker/run
credential: my-docker
timeout: "30s"
params:
image: giantswarm/tiny-tools
cmd: ["jq", "[.[] | {id, name, status}]"]
stdin: "{{ jsonEncode(steps['fetch-records'].output.rows) }}"
memory: "128m"
Comment thread
coderabbitai[bot] marked this conversation as resolved.

- name: post-results
action: slack/send
credential: slack-token
if: "steps['transform-data'].output.exit_code == 0"
params:
channel: "#daily-report"
text: "Daily task summary:\n{{ steps['transform-data'].output.stdout }}"
56 changes: 56 additions & 0 deletions examples/docker-volume-backup.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
name: docker-volume-backup
description: >
Back up a Docker volume to S3 on a daily schedule.
Compresses the volume contents, uploads to S3, and
sends a Slack alert on success or failure.

triggers:
- type: cron
schedule: "0 2 * * *"

steps:
- name: backup-volume
action: docker/run
credential: my-docker
timeout: "10m"
params:
image: alpine
cmd: ["tar", "-czf", "/mantle/artifacts/backup.tar.gz", "-C", "/data", "."]
mounts:
- source: "my-app-data"
target: "/data"
readonly: true
memory: "256m"
remove: true
artifacts:
- path: /mantle/artifacts/backup.tar.gz
name: backup-archive

- name: upload-to-s3
action: s3/put
credential: aws-prod
timeout: "5m"
params:
bucket: my-backups
key: "volumes/my-app-data/{{ date }}/backup.tar.gz"
content: "{{ artifacts['backup-archive'].url }}"
content_type: "application/gzip"

- name: notify-success
action: slack/send
credential: slack-token
if: "steps['upload-to-s3'].error == null && steps['upload-to-s3'].output.size > 0"
params:
channel: "#ops-alerts"
text: "Volume backup completed — {{ artifacts['backup-archive'].size }} bytes uploaded to s3://my-backups/volumes/my-app-data/"

# NOTE: notify-failure requires continue_on_error support (see issue #29).
# Without it, the engine halts on the first failed step and this step
# will not execute. This is included to show the intended pattern.
- name: notify-failure
action: slack/send
credential: slack-token
if: "steps['upload-to-s3'].error != null || steps['backup-volume'].error != null"
params:
channel: "#ops-alerts"
text: "Volume backup FAILED"
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ require (
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
github.com/docker/docker v28.5.2+incompatible
github.com/testcontainers/testcontainers-go v0.41.0
github.com/testcontainers/testcontainers-go/modules/postgres v0.41.0
golang.org/x/oauth2 v0.35.0
Expand Down Expand Up @@ -68,7 +69,6 @@ require (
github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/docker v28.5.2+incompatible // indirect
github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/ebitengine/purego v0.10.0 // indirect
Expand Down
16 changes: 16 additions & 0 deletions internal/artifact/context.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package artifact

import "context"

type artifactsDirKey struct{}

// WithArtifactsDir returns a context carrying the artifacts directory path.
func WithArtifactsDir(ctx context.Context, dir string) context.Context {
return context.WithValue(ctx, artifactsDirKey{}, dir)
}

// ArtifactsDirFromContext extracts the artifacts directory from context.
func ArtifactsDirFromContext(ctx context.Context) string {
dir, _ := ctx.Value(artifactsDirKey{}).(string)
return dir
}
60 changes: 60 additions & 0 deletions internal/artifact/reaper.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package artifact

import (
"context"
"fmt"
"log/slog"
"time"
)

// Reaper cleans up expired artifacts from both tmp storage and the database.
type Reaper struct {
Store *Store
TmpStorage TmpStorage
Retention time.Duration
Logger *slog.Logger
}

// Sweep finds and removes artifacts older than the retention period.
// Returns the number of artifacts cleaned up.
func (r *Reaper) Sweep(ctx context.Context) (int, error) {
if r.Retention <= 0 {
return 0, nil // cleanup disabled
}

logger := r.Logger
if logger == nil {
logger = slog.Default()
}

expired, err := r.Store.ListExpired(ctx, r.Retention)
if err != nil {
return 0, fmt.Errorf("listing expired artifacts: %w", err)
}

if len(expired) == 0 {
return 0, nil
}

cleaned := 0
for _, a := range expired {
// Delete file from tmp storage.
if delErr := r.TmpStorage.Delete(ctx, a.URL); delErr != nil {
logger.Error("failed to delete artifact file",
"artifact", a.Name, "url", a.URL, "error", delErr)
continue // skip metadata deletion if file delete failed
}

// Delete metadata from database.
if err := r.Store.DeleteByID(ctx, a.ID); err != nil {
logger.Error("failed to delete artifact metadata",
"artifact", a.Name, "id", a.ID, "error", err)
continue
}
cleaned++
logger.Info("cleaned expired artifact",
"artifact", a.Name, "execution_id", a.ExecutionID)
}
Comment on lines +39 to +57

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check what Put returns and how URL is used

# Find what Put returns
echo "=== FilesystemTmpStorage.Put return value ==="
ast-grep --pattern $'func (fs *FilesystemTmpStorage) Put($_, $_, $_) (string, error) {
  $$$
}'

# Check if URL is stored as-is from Put
echo ""
echo "=== Store.Create usage of URL ==="
rg -n -A5 'store\.Create' --type=go | head -40

# Check how engine stores artifacts
echo ""
echo "=== Engine artifact persistence ==="
rg -n 'URL.*=' internal/engine/engine.go | head -20

Repository: dvflw/mantle

Length of output: 4659


🏁 Script executed:

# Read DeleteByPrefix implementation
echo "=== DeleteByPrefix implementation (tmp.go lines 67-83) ==="
sed -n '67,83p' internal/artifact/tmp.go

echo ""
echo "=== Full DeleteByPrefix and surrounding context ==="
sed -n '65,95p' internal/artifact/tmp.go

echo ""
echo "=== Artifact struct definition ==="
rg -n -B2 -A8 'type Artifact struct' internal/artifact/

echo ""
echo "=== How artifacts are created in engine ==="
rg -n -B3 -A3 'Store\.Create' internal/engine/ --type=go

Repository: dvflw/mantle

Length of output: 2480


🏁 Script executed:

# See the full artifact creation context
sed -n '510,535p' internal/engine/engine.go

echo ""
echo "=== Look for where putResp is assigned ==="
rg -n -B5 'putResp.*:=' internal/engine/engine.go | head -30

Repository: dvflw/mantle

Length of output: 1100


Fix DeleteByPrefix call to use a reconstructed key instead of the full a.URL path.

The issue is valid. In engine.go line 519, Put returns the full filesystem path (e.g., /tmp/storage/workflow/exec/artifact/file), which is stored in a.URL. However, DeleteByPrefix at line 45 is called with this full path, while it expects a relative key prefix.

In DeleteByPrefix, the prefix argument is joined with BasePath via filepath.Join(fs.BasePath, prefix). When a full path is passed as prefix, filepath.Join returns that absolute path (absolute paths override the base), which works by accident but is semantically incorrect and fragile.

Compare this to engine.go lines 530–532, where orphan cleanup correctly uses the relative key string, not the full path. The reaper should follow the same pattern—reconstruct the key prefix from the artifact's metadata (workflow name, execution ID, artifact name) and pass that to DeleteByPrefix, not the stored URL.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/artifact/reaper.go` around lines 39 - 57, The reaper currently calls
r.TmpStorage.DeleteByPrefix(ctx, a.URL) using the stored full filesystem path
(a.URL); instead reconstruct the relative storage key prefix from the artifact
metadata (e.g. workflow name, a.ExecutionID and a.Name) and pass that relative
key to r.TmpStorage.DeleteByPrefix so filepath.Join(fs.BasePath, prefix) behaves
correctly. Locate the loop that calls r.TmpStorage.DeleteByPrefix and replace
the a.URL argument with the reconstructed key string (matching how orphan
cleanup builds the key in engine.go), then keep the subsequent
r.Store.DeleteByID call and logging unchanged.


return cleaned, nil
}
90 changes: 90 additions & 0 deletions internal/artifact/reaper_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package artifact

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

func TestReaper_CleansExpiredArtifacts(t *testing.T) {
dir := t.TempDir()
fs := &FilesystemTmpStorage{BasePath: dir}
db := setupTestDB(t)
store := &Store{DB: db}
ctx := context.Background()
execID := createTestExecution(t, db)

// Write a file and create metadata
srcPath := filepath.Join(dir, "src.bin")
if err := os.WriteFile(srcPath, []byte("data"), 0644); err != nil {
t.Fatalf("os.WriteFile: %v", err)
}
key := "wf/" + execID + "/artifact/file.bin"
url, err := fs.Put(ctx, key, srcPath)
if err != nil {
t.Fatalf("fs.Put: %v", err)
}

if err := store.Create(ctx, &Artifact{
ExecutionID: execID,
StepName: "step-a",
Name: "test-artifact",
URL: url,
Size: 4,
}); err != nil {
t.Fatalf("store.Create: %v", err)
}

// Backdate the artifact so it appears expired
_, err = db.ExecContext(ctx, `
UPDATE execution_artifacts SET created_at = NOW() - interval '48 hours'
WHERE execution_id = $1
`, execID)
if err != nil {
t.Fatalf("backdate: %v", err)
}

reaper := &Reaper{
Store: store,
TmpStorage: fs,
Retention: 24 * time.Hour,
}

cleaned, err := reaper.Sweep(ctx)
if err != nil {
t.Fatalf("Sweep: %v", err)
}
if cleaned != 1 {
t.Errorf("cleaned = %d, want 1", cleaned)
}

// Metadata should be gone
arts, listErr := store.ListByExecution(ctx, execID)
if listErr != nil {
t.Fatalf("ListByExecution: %v", listErr)
}
if len(arts) != 0 {
t.Errorf("expected 0 artifacts after sweep, got %d", len(arts))
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

func TestReaper_SkipsWhenRetentionZero(t *testing.T) {
dir := t.TempDir()
fs := &FilesystemTmpStorage{BasePath: dir}

reaper := &Reaper{
Store: &Store{}, // safe: Sweep returns early when Retention <= 0, before accessing Store.DB
TmpStorage: fs,
Retention: 0,
}

cleaned, err := reaper.Sweep(context.Background())
if err != nil {
t.Fatalf("Sweep: %v", err)
}
if cleaned != 0 {
t.Errorf("cleaned = %d, want 0 when retention is 0", cleaned)
}
}
Comment on lines +73 to +90

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Consider making the zero-retention test more robust.

The test uses &Store{} with a nil DB, which works because Sweep returns early when Retention <= 0. However, this could lead to a confusing nil-pointer panic if the implementation changes.

♻️ Optional: use a real store or document the intent
 func TestReaper_SkipsWhenRetentionZero(t *testing.T) {
 	dir := t.TempDir()
 	fs := &FilesystemTmpStorage{BasePath: dir}

 	reaper := &Reaper{
-		Store:      &Store{}, // won't be called
+		Store:      nil, // explicitly nil; Sweep must return early before Store access
 		TmpStorage: fs,
 		Retention:  0,
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func TestReaper_SkipsWhenRetentionZero(t *testing.T) {
dir := t.TempDir()
fs := &FilesystemTmpStorage{BasePath: dir}
reaper := &Reaper{
Store: &Store{}, // won't be called
TmpStorage: fs,
Retention: 0,
}
cleaned, err := reaper.Sweep(context.Background())
if err != nil {
t.Fatalf("Sweep: %v", err)
}
if cleaned != 0 {
t.Errorf("cleaned = %d, want 0 when retention is 0", cleaned)
}
}
func TestReaper_SkipsWhenRetentionZero(t *testing.T) {
dir := t.TempDir()
fs := &FilesystemTmpStorage{BasePath: dir}
reaper := &Reaper{
Store: nil, // explicitly nil; Sweep must return early before Store access
TmpStorage: fs,
Retention: 0,
}
cleaned, err := reaper.Sweep(context.Background())
if err != nil {
t.Fatalf("Sweep: %v", err)
}
if cleaned != 0 {
t.Errorf("cleaned = %d, want 0 when retention is 0", cleaned)
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/artifact/reaper_test.go` around lines 73 - 90, The test
TestReaper_SkipsWhenRetentionZero currently constructs Store as &Store{} which
may cause future nil-pointer panics if Sweep’s early-return logic changes;
update the test to provide a safe, real or mocked Store instance (e.g., an
in-memory or test helper Store) instead of a raw empty struct so Reaper.Sweep
has a valid Store object to call if retention logic changes; locate the test and
replace the Store creation used in the Reaper struct (reference:
TestReaper_SkipsWhenRetentionZero, Reaper.Sweep, Store) with a lightweight safe
implementation or explicit comment that a real Store is intended.

124 changes: 124 additions & 0 deletions internal/artifact/store.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package artifact

import (
"context"
"database/sql"
"fmt"
"time"
)

// Artifact represents metadata for a persisted artifact.
type Artifact struct {
ID string
ExecutionID string
StepName string
Name string
URL string
Size int64
CreatedAt time.Time
}

// Store manages artifact metadata in Postgres.
type Store struct {
DB *sql.DB
}

// Create inserts artifact metadata into the database and populates a.ID and a.CreatedAt.
func (s *Store) Create(ctx context.Context, a *Artifact) error {
err := s.DB.QueryRowContext(ctx, `
INSERT INTO execution_artifacts (execution_id, step_name, name, url, size)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, created_at
`, a.ExecutionID, a.StepName, a.Name, a.URL, a.Size).Scan(&a.ID, &a.CreatedAt)
if err != nil {
return fmt.Errorf("artifact create: %w", err)
}
return nil
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// GetByName retrieves a single artifact by execution ID and artifact name.
func (s *Store) GetByName(ctx context.Context, executionID, name string) (*Artifact, error) {
var a Artifact
err := s.DB.QueryRowContext(ctx, `
SELECT id, execution_id, step_name, name, url, size, created_at
FROM execution_artifacts
WHERE execution_id = $1 AND name = $2
`, executionID, name).Scan(&a.ID, &a.ExecutionID, &a.StepName, &a.Name, &a.URL, &a.Size, &a.CreatedAt)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("artifact %q not found for execution %s", name, executionID)
}
if err != nil {
return nil, fmt.Errorf("artifact get: %w", err)
}
return &a, nil
}

// ListByExecution returns all artifacts associated with the given execution, ordered by creation time.
func (s *Store) ListByExecution(ctx context.Context, executionID string) ([]Artifact, error) {
rows, err := s.DB.QueryContext(ctx, `
SELECT id, execution_id, step_name, name, url, size, created_at
FROM execution_artifacts
WHERE execution_id = $1
ORDER BY created_at ASC
`, executionID)
if err != nil {
return nil, fmt.Errorf("artifact list: %w", err)
}
defer rows.Close()

var artifacts []Artifact
for rows.Next() {
var a Artifact
if err := rows.Scan(&a.ID, &a.ExecutionID, &a.StepName, &a.Name, &a.URL, &a.Size, &a.CreatedAt); err != nil {
return nil, fmt.Errorf("artifact scan: %w", err)
}
artifacts = append(artifacts, a)
}
return artifacts, rows.Err()
}

// DeleteByExecution removes all artifact metadata for an execution.
func (s *Store) DeleteByExecution(ctx context.Context, executionID string) error {
_, err := s.DB.ExecContext(ctx, `
DELETE FROM execution_artifacts WHERE execution_id = $1
`, executionID)
if err != nil {
return fmt.Errorf("artifact delete: %w", err)
}
return nil
}

// DeleteByID removes a single artifact metadata row.
func (s *Store) DeleteByID(ctx context.Context, id string) error {
_, err := s.DB.ExecContext(ctx, `
DELETE FROM execution_artifacts WHERE id = $1
`, id)
if err != nil {
return fmt.Errorf("artifact delete by id: %w", err)
}
return nil
}

// ListExpired returns artifacts older than the given duration, using the DB clock for comparison.
func (s *Store) ListExpired(ctx context.Context, olderThan time.Duration) ([]Artifact, error) {
rows, err := s.DB.QueryContext(ctx, `
SELECT id, execution_id, step_name, name, url, size, created_at
FROM execution_artifacts
WHERE created_at < NOW() - $1::interval
ORDER BY created_at ASC
`, olderThan.String())
if err != nil {
return nil, fmt.Errorf("artifact list expired: %w", err)
}
defer rows.Close()

var artifacts []Artifact
for rows.Next() {
var a Artifact
if err := rows.Scan(&a.ID, &a.ExecutionID, &a.StepName, &a.Name, &a.URL, &a.Size, &a.CreatedAt); err != nil {
return nil, fmt.Errorf("artifact scan: %w", err)
}
artifacts = append(artifacts, a)
}
return artifacts, rows.Err()
}
Loading
Loading