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:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user