60c5cc40b2
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>
215 lines
7.0 KiB
Go
215 lines
7.0 KiB
Go
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
|
|
}
|