feat(assets): add native Go asset/file loader for migrate-apply

Implements a new `relspec assets` command (list/execute subcommands) that
loads local binary and text asset files into PostgreSQL by binding file bytes
as native pgx query parameters — never as SQL text literals — so binary data
stays byte-exact with no escaping overhead.

Key design points:
- YAML manifest (assets.yaml) colocated with files describes each entry:
  file path, SQL call with :bytes/:filename/:param named placeholders, and
  optional static params map.
- Placeholder substitution converts :name to positional $N params; PostgreSQL
  ::cast syntax is protected before substitution to avoid false matches.
- Directory scan follows the existing {priority}_{sequence}_{name} naming
  convention, enabling asset-loading steps to be correctly interleaved with
  relspec scripts execute in a migrate-apply pipeline.
- Symlink components and path traversal (../) are silently skipped to prevent
  directory escape attacks.
- 14 unit tests cover manifest loading, directory scanning, ordering, symlink
  skipping, path traversal rejection, placeholder substitution edge cases
  (repeated, cast protection, binary byte-exact, unknown).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 01:14:11 +02:00
parent 5edb004799
commit 60c5cc40b2
10 changed files with 876 additions and 0 deletions
+214
View File
@@ -0,0 +1,214 @@
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"github.com/spf13/cobra"
"git.warky.dev/wdevs/relspecgo/pkg/assetloader"
"git.warky.dev/wdevs/relspecgo/pkg/pgsql"
)
var (
assetsDir string
assetsConn string
assetsIgnoreErrors bool
)
var assetsCmd = &cobra.Command{
Use: "assets",
Short: "Load and execute asset manifests against a database",
Long: `Load local binary and text asset files into a PostgreSQL database.
Assets are described by YAML manifests (assets.yaml) colocated with the files.
Each manifest entry specifies the file to load and the SQL call to execute.
File bytes are bound as native pgx parameters — never as SQL text literals —
so binary files stay byte-exact with no size or encoding limitations.
Manifests must live in directories that follow the naming pattern used by
relspec scripts:
{priority}_{sequence}_{name}/ or {priority}-{sequence}-{name}/
This allows asset-loading steps to be ordered correctly alongside SQL scripts
in a migrate-apply pipeline.
Manifest format (assets.yaml):
- file: invoice.md
call: |
INSERT INTO org.filepointer (rid_owner, filename, contenttype, jsonstore)
VALUES (1, :filename, 'text/markdown', jsonb_build_object('content', :bytes::text))
- file: logo.png
call: UPDATE branding SET logo = :bytes WHERE id = 1
params:
owner_id: "42"
Built-in placeholders:
:bytes — the file's raw content as bytea
:filename — the base name of the file (string)
:any_key — a static value declared in the entry's params map`,
}
var assetsListCmd = &cobra.Command{
Use: "list",
Short: "List asset manifests from a directory",
Long: `List all asset manifest entries from a directory in execution order.
The directory is scanned recursively for assets.yaml files located in
directories that follow the {priority}_{sequence}_{name} naming convention.
Example:
relspec assets list --dir ./sql`,
RunE: runAssetsList,
}
var assetsExecuteCmd = &cobra.Command{
Use: "execute",
Short: "Execute asset manifests against a database",
Long: `Execute asset manifest entries from a directory against a PostgreSQL database.
Asset manifests are executed in order: Priority (ascending), Sequence (ascending),
Directory name (alphabetical). By default, execution stops on the first error.
Use --ignore-errors to continue even when individual entries fail.
PostgreSQL Connection String Examples:
postgres://username:password@localhost:5432/database_name
postgresql://user:pass@host/dbname?sslmode=disable
Examples:
relspec assets execute --dir ./sql \
--conn "postgres://user:pass@localhost:5432/mydb"
relspec assets execute --dir ./sql \
--conn "postgres://localhost/mydb" \
--ignore-errors`,
RunE: runAssetsExecute,
}
func init() {
assetsListCmd.Flags().StringVar(&assetsDir, "dir", "", "Directory to scan for asset manifests (required)")
if err := assetsListCmd.MarkFlagRequired("dir"); err != nil {
fmt.Fprintf(os.Stderr, "Error marking dir flag as required: %v\n", err)
}
assetsExecuteCmd.Flags().StringVar(&assetsDir, "dir", "", "Directory to scan for asset manifests (required)")
assetsExecuteCmd.Flags().StringVar(&assetsConn, "conn", "", "PostgreSQL connection string (required)")
assetsExecuteCmd.Flags().BoolVar(&assetsIgnoreErrors, "ignore-errors", false, "Continue executing even if entries fail")
if err := assetsExecuteCmd.MarkFlagRequired("dir"); err != nil {
fmt.Fprintf(os.Stderr, "Error marking dir flag as required: %v\n", err)
}
if err := assetsExecuteCmd.MarkFlagRequired("conn"); err != nil {
fmt.Fprintf(os.Stderr, "Error marking conn flag as required: %v\n", err)
}
assetsCmd.AddCommand(assetsListCmd)
assetsCmd.AddCommand(assetsExecuteCmd)
}
func runAssetsList(cmd *cobra.Command, args []string) error {
fmt.Fprintf(os.Stderr, "\n=== Asset Manifests List ===\n")
fmt.Fprintf(os.Stderr, "Directory: %s\n\n", assetsDir)
items, err := assetloader.ScanDir(assetsDir)
if err != nil {
return fmt.Errorf("scanning directory: %w", err)
}
if len(items) == 0 {
fmt.Fprintf(os.Stderr, "No asset manifests found.\n\n")
return nil
}
fmt.Fprintf(os.Stderr, "Found %d asset entry(ies) in execution order:\n\n", len(items))
fmt.Fprintf(os.Stderr, "%-4s %-10s %-8s %-20s %s\n", "No.", "Priority", "Sequence", "Dir", "File")
fmt.Fprintf(os.Stderr, "%-4s %-10s %-8s %-20s %s\n", "----", "--------", "--------", "--------------------", "----")
for i, item := range items {
fmt.Fprintf(os.Stderr, "%-4d %-10d %-8d %-20s %s\n",
i+1,
item.Priority,
item.Sequence,
item.DirName,
filepath.Base(item.Entry.File),
)
}
fmt.Fprintf(os.Stderr, "\n")
return nil
}
func runAssetsExecute(cmd *cobra.Command, args []string) error {
fmt.Fprintf(os.Stderr, "\n=== Asset Manifests Execution ===\n")
fmt.Fprintf(os.Stderr, "Started at: %s\n", getCurrentTimestamp())
fmt.Fprintf(os.Stderr, "Directory: %s\n", assetsDir)
fmt.Fprintf(os.Stderr, "Database: %s\n\n", maskPassword(assetsConn))
fmt.Fprintf(os.Stderr, "[1/2] Scanning asset manifests...\n")
items, err := assetloader.ScanDir(assetsDir)
if err != nil {
return fmt.Errorf("scanning directory: %w", err)
}
if len(items) == 0 {
fmt.Fprintf(os.Stderr, " No asset manifests found. Nothing to execute.\n\n")
return nil
}
fmt.Fprintf(os.Stderr, " ✓ Found %d asset entry(ies)\n\n", len(items))
fmt.Fprintf(os.Stderr, "[2/2] Executing assets in order (Priority → Sequence → Dir)...\n\n")
ctx := context.Background()
conn, err := pgsql.Connect(ctx, assetsConn, "assets-execute")
if err != nil {
return fmt.Errorf("connecting to database: %w", err)
}
defer conn.Close(ctx)
successCount := 0
var failures []struct {
item assetloader.Item
err error
}
for _, item := range items {
name := filepath.Base(item.Entry.File)
fmt.Printf("Executing asset: %s (Priority=%d, Sequence=%d, Dir=%s)\n",
name, item.Priority, item.Sequence, item.DirName)
if err := assetloader.ExecuteItem(ctx, conn, item); err != nil {
if assetsIgnoreErrors {
fmt.Printf("⚠ Error loading %s: %v (continuing due to --ignore-errors)\n", name, err)
failures = append(failures, struct {
item assetloader.Item
err error
}{item, err})
continue
}
return fmt.Errorf("asset %s (Priority=%d, Sequence=%d): %w",
name, item.Priority, item.Sequence, err)
}
successCount++
fmt.Printf("✓ Successfully loaded: %s\n", name)
}
fmt.Fprintf(os.Stderr, "\n=== Execution Complete ===\n")
fmt.Fprintf(os.Stderr, "Completed at: %s\n", getCurrentTimestamp())
fmt.Fprintf(os.Stderr, "Total entries: %d\n", len(items))
fmt.Fprintf(os.Stderr, "Successful: %d\n", successCount)
if len(failures) > 0 {
fmt.Fprintf(os.Stderr, "Failed: %d\n", len(failures))
fmt.Fprintf(os.Stderr, "\n⚠ Failed Entries Summary (%d failed):\n", len(failures))
for i, f := range failures {
fmt.Fprintf(os.Stderr, " %d. %s (Priority=%d, Sequence=%d)\n Error: %v\n",
i+1, filepath.Base(f.item.Entry.File), f.item.Priority, f.item.Sequence, f.err)
}
}
fmt.Fprintf(os.Stderr, "\n")
return nil
}
+1
View File
@@ -64,6 +64,7 @@ func init() {
rootCmd.AddCommand(diffCmd)
rootCmd.AddCommand(inspectCmd)
rootCmd.AddCommand(scriptsCmd)
rootCmd.AddCommand(assetsCmd)
rootCmd.AddCommand(templCmd)
rootCmd.AddCommand(editCmd)
rootCmd.AddCommand(mergeCmd)