Merge pull request 'feat(api): approvals + ECO workflows; refactor(storage): remove MinIO' (#154) from feat/approval-workflows into main

Reviewed-on: #154
This commit was merged in pull request #154.
This commit is contained in:
2026-02-19 20:57:15 +00:00
42 changed files with 1195 additions and 859 deletions

View File

@@ -5,10 +5,6 @@
# PostgreSQL # PostgreSQL
POSTGRES_PASSWORD=silodev POSTGRES_PASSWORD=silodev
# MinIO
MINIO_ACCESS_KEY=silominio
MINIO_SECRET_KEY=silominiosecret
# OpenLDAP # OpenLDAP
LDAP_ADMIN_PASSWORD=ldapadmin LDAP_ADMIN_PASSWORD=ldapadmin
LDAP_USERS=siloadmin LDAP_USERS=siloadmin

View File

@@ -1,8 +1,7 @@
.PHONY: build run test test-integration clean migrate fmt lint \ .PHONY: build run test test-integration clean migrate fmt lint \
docker-build docker-up docker-down docker-logs docker-ps \ docker-build docker-up docker-down docker-logs docker-ps \
docker-clean docker-rebuild \ docker-clean docker-rebuild \
web-install web-dev web-build \ web-install web-dev web-build
migrate-storage
# ============================================================================= # =============================================================================
# Local Development # Local Development
@@ -57,13 +56,6 @@ tidy:
migrate: migrate:
./scripts/init-db.sh ./scripts/init-db.sh
# Build and run MinIO → filesystem migration tool
# Usage: make migrate-storage DEST=/opt/silo/data [ARGS="--dry-run --verbose"]
migrate-storage:
go build -o migrate-storage ./cmd/migrate-storage
@echo "Built ./migrate-storage"
@echo "Run: ./migrate-storage -config <config.yaml> -dest <dir> [-dry-run] [-verbose]"
# Connect to database (requires psql) # Connect to database (requires psql)
db-shell: db-shell:
PGPASSWORD=$${SILO_DB_PASSWORD:-silodev} psql -h $${SILO_DB_HOST:-localhost} -U $${SILO_DB_USER:-silo} -d $${SILO_DB_NAME:-silo} PGPASSWORD=$${SILO_DB_PASSWORD:-silodev} psql -h $${SILO_DB_HOST:-localhost} -U $${SILO_DB_USER:-silo} -d $${SILO_DB_NAME:-silo}
@@ -76,7 +68,7 @@ db-shell:
docker-build: docker-build:
docker build -t silo:latest -f build/package/Dockerfile . docker build -t silo:latest -f build/package/Dockerfile .
# Start the full stack (postgres + minio + silo) # Start the full stack (postgres + silo)
docker-up: docker-up:
docker compose -f deployments/docker-compose.yaml up -d docker compose -f deployments/docker-compose.yaml up -d
@@ -103,9 +95,6 @@ docker-logs-silo:
docker-logs-postgres: docker-logs-postgres:
docker compose -f deployments/docker-compose.yaml logs -f postgres docker compose -f deployments/docker-compose.yaml logs -f postgres
docker-logs-minio:
docker compose -f deployments/docker-compose.yaml logs -f minio
# Show running containers # Show running containers
docker-ps: docker-ps:
docker compose -f deployments/docker-compose.yaml ps docker compose -f deployments/docker-compose.yaml ps
@@ -175,7 +164,7 @@ help:
@echo "" @echo ""
@echo "Docker:" @echo "Docker:"
@echo " docker-build - Build Docker image" @echo " docker-build - Build Docker image"
@echo " docker-up - Start full stack (postgres + minio + silo)" @echo " docker-up - Start full stack (postgres + silo)"
@echo " docker-down - Stop the stack" @echo " docker-down - Stop the stack"
@echo " docker-clean - Stop and remove volumes (deletes data)" @echo " docker-clean - Stop and remove volumes (deletes data)"
@echo " docker-logs - View all logs" @echo " docker-logs - View all logs"

View File

@@ -34,7 +34,7 @@ silo/
│ ├── ods/ # ODS spreadsheet library │ ├── ods/ # ODS spreadsheet library
│ ├── partnum/ # Part number generation │ ├── partnum/ # Part number generation
│ ├── schema/ # YAML schema parsing │ ├── schema/ # YAML schema parsing
│ ├── storage/ # MinIO file storage │ ├── storage/ # Filesystem storage
│ └── testutil/ # Test helpers │ └── testutil/ # Test helpers
├── web/ # React SPA (Vite + TypeScript) ├── web/ # React SPA (Vite + TypeScript)
│ └── src/ │ └── src/
@@ -55,7 +55,7 @@ silo/
See the **[Installation Guide](docs/INSTALL.md)** for complete setup instructions. See the **[Installation Guide](docs/INSTALL.md)** for complete setup instructions.
**Docker Compose (quickest — includes PostgreSQL, MinIO, OpenLDAP, and Silo):** **Docker Compose (quickest — includes PostgreSQL, OpenLDAP, and Silo):**
```bash ```bash
./scripts/setup-docker.sh ./scripts/setup-docker.sh
@@ -65,7 +65,7 @@ docker compose -f deployments/docker-compose.allinone.yaml up -d
**Development (local Go + Docker services):** **Development (local Go + Docker services):**
```bash ```bash
make docker-up # Start PostgreSQL + MinIO in Docker make docker-up # Start PostgreSQL in Docker
make run # Run silo locally with Go make run # Run silo locally with Go
``` ```

View File

@@ -1,288 +0,0 @@
// Command migrate-storage downloads files from MinIO and writes them to the
// local filesystem. It is a one-shot migration tool for moving off MinIO.
//
// Usage:
//
// migrate-storage -config config.yaml -dest /opt/silo/data [-dry-run] [-verbose]
package main
import (
"context"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"time"
"github.com/kindredsystems/silo/internal/config"
"github.com/kindredsystems/silo/internal/db"
"github.com/kindredsystems/silo/internal/storage"
"github.com/rs/zerolog"
)
// fileEntry represents a single file to migrate.
type fileEntry struct {
key string
versionID string // MinIO version ID; empty if not versioned
size int64 // expected size from DB; 0 if unknown
}
func main() {
configPath := flag.String("config", "config.yaml", "Path to configuration file")
dest := flag.String("dest", "", "Destination root directory (required)")
dryRun := flag.Bool("dry-run", false, "Preview what would be migrated without downloading")
verbose := flag.Bool("verbose", false, "Log every file, not just errors and summary")
flag.Parse()
logger := zerolog.New(os.Stdout).With().Timestamp().Logger()
if *dest == "" {
logger.Fatal().Msg("-dest is required")
}
// Load config (reuses existing config for DB + MinIO credentials).
cfg, err := config.Load(*configPath)
if err != nil {
logger.Fatal().Err(err).Msg("failed to load configuration")
}
ctx := context.Background()
// Connect to PostgreSQL.
database, err := db.Connect(ctx, db.Config{
Host: cfg.Database.Host,
Port: cfg.Database.Port,
Name: cfg.Database.Name,
User: cfg.Database.User,
Password: cfg.Database.Password,
SSLMode: cfg.Database.SSLMode,
MaxConnections: cfg.Database.MaxConnections,
})
if err != nil {
logger.Fatal().Err(err).Msg("failed to connect to database")
}
defer database.Close()
logger.Info().Msg("connected to database")
// Connect to MinIO.
store, err := storage.Connect(ctx, storage.Config{
Endpoint: cfg.Storage.Endpoint,
AccessKey: cfg.Storage.AccessKey,
SecretKey: cfg.Storage.SecretKey,
Bucket: cfg.Storage.Bucket,
UseSSL: cfg.Storage.UseSSL,
Region: cfg.Storage.Region,
})
if err != nil {
logger.Fatal().Err(err).Msg("failed to connect to MinIO")
}
logger.Info().Str("bucket", cfg.Storage.Bucket).Msg("connected to MinIO")
// Collect all file references from the database.
entries, err := collectEntries(ctx, logger, database)
if err != nil {
logger.Fatal().Err(err).Msg("failed to collect file entries from database")
}
logger.Info().Int("total", len(entries)).Msg("file entries found")
if len(entries) == 0 {
logger.Info().Msg("nothing to migrate")
return
}
// Migrate.
var migrated, skipped, failed int
start := time.Now()
for i, e := range entries {
destPath := filepath.Join(*dest, e.key)
// Check if already migrated.
if info, err := os.Stat(destPath); err == nil {
if e.size > 0 && info.Size() == e.size {
if *verbose {
logger.Info().Str("key", e.key).Msg("skipped (already exists)")
}
skipped++
continue
}
// Size mismatch or unknown size — re-download.
}
if *dryRun {
logger.Info().
Str("key", e.key).
Int64("size", e.size).
Str("version", e.versionID).
Msgf("[%d/%d] would migrate", i+1, len(entries))
continue
}
if err := migrateFile(ctx, store, e, destPath); err != nil {
logger.Error().Err(err).Str("key", e.key).Msg("failed to migrate")
failed++
continue
}
migrated++
if *verbose {
logger.Info().
Str("key", e.key).
Int64("size", e.size).
Msgf("[%d/%d] migrated", i+1, len(entries))
} else if (i+1)%50 == 0 {
logger.Info().Msgf("progress: %d/%d", i+1, len(entries))
}
}
elapsed := time.Since(start)
ev := logger.Info().
Int("total", len(entries)).
Int("migrated", migrated).
Int("skipped", skipped).
Int("failed", failed).
Dur("elapsed", elapsed)
if *dryRun {
ev.Msg("dry run complete")
} else {
ev.Msg("migration complete")
}
if failed > 0 {
os.Exit(1)
}
}
// collectEntries queries the database for all file references across the three
// storage domains: revision files, item file attachments, and item thumbnails.
// It deduplicates by key.
func collectEntries(ctx context.Context, logger zerolog.Logger, database *db.DB) ([]fileEntry, error) {
pool := database.Pool()
seen := make(map[string]struct{})
var entries []fileEntry
add := func(key, versionID string, size int64) {
if key == "" {
return
}
if _, ok := seen[key]; ok {
return
}
seen[key] = struct{}{}
entries = append(entries, fileEntry{key: key, versionID: versionID, size: size})
}
// 1. Revision files.
rows, err := pool.Query(ctx,
`SELECT file_key, COALESCE(file_version, ''), COALESCE(file_size, 0)
FROM revisions WHERE file_key IS NOT NULL`)
if err != nil {
return nil, fmt.Errorf("querying revisions: %w", err)
}
for rows.Next() {
var key, version string
var size int64
if err := rows.Scan(&key, &version, &size); err != nil {
rows.Close()
return nil, fmt.Errorf("scanning revision row: %w", err)
}
add(key, version, size)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating revisions: %w", err)
}
logger.Info().Int("count", len(entries)).Msg("revision files found")
// 2. Item file attachments.
countBefore := len(entries)
rows, err = pool.Query(ctx,
`SELECT object_key, size FROM item_files`)
if err != nil {
return nil, fmt.Errorf("querying item_files: %w", err)
}
for rows.Next() {
var key string
var size int64
if err := rows.Scan(&key, &size); err != nil {
rows.Close()
return nil, fmt.Errorf("scanning item_files row: %w", err)
}
add(key, "", size)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating item_files: %w", err)
}
logger.Info().Int("count", len(entries)-countBefore).Msg("item file attachments found")
// 3. Item thumbnails.
countBefore = len(entries)
rows, err = pool.Query(ctx,
`SELECT thumbnail_key FROM items WHERE thumbnail_key IS NOT NULL`)
if err != nil {
return nil, fmt.Errorf("querying item thumbnails: %w", err)
}
for rows.Next() {
var key string
if err := rows.Scan(&key); err != nil {
rows.Close()
return nil, fmt.Errorf("scanning thumbnail row: %w", err)
}
add(key, "", 0)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating thumbnails: %w", err)
}
logger.Info().Int("count", len(entries)-countBefore).Msg("item thumbnails found")
return entries, nil
}
// migrateFile downloads a single file from MinIO and writes it atomically to destPath.
func migrateFile(ctx context.Context, store *storage.Storage, e fileEntry, destPath string) error {
// Ensure parent directory exists.
if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
return fmt.Errorf("creating directory: %w", err)
}
// Download from MinIO.
var reader io.ReadCloser
var err error
if e.versionID != "" {
reader, err = store.GetVersion(ctx, e.key, e.versionID)
} else {
reader, err = store.Get(ctx, e.key)
}
if err != nil {
return fmt.Errorf("downloading from MinIO: %w", err)
}
defer reader.Close()
// Write to temp file then rename for atomicity.
tmpPath := destPath + ".tmp"
f, err := os.Create(tmpPath)
if err != nil {
return fmt.Errorf("creating temp file: %w", err)
}
if _, err := io.Copy(f, reader); err != nil {
f.Close()
os.Remove(tmpPath)
return fmt.Errorf("writing file: %w", err)
}
if err := f.Close(); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("closing temp file: %w", err)
}
if err := os.Rename(tmpPath, destPath); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("renaming temp file: %w", err)
}
return nil
}

View File

@@ -23,6 +23,7 @@ import (
"github.com/kindredsystems/silo/internal/modules" "github.com/kindredsystems/silo/internal/modules"
"github.com/kindredsystems/silo/internal/schema" "github.com/kindredsystems/silo/internal/schema"
"github.com/kindredsystems/silo/internal/storage" "github.com/kindredsystems/silo/internal/storage"
"github.com/kindredsystems/silo/internal/workflow"
"github.com/rs/zerolog" "github.com/rs/zerolog"
) )
@@ -44,7 +45,6 @@ func main() {
Str("host", cfg.Server.Host). Str("host", cfg.Server.Host).
Int("port", cfg.Server.Port). Int("port", cfg.Server.Port).
Str("database", cfg.Database.Host). Str("database", cfg.Database.Host).
Str("storage", cfg.Storage.Endpoint).
Msg("starting silo server") Msg("starting silo server")
// Connect to database // Connect to database
@@ -64,40 +64,17 @@ func main() {
defer database.Close() defer database.Close()
logger.Info().Msg("connected to database") logger.Info().Msg("connected to database")
// Connect to storage (optional - may be externally managed) // Connect to storage (optional — requires root_dir to be set)
var store storage.FileStore var store storage.FileStore
switch cfg.Storage.Backend { if cfg.Storage.Filesystem.RootDir != "" {
case "minio", "":
if cfg.Storage.Endpoint != "" {
s, connErr := storage.Connect(ctx, storage.Config{
Endpoint: cfg.Storage.Endpoint,
AccessKey: cfg.Storage.AccessKey,
SecretKey: cfg.Storage.SecretKey,
Bucket: cfg.Storage.Bucket,
UseSSL: cfg.Storage.UseSSL,
Region: cfg.Storage.Region,
})
if connErr != nil {
logger.Warn().Err(connErr).Msg("failed to connect to storage - file operations disabled")
} else {
store = s
logger.Info().Msg("connected to storage")
}
} else {
logger.Info().Msg("storage not configured - file operations disabled")
}
case "filesystem":
if cfg.Storage.Filesystem.RootDir == "" {
logger.Fatal().Msg("storage.filesystem.root_dir is required when backend is \"filesystem\"")
}
s, fsErr := storage.NewFilesystemStore(cfg.Storage.Filesystem.RootDir) s, fsErr := storage.NewFilesystemStore(cfg.Storage.Filesystem.RootDir)
if fsErr != nil { if fsErr != nil {
logger.Fatal().Err(fsErr).Msg("failed to initialize filesystem storage") logger.Fatal().Err(fsErr).Msg("failed to initialize filesystem storage")
} }
store = s store = s
logger.Info().Str("root", cfg.Storage.Filesystem.RootDir).Msg("connected to filesystem storage") logger.Info().Str("root", cfg.Storage.Filesystem.RootDir).Msg("connected to filesystem storage")
default: } else {
logger.Fatal().Str("backend", cfg.Storage.Backend).Msg("unknown storage backend") logger.Info().Msg("storage not configured - file operations disabled")
} }
// Load schemas // Load schemas
@@ -235,6 +212,19 @@ func main() {
} }
} }
// Load approval workflow definitions (optional — directory may not exist yet)
var workflows map[string]*workflow.Workflow
if _, err := os.Stat(cfg.Workflows.Directory); err == nil {
workflows, err = workflow.LoadAll(cfg.Workflows.Directory)
if err != nil {
logger.Fatal().Err(err).Str("directory", cfg.Workflows.Directory).Msg("failed to load workflow definitions")
}
logger.Info().Int("count", len(workflows)).Msg("loaded workflow definitions")
} else {
workflows = make(map[string]*workflow.Workflow)
logger.Info().Str("directory", cfg.Workflows.Directory).Msg("workflows directory not found, skipping")
}
// Initialize module registry // Initialize module registry
registry := modules.NewRegistry() registry := modules.NewRegistry()
if err := modules.LoadState(registry, cfg, database.Pool()); err != nil { if err := modules.LoadState(registry, cfg, database.Pool()); err != nil {
@@ -258,7 +248,7 @@ func main() {
// Create API server // Create API server
server := api.NewServer(logger, database, schemas, cfg.Schemas.Directory, store, server := api.NewServer(logger, database, schemas, cfg.Schemas.Directory, store,
authService, sessionManager, oidcBackend, &cfg.Auth, broker, serverState, authService, sessionManager, oidcBackend, &cfg.Auth, broker, serverState,
jobDefs, cfg.Jobs.Directory, registry, cfg) jobDefs, cfg.Jobs.Directory, registry, cfg, workflows)
router := api.NewRouter(server, logger) router := api.NewRouter(server, logger)
// Start background sweepers for job/runner timeouts (only when jobs module enabled) // Start background sweepers for job/runner timeouts (only when jobs module enabled)

View File

@@ -17,17 +17,9 @@ database:
max_connections: 10 max_connections: 10
storage: storage:
backend: "minio" # "minio" (default) or "filesystem" backend: "filesystem"
# MinIO/S3 settings (used when backend: "minio") filesystem:
endpoint: "localhost:9000" # Use "minio:9000" for Docker Compose root_dir: "/opt/silo/data" # Override with SILO_STORAGE_ROOT_DIR env var
access_key: "" # Use SILO_MINIO_ACCESS_KEY env var
secret_key: "" # Use SILO_MINIO_SECRET_KEY env var
bucket: "silo-files"
use_ssl: true # Use false for Docker Compose (internal network)
region: "us-east-1"
# Filesystem settings (used when backend: "filesystem")
# filesystem:
# root_dir: "/var/lib/silo/objects"
schemas: schemas:
# Directory containing YAML schema files # Directory containing YAML schema files

View File

@@ -17,12 +17,9 @@ database:
max_connections: 10 max_connections: 10
storage: storage:
endpoint: "minio:9000" backend: "filesystem"
access_key: "${MINIO_ACCESS_KEY:-silominio}" filesystem:
secret_key: "${MINIO_SECRET_KEY:-silominiosecret}" root_dir: "/var/lib/silo/data"
bucket: "silo-files"
use_ssl: false
region: "us-east-1"
schemas: schemas:
directory: "/etc/silo/schemas" directory: "/etc/silo/schemas"

View File

@@ -1,5 +1,5 @@
# Silo All-in-One Stack # Silo All-in-One Stack
# PostgreSQL + MinIO + OpenLDAP + Silo API + Nginx (optional) # PostgreSQL + OpenLDAP + Silo API + Nginx (optional)
# #
# Quick start: # Quick start:
# ./scripts/setup-docker.sh # ./scripts/setup-docker.sh
@@ -40,29 +40,6 @@ services:
networks: networks:
- silo-net - silo-net
# ---------------------------------------------------------------------------
# MinIO (S3-compatible object storage)
# ---------------------------------------------------------------------------
minio:
image: minio/minio:latest
container_name: silo-minio
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:?Run ./scripts/setup-docker.sh first}
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:?Run ./scripts/setup-docker.sh first}
volumes:
- minio_data:/data
ports:
- "9001:9001" # MinIO console (remove in hardened setups)
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 10s
timeout: 5s
retries: 5
networks:
- silo-net
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# OpenLDAP (user directory for LDAP authentication) # OpenLDAP (user directory for LDAP authentication)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -85,7 +62,11 @@ services:
ports: ports:
- "1389:1389" # LDAP access for debugging (remove in hardened setups) - "1389:1389" # LDAP access for debugging (remove in hardened setups)
healthcheck: healthcheck:
test: ["CMD-SHELL", "ldapsearch -x -H ldap://localhost:1389 -b dc=silo,dc=local -D cn=admin,dc=silo,dc=local -w $${LDAP_ADMIN_PASSWORD} '(objectClass=organization)' >/dev/null 2>&1"] test:
[
"CMD-SHELL",
"ldapsearch -x -H ldap://localhost:1389 -b dc=silo,dc=local -D cn=admin,dc=silo,dc=local -w $${LDAP_ADMIN_PASSWORD} '(objectClass=organization)' >/dev/null 2>&1",
]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
@@ -104,8 +85,6 @@ services:
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
minio:
condition: service_healthy
openldap: openldap:
condition: service_healthy condition: service_healthy
env_file: env_file:
@@ -117,12 +96,10 @@ services:
SILO_DB_NAME: silo SILO_DB_NAME: silo
SILO_DB_USER: silo SILO_DB_USER: silo
SILO_DB_PASSWORD: ${POSTGRES_PASSWORD} SILO_DB_PASSWORD: ${POSTGRES_PASSWORD}
SILO_MINIO_ENDPOINT: minio:9000
SILO_MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY}
SILO_MINIO_SECRET_KEY: ${MINIO_SECRET_KEY}
ports: ports:
- "${SILO_PORT:-8080}:8080" - "${SILO_PORT:-8080}:8080"
volumes: volumes:
- silo_data:/var/lib/silo/data
- ../schemas:/etc/silo/schemas:ro - ../schemas:/etc/silo/schemas:ro
- ./config.docker.yaml:/etc/silo/config.yaml:ro - ./config.docker.yaml:/etc/silo/config.yaml:ro
healthcheck: healthcheck:
@@ -164,7 +141,7 @@ services:
volumes: volumes:
postgres_data: postgres_data:
minio_data: silo_data:
openldap_data: openldap_data:
networks: networks:

View File

@@ -1,10 +1,8 @@
# Production Docker Compose for Silo # Production Docker Compose for Silo
# Uses external PostgreSQL (psql.example.internal) and MinIO (minio.example.internal) # Uses external PostgreSQL (psql.example.internal) and filesystem storage
# #
# Usage: # Usage:
# export SILO_DB_PASSWORD=<your-password> # export SILO_DB_PASSWORD=<your-password>
# export SILO_MINIO_ACCESS_KEY=<your-access-key>
# export SILO_MINIO_SECRET_KEY=<your-secret-key>
# docker compose -f docker-compose.prod.yaml up -d # docker compose -f docker-compose.prod.yaml up -d
services: services:
@@ -24,14 +22,6 @@ services:
# Note: SILO_DB_PORT and SILO_DB_SSLMODE are NOT supported as direct # Note: SILO_DB_PORT and SILO_DB_SSLMODE are NOT supported as direct
# env var overrides. Set these in config.yaml instead, or use ${VAR} # env var overrides. Set these in config.yaml instead, or use ${VAR}
# syntax in the YAML file. See docs/CONFIGURATION.md for details. # syntax in the YAML file. See docs/CONFIGURATION.md for details.
# MinIO storage (minio.example.internal)
# Supported as direct env var overrides:
SILO_MINIO_ENDPOINT: minio.example.internal:9000
SILO_MINIO_ACCESS_KEY: ${SILO_MINIO_ACCESS_KEY:?MinIO access key required}
SILO_MINIO_SECRET_KEY: ${SILO_MINIO_SECRET_KEY:?MinIO secret key required}
# Note: SILO_MINIO_BUCKET and SILO_MINIO_USE_SSL are NOT supported as
# direct env var overrides. Set these in config.yaml instead.
ports: ports:
- "8080:8080" - "8080:8080"
volumes: volumes:

View File

@@ -19,26 +19,6 @@ services:
networks: networks:
- silo-network - silo-network
minio:
image: minio/minio:RELEASE.2023-05-04T21-44-30Z
container_name: silo-minio
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:-silominio}
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:-silominiosecret}
volumes:
- minio_data:/data
ports:
- "9000:9000"
- "9001:9001"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 10s
timeout: 5s
retries: 5
networks:
- silo-network
silo: silo:
build: build:
context: .. context: ..
@@ -47,19 +27,12 @@ services:
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
minio:
condition: service_healthy
environment: environment:
SILO_DB_HOST: postgres SILO_DB_HOST: postgres
SILO_DB_PORT: 5432 SILO_DB_PORT: 5432
SILO_DB_NAME: silo SILO_DB_NAME: silo
SILO_DB_USER: silo SILO_DB_USER: silo
SILO_DB_PASSWORD: ${POSTGRES_PASSWORD:-silodev} SILO_DB_PASSWORD: ${POSTGRES_PASSWORD:-silodev}
SILO_MINIO_ENDPOINT: minio:9000
SILO_MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:-silominio}
SILO_MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:-silominiosecret}
SILO_MINIO_BUCKET: silo-files
SILO_MINIO_USE_SSL: "false"
SILO_SESSION_SECRET: ${SILO_SESSION_SECRET:-change-me-in-production} SILO_SESSION_SECRET: ${SILO_SESSION_SECRET:-change-me-in-production}
SILO_OIDC_CLIENT_SECRET: ${SILO_OIDC_CLIENT_SECRET:-} SILO_OIDC_CLIENT_SECRET: ${SILO_OIDC_CLIENT_SECRET:-}
SILO_LDAP_BIND_PASSWORD: ${SILO_LDAP_BIND_PASSWORD:-} SILO_LDAP_BIND_PASSWORD: ${SILO_LDAP_BIND_PASSWORD:-}
@@ -68,6 +41,7 @@ services:
ports: ports:
- "8080:8080" - "8080:8080"
volumes: volumes:
- silo_data:/var/lib/silo/data
- ../schemas:/etc/silo/schemas:ro - ../schemas:/etc/silo/schemas:ro
- ./config.dev.yaml:/etc/silo/config.yaml:ro - ./config.dev.yaml:/etc/silo/config.yaml:ro
healthcheck: healthcheck:
@@ -80,7 +54,7 @@ services:
volumes: volumes:
postgres_data: postgres_data:
minio_data: silo_data:
networks: networks:
silo-network: silo-network:

View File

@@ -27,6 +27,7 @@ NoNewPrivileges=yes
ProtectSystem=strict ProtectSystem=strict
ProtectHome=yes ProtectHome=yes
PrivateTmp=yes PrivateTmp=yes
ReadWritePaths=/opt/silo/data
ReadOnlyPaths=/etc/silo /opt/silo ReadOnlyPaths=/etc/silo /opt/silo
# Resource limits # Resource limits

View File

@@ -23,7 +23,7 @@ These cannot be disabled. They define what Silo *is*.
|-----------|------|-------------| |-----------|------|-------------|
| `core` | Core PDM | Items, revisions, files, BOM, search, import/export, part number generation | | `core` | Core PDM | Items, revisions, files, BOM, search, import/export, part number generation |
| `schemas` | Schemas | Part numbering schema parsing, segment management, form descriptors | | `schemas` | Schemas | Part numbering schema parsing, segment management, form descriptors |
| `storage` | Storage | MinIO/S3 file storage, presigned uploads, versioning | | `storage` | Storage | Filesystem storage |
### 2.2 Optional Modules ### 2.2 Optional Modules
@@ -470,12 +470,10 @@ Returns full config grouped by module with secrets redacted:
"default": "kindred-rd" "default": "kindred-rd"
}, },
"storage": { "storage": {
"endpoint": "minio:9000", "backend": "filesystem",
"bucket": "silo-files", "filesystem": {
"access_key": "****", "root_dir": "/var/lib/silo/data"
"secret_key": "****", },
"use_ssl": false,
"region": "us-east-1",
"status": "connected" "status": "connected"
}, },
"database": { "database": {
@@ -566,7 +564,7 @@ Available for modules with external connections:
| Module | Test Action | | Module | Test Action |
|--------|------------| |--------|------------|
| `storage` | Ping MinIO, verify bucket exists | | `storage` | Verify filesystem storage directory is accessible |
| `auth` (ldap) | Attempt LDAP bind with configured credentials | | `auth` (ldap) | Attempt LDAP bind with configured credentials |
| `auth` (oidc) | Fetch OIDC discovery document from issuer URL | | `auth` (oidc) | Fetch OIDC discovery document from issuer URL |
| `odoo` | Attempt XML-RPC connection to Odoo | | `odoo` | Attempt XML-RPC connection to Odoo |
@@ -602,11 +600,9 @@ database:
sslmode: disable sslmode: disable
storage: storage:
endpoint: minio:9000 backend: filesystem
bucket: silo-files filesystem:
access_key: silominio root_dir: /var/lib/silo/data
secret_key: silominiosecret
use_ssl: false
schemas: schemas:
directory: /etc/silo/schemas directory: /etc/silo/schemas

View File

@@ -337,7 +337,7 @@ Supporting files:
| File | Purpose | | File | Purpose |
|------|---------| |------|---------|
| `web/src/components/items/CategoryPicker.tsx` | Multi-stage domain/subcategory selector | | `web/src/components/items/CategoryPicker.tsx` | Multi-stage domain/subcategory selector |
| `web/src/components/items/FileDropZone.tsx` | Drag-and-drop file upload with MinIO presigned URLs | | `web/src/components/items/FileDropZone.tsx` | Drag-and-drop file upload |
| `web/src/components/items/TagInput.tsx` | Multi-select tag input for projects | | `web/src/components/items/TagInput.tsx` | Multi-select tag input for projects |
| `web/src/hooks/useFormDescriptor.ts` | Fetches and caches form descriptor from `/api/schemas/{name}/form` | | `web/src/hooks/useFormDescriptor.ts` | Fetches and caches form descriptor from `/api/schemas/{name}/form` |
| `web/src/hooks/useFileUpload.ts` | Manages presigned URL upload flow | | `web/src/hooks/useFileUpload.ts` | Manages presigned URL upload flow |
@@ -421,7 +421,7 @@ Below the picker, the selected category is shown as a breadcrumb: `Fasteners
### FileDropZone ### FileDropZone
Handles drag-and-drop and click-to-browse file uploads with MinIO presigned URL flow. Handles drag-and-drop and click-to-browse file uploads.
**Props**: **Props**:
@@ -435,7 +435,7 @@ interface FileDropZoneProps {
interface PendingAttachment { interface PendingAttachment {
file: File; file: File;
objectKey: string; // MinIO key after upload objectKey: string; // storage key after upload
uploadProgress: number; // 0-100 uploadProgress: number; // 0-100
uploadStatus: 'pending' | 'uploading' | 'complete' | 'error'; uploadStatus: 'pending' | 'uploading' | 'complete' | 'error';
error?: string; error?: string;
@@ -462,7 +462,7 @@ Clicking the zone opens a hidden `<input type="file" multiple>`.
1. On file selection/drop, immediately request a presigned upload URL: `POST /api/uploads/presign` with `{ filename, content_type, size }`. 1. On file selection/drop, immediately request a presigned upload URL: `POST /api/uploads/presign` with `{ filename, content_type, size }`.
2. Backend returns `{ object_key, upload_url, expires_at }`. 2. Backend returns `{ object_key, upload_url, expires_at }`.
3. `PUT` the file directly to the presigned MinIO URL using `XMLHttpRequest` (for progress tracking). 3. `PUT` the file directly to the presigned URL using `XMLHttpRequest` (for progress tracking).
4. On completion, update `PendingAttachment.uploadStatus` to `'complete'` and store the `object_key`. 4. On completion, update `PendingAttachment.uploadStatus` to `'complete'` and store the `object_key`.
5. The `object_key` is later sent to the item creation endpoint to associate the file. 5. The `object_key` is later sent to the item creation endpoint to associate the file.
@@ -589,10 +589,10 @@ Items 1-5 below are implemented. Item 4 (hierarchical categories) is resolved by
``` ```
POST /api/uploads/presign POST /api/uploads/presign
Request: { "filename": "bracket.FCStd", "content_type": "application/octet-stream", "size": 2400000 } Request: { "filename": "bracket.FCStd", "content_type": "application/octet-stream", "size": 2400000 }
Response: { "object_key": "uploads/tmp/{uuid}/{filename}", "upload_url": "https://minio.../...", "expires_at": "2026-02-06T..." } Response: { "object_key": "uploads/tmp/{uuid}/{filename}", "upload_url": "https://...", "expires_at": "2026-02-06T..." }
``` ```
The Go handler generates a presigned PUT URL via the MinIO SDK. Objects are uploaded to a temporary prefix. On item creation, they're moved/linked to the item's permanent prefix. The Go handler generates a presigned PUT URL for direct upload. Objects are uploaded to a temporary prefix. On item creation, they're moved/linked to the item's permanent prefix.
### 2. File Association -- IMPLEMENTED ### 2. File Association -- IMPLEMENTED
@@ -612,7 +612,7 @@ Request: { "object_key": "uploads/tmp/{uuid}/thumb.png" }
Response: 204 Response: 204
``` ```
Stores the thumbnail at `items/{item_id}/thumbnail.png` in MinIO. Updates `item.thumbnail_key` column. Stores the thumbnail at `items/{item_id}/thumbnail.png` in storage. Updates `item.thumbnail_key` column.
### 4. Hierarchical Categories -- IMPLEMENTED (via Form Descriptor) ### 4. Hierarchical Categories -- IMPLEMENTED (via Form Descriptor)

13
go.mod
View File

@@ -11,7 +11,6 @@ require (
github.com/go-ldap/ldap/v3 v3.4.12 github.com/go-ldap/ldap/v3 v3.4.12
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.5.4 github.com/jackc/pgx/v5 v5.5.4
github.com/minio/minio-go/v7 v7.0.66
github.com/rs/zerolog v1.32.0 github.com/rs/zerolog v1.32.0
github.com/sahilm/fuzzy v0.1.1 github.com/sahilm/fuzzy v0.1.1
golang.org/x/crypto v0.47.0 golang.org/x/crypto v0.47.0
@@ -21,28 +20,16 @@ require (
require ( require (
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.17.4 // indirect
github.com/klauspost/cpuid/v2 v2.2.6 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect github.com/mattn/go-isatty v0.0.19 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/minio/sha256-simd v1.0.1 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/rs/xid v1.5.0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect golang.org/x/text v0.33.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
) )

27
go.sum
View File

@@ -13,8 +13,6 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s= github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s=
@@ -26,7 +24,6 @@ github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9
github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4= github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4=
github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo= github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
@@ -51,13 +48,6 @@ github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh6
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.6 h1:ndNyv040zDGIDh8thGkXYjnFtiN02M1PVVF+JE/48xc=
github.com/klauspost/cpuid/v2 v2.2.6/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
@@ -73,31 +63,17 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
github.com/minio/minio-go/v7 v7.0.66 h1:bnTOXOHjOqv/gcMuiVbN9o2ngRItvqE774dG9nq0Dzw=
github.com/minio/minio-go/v7 v7.0.66/go.mod h1:DHAgmyQEGdW3Cif0UooKOyrT3Vxs82zNdV6tkKhRtbs=
github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.32.0 h1:keLypqrlIjaFsbmJOBdB/qvyF8KEtCWHwobLp5l/mQ0= github.com/rs/zerolog v1.32.0 h1:keLypqrlIjaFsbmJOBdB/qvyF8KEtCWHwobLp5l/mQ0=
github.com/rs/zerolog v1.32.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= github.com/rs/zerolog v1.32.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -133,7 +109,6 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -166,8 +141,6 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,391 @@
package api
import (
"encoding/json"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/kindredsystems/silo/internal/auth"
"github.com/kindredsystems/silo/internal/db"
"github.com/kindredsystems/silo/internal/workflow"
)
// ApprovalResponse is the JSON representation for approval endpoints.
type ApprovalResponse struct {
ID string `json:"id"`
WorkflowName string `json:"workflow"`
ECONumber *string `json:"eco_number"`
State string `json:"state"`
UpdatedAt string `json:"updated_at"`
UpdatedBy *string `json:"updated_by"`
Signatures []SignatureResponse `json:"signatures"`
}
// SignatureResponse is the JSON representation for a signature.
type SignatureResponse struct {
Username string `json:"username"`
Role string `json:"role"`
Status string `json:"status"`
SignedAt *string `json:"signed_at"`
Comment *string `json:"comment"`
}
// CreateApprovalRequest is the JSON body for POST /approvals.
type CreateApprovalRequest struct {
Workflow string `json:"workflow"`
ECONumber string `json:"eco_number"`
Signers []SignerRequest `json:"signers"`
}
// SignerRequest defines a signer in the create request.
type SignerRequest struct {
Username string `json:"username"`
Role string `json:"role"`
}
// SignApprovalRequest is the JSON body for POST /approvals/{id}/sign.
type SignApprovalRequest struct {
Status string `json:"status"`
Comment *string `json:"comment"`
}
func approvalToResponse(a *db.ItemApproval) ApprovalResponse {
sigs := make([]SignatureResponse, len(a.Signatures))
for i, s := range a.Signatures {
var signedAt *string
if s.SignedAt != nil {
t := s.SignedAt.UTC().Format("2006-01-02T15:04:05Z")
signedAt = &t
}
sigs[i] = SignatureResponse{
Username: s.Username,
Role: s.Role,
Status: s.Status,
SignedAt: signedAt,
Comment: s.Comment,
}
}
return ApprovalResponse{
ID: a.ID,
WorkflowName: a.WorkflowName,
ECONumber: a.ECONumber,
State: a.State,
UpdatedAt: a.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z"),
UpdatedBy: a.UpdatedBy,
Signatures: sigs,
}
}
// HandleGetApprovals returns all approvals with signatures for an item.
// GET /api/items/{partNumber}/approvals
func (s *Server) HandleGetApprovals(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
partNumber := chi.URLParam(r, "partNumber")
item, err := s.items.GetByPartNumber(ctx, partNumber)
if err != nil {
s.logger.Error().Err(err).Msg("failed to get item")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item")
return
}
if item == nil {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
approvals, err := s.approvals.ListByItemWithSignatures(ctx, item.ID)
if err != nil {
s.logger.Error().Err(err).Msg("failed to list approvals")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list approvals")
return
}
resp := make([]ApprovalResponse, len(approvals))
for i, a := range approvals {
resp[i] = approvalToResponse(a)
}
writeJSON(w, http.StatusOK, resp)
}
// HandleCreateApproval creates an ECO with a workflow and signers.
// POST /api/items/{partNumber}/approvals
func (s *Server) HandleCreateApproval(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
partNumber := chi.URLParam(r, "partNumber")
item, err := s.items.GetByPartNumber(ctx, partNumber)
if err != nil {
s.logger.Error().Err(err).Msg("failed to get item")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item")
return
}
if item == nil {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
var req CreateApprovalRequest
if err := readJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_body", "Invalid JSON body")
return
}
if len(req.Signers) == 0 {
writeError(w, http.StatusBadRequest, "invalid_body", "At least one signer is required")
return
}
// Validate workflow exists
wf, ok := s.workflows[req.Workflow]
if !ok {
writeError(w, http.StatusBadRequest, "invalid_workflow", "Workflow '"+req.Workflow+"' not found")
return
}
// Validate each signer's role matches a gate in the workflow
for _, signer := range req.Signers {
if !wf.HasRole(signer.Role) {
writeError(w, http.StatusBadRequest, "invalid_role",
"Role '"+signer.Role+"' is not defined in workflow '"+req.Workflow+"'")
return
}
}
// Validate all required gates have at least one signer
signerRoles := make(map[string]bool)
for _, signer := range req.Signers {
signerRoles[signer.Role] = true
}
for _, gate := range wf.RequiredGates() {
if !signerRoles[gate.Role] {
writeError(w, http.StatusBadRequest, "missing_required_signer",
"Required role '"+gate.Role+"' ("+gate.Label+") has no assigned signer")
return
}
}
username := ""
if user := auth.UserFromContext(ctx); user != nil {
username = user.Username
}
var ecoNumber *string
if req.ECONumber != "" {
ecoNumber = &req.ECONumber
}
approval := &db.ItemApproval{
ItemID: item.ID,
WorkflowName: req.Workflow,
ECONumber: ecoNumber,
State: "pending",
UpdatedBy: &username,
}
if err := s.approvals.Create(ctx, approval); err != nil {
s.logger.Error().Err(err).Msg("failed to create approval")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create approval")
return
}
// Add signature rows for each signer
for _, signer := range req.Signers {
sig := &db.ApprovalSignature{
ApprovalID: approval.ID,
Username: signer.Username,
Role: signer.Role,
Status: "pending",
}
if err := s.approvals.AddSignature(ctx, sig); err != nil {
s.logger.Error().Err(err).Msg("failed to add signature")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to add signer")
return
}
}
// Re-fetch with signatures for response
approval, err = s.approvals.GetWithSignatures(ctx, approval.ID)
if err != nil {
s.logger.Error().Err(err).Msg("failed to get approval")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get approval")
return
}
resp := approvalToResponse(approval)
writeJSON(w, http.StatusCreated, resp)
s.broker.Publish("approval.created", mustMarshal(map[string]any{
"part_number": partNumber,
"approval_id": approval.ID,
"workflow": approval.WorkflowName,
"eco_number": approval.ECONumber,
}))
}
// HandleSignApproval records an approve or reject signature.
// POST /api/items/{partNumber}/approvals/{id}/sign
func (s *Server) HandleSignApproval(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
partNumber := chi.URLParam(r, "partNumber")
approvalID := chi.URLParam(r, "id")
item, err := s.items.GetByPartNumber(ctx, partNumber)
if err != nil {
s.logger.Error().Err(err).Msg("failed to get item")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item")
return
}
if item == nil {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
approval, err := s.approvals.GetWithSignatures(ctx, approvalID)
if err != nil {
s.logger.Error().Err(err).Msg("failed to get approval")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get approval")
return
}
if approval == nil || approval.ItemID != item.ID {
writeError(w, http.StatusNotFound, "not_found", "Approval not found")
return
}
if approval.State != "pending" {
writeError(w, http.StatusUnprocessableEntity, "invalid_state",
"Approval is in state '"+approval.State+"', signatures can only be added when 'pending'")
return
}
var req SignApprovalRequest
if err := readJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_body", "Invalid JSON body")
return
}
if req.Status != "approved" && req.Status != "rejected" {
writeError(w, http.StatusBadRequest, "invalid_status", "Status must be 'approved' or 'rejected'")
return
}
// Get the caller's username
username := ""
if user := auth.UserFromContext(ctx); user != nil {
username = user.Username
}
// Check that the caller has a pending signature on this approval
sig, err := s.approvals.GetSignatureForUser(ctx, approvalID, username)
if err != nil {
s.logger.Error().Err(err).Msg("failed to get signature")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to check signature")
return
}
if sig == nil {
writeError(w, http.StatusForbidden, "not_a_signer", "You are not a signer on this approval")
return
}
if sig.Status != "pending" {
writeError(w, http.StatusConflict, "already_signed", "You have already signed this approval")
return
}
// Update the signature
if err := s.approvals.UpdateSignature(ctx, sig.ID, req.Status, req.Comment); err != nil {
s.logger.Error().Err(err).Msg("failed to update signature")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update signature")
return
}
s.broker.Publish("approval.signed", mustMarshal(map[string]any{
"part_number": partNumber,
"approval_id": approvalID,
"username": username,
"status": req.Status,
}))
// Evaluate auto-advance based on workflow rules
wf := s.workflows[approval.WorkflowName]
if wf != nil {
// Re-fetch signatures after update
approval, err = s.approvals.GetWithSignatures(ctx, approvalID)
if err == nil && approval != nil {
newState := evaluateApprovalState(wf, approval)
if newState != "" && newState != approval.State {
if err := s.approvals.UpdateState(ctx, approvalID, newState, username); err != nil {
s.logger.Warn().Err(err).Msg("failed to auto-advance approval state")
} else {
approval.State = newState
s.broker.Publish("approval.completed", mustMarshal(map[string]any{
"part_number": partNumber,
"approval_id": approvalID,
"state": newState,
}))
}
}
}
}
// Return updated approval
if approval == nil {
approval, _ = s.approvals.GetWithSignatures(ctx, approvalID)
}
if approval != nil {
writeJSON(w, http.StatusOK, approvalToResponse(approval))
} else {
w.WriteHeader(http.StatusOK)
}
}
// HandleListWorkflows returns all loaded workflow definitions.
// GET /api/workflows
func (s *Server) HandleListWorkflows(w http.ResponseWriter, r *http.Request) {
resp := make([]map[string]any, 0, len(s.workflows))
for _, wf := range s.workflows {
resp = append(resp, map[string]any{
"name": wf.Name,
"version": wf.Version,
"description": wf.Description,
"gates": wf.Gates,
})
}
writeJSON(w, http.StatusOK, resp)
}
// evaluateApprovalState checks workflow rules against current signatures
// and returns the new state, or "" if no transition is needed.
func evaluateApprovalState(wf *workflow.Workflow, approval *db.ItemApproval) string {
// Check for any rejection
if wf.Rules.AnyReject != "" {
for _, sig := range approval.Signatures {
if sig.Status == "rejected" {
return wf.Rules.AnyReject
}
}
}
// Check if all required roles have approved
if wf.Rules.AllRequiredApprove != "" {
requiredRoles := make(map[string]bool)
for _, gate := range wf.RequiredGates() {
requiredRoles[gate.Role] = true
}
// For each required role, check that all signers with that role have approved
for _, sig := range approval.Signatures {
if requiredRoles[sig.Role] && sig.Status != "approved" {
return "" // at least one required signer hasn't approved yet
}
}
// All required signers approved
return wf.Rules.AllRequiredApprove
}
return ""
}
// readJSON decodes a JSON request body.
func readJSON(r *http.Request, v any) error {
return json.NewDecoder(r.Body).Decode(v)
}

View File

@@ -21,7 +21,7 @@ type presignUploadRequest struct {
Size int64 `json:"size"` Size int64 `json:"size"`
} }
// HandlePresignUpload generates a presigned PUT URL for direct browser upload to MinIO. // HandlePresignUpload generates a presigned PUT URL for direct browser upload.
func (s *Server) HandlePresignUpload(w http.ResponseWriter, r *http.Request) { func (s *Server) HandlePresignUpload(w http.ResponseWriter, r *http.Request) {
if s.storage == nil { if s.storage == nil {
writeError(w, http.StatusServiceUnavailable, "storage_unavailable", "File storage not configured") writeError(w, http.StatusServiceUnavailable, "storage_unavailable", "File storage not configured")
@@ -317,12 +317,9 @@ func (s *Server) HandleSetItemThumbnail(w http.ResponseWriter, r *http.Request)
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
// storageBackend returns the configured storage backend name, defaulting to "minio". // storageBackend returns the configured storage backend name.
func (s *Server) storageBackend() string { func (s *Server) storageBackend() string {
if s.cfg != nil && s.cfg.Storage.Backend != "" { return "filesystem"
return s.cfg.Storage.Backend
}
return "minio"
} }
// HandleUploadItemFile accepts a multipart file upload and stores it as an item attachment. // HandleUploadItemFile accepts a multipart file upload and stores it as an item attachment.

View File

@@ -25,6 +25,7 @@ import (
"github.com/kindredsystems/silo/internal/partnum" "github.com/kindredsystems/silo/internal/partnum"
"github.com/kindredsystems/silo/internal/schema" "github.com/kindredsystems/silo/internal/schema"
"github.com/kindredsystems/silo/internal/storage" "github.com/kindredsystems/silo/internal/storage"
"github.com/kindredsystems/silo/internal/workflow"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
@@ -58,6 +59,8 @@ type Server struct {
metadata *db.ItemMetadataRepository metadata *db.ItemMetadataRepository
deps *db.ItemDependencyRepository deps *db.ItemDependencyRepository
macros *db.ItemMacroRepository macros *db.ItemMacroRepository
approvals *db.ItemApprovalRepository
workflows map[string]*workflow.Workflow
} }
// NewServer creates a new API server. // NewServer creates a new API server.
@@ -77,6 +80,7 @@ func NewServer(
jobDefsDir string, jobDefsDir string,
registry *modules.Registry, registry *modules.Registry,
cfg *config.Config, cfg *config.Config,
workflows map[string]*workflow.Workflow,
) *Server { ) *Server {
items := db.NewItemRepository(database) items := db.NewItemRepository(database)
projects := db.NewProjectRepository(database) projects := db.NewProjectRepository(database)
@@ -89,6 +93,7 @@ func NewServer(
metadata := db.NewItemMetadataRepository(database) metadata := db.NewItemMetadataRepository(database)
itemDeps := db.NewItemDependencyRepository(database) itemDeps := db.NewItemDependencyRepository(database)
itemMacros := db.NewItemMacroRepository(database) itemMacros := db.NewItemMacroRepository(database)
itemApprovals := db.NewItemApprovalRepository(database)
seqStore := &dbSequenceStore{db: database, schemas: schemas} seqStore := &dbSequenceStore{db: database, schemas: schemas}
partgen := partnum.NewGenerator(schemas, seqStore) partgen := partnum.NewGenerator(schemas, seqStore)
@@ -120,6 +125,8 @@ func NewServer(
metadata: metadata, metadata: metadata,
deps: itemDeps, deps: itemDeps,
macros: itemMacros, macros: itemMacros,
approvals: itemApprovals,
workflows: workflows,
} }
} }

View File

@@ -74,11 +74,58 @@ func (s *Server) packKCFile(ctx context.Context, data []byte, item *db.Item, rev
deps = []kc.Dependency{} deps = []kc.Dependency{}
} }
// Build approvals from item_approvals table.
var approvals []kc.ApprovalEntry
dbApprovals, err := s.approvals.ListByItemWithSignatures(ctx, item.ID)
if err != nil {
s.logger.Warn().Err(err).Str("part_number", item.PartNumber).Msg("kc: failed to query approvals for packing")
} else {
approvals = make([]kc.ApprovalEntry, len(dbApprovals))
for i, a := range dbApprovals {
sigs := make([]kc.SignatureEntry, len(a.Signatures))
for j, sig := range a.Signatures {
var signedAt string
if sig.SignedAt != nil {
signedAt = sig.SignedAt.UTC().Format("2006-01-02T15:04:05Z")
}
var comment string
if sig.Comment != nil {
comment = *sig.Comment
}
sigs[j] = kc.SignatureEntry{
Username: sig.Username,
Role: sig.Role,
Status: sig.Status,
SignedAt: signedAt,
Comment: comment,
}
}
var ecoNumber string
if a.ECONumber != nil {
ecoNumber = *a.ECONumber
}
var updatedBy string
if a.UpdatedBy != nil {
updatedBy = *a.UpdatedBy
}
approvals[i] = kc.ApprovalEntry{
ID: a.ID,
WorkflowName: a.WorkflowName,
ECONumber: ecoNumber,
State: a.State,
UpdatedAt: a.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z"),
UpdatedBy: updatedBy,
Signatures: sigs,
}
}
}
input := &kc.PackInput{ input := &kc.PackInput{
Manifest: manifest, Manifest: manifest,
Metadata: metadata, Metadata: metadata,
History: history, History: history,
Dependencies: deps, Dependencies: deps,
Approvals: approvals,
} }
return kc.Pack(data, input) return kc.Pack(data, input)

View File

@@ -68,6 +68,9 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
// SSE event stream (viewer+) // SSE event stream (viewer+)
r.Get("/events", server.HandleEvents) r.Get("/events", server.HandleEvents)
// Workflows (viewer+)
r.Get("/workflows", server.HandleListWorkflows)
// Auth endpoints // Auth endpoints
r.Get("/auth/me", server.HandleGetCurrentUser) r.Get("/auth/me", server.HandleGetCurrentUser)
r.Route("/auth/tokens", func(r chi.Router) { r.Route("/auth/tokens", func(r chi.Router) {
@@ -177,6 +180,7 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
r.Get("/dependencies/resolve", server.HandleResolveDependencies) r.Get("/dependencies/resolve", server.HandleResolveDependencies)
r.Get("/macros", server.HandleGetMacros) r.Get("/macros", server.HandleGetMacros)
r.Get("/macros/{filename}", server.HandleGetMacro) r.Get("/macros/{filename}", server.HandleGetMacro)
r.Get("/approvals", server.HandleGetApprovals)
// DAG (gated by dag module) // DAG (gated by dag module)
r.Route("/dag", func(r chi.Router) { r.Route("/dag", func(r chi.Router) {
@@ -217,6 +221,8 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
r.Put("/metadata", server.HandleUpdateMetadata) r.Put("/metadata", server.HandleUpdateMetadata)
r.Patch("/metadata/lifecycle", server.HandleUpdateLifecycle) r.Patch("/metadata/lifecycle", server.HandleUpdateLifecycle)
r.Patch("/metadata/tags", server.HandleUpdateTags) r.Patch("/metadata/tags", server.HandleUpdateTags)
r.Post("/approvals", server.HandleCreateApproval)
r.Post("/approvals/{id}/sign", server.HandleSignApproval)
}) })
}) })
}) })

View File

@@ -86,7 +86,7 @@ func (ss *ServerState) ToggleReadOnly() {
ss.SetReadOnly(!current) ss.SetReadOnly(!current)
} }
// StartStorageHealthCheck launches a periodic check of MinIO reachability. // StartStorageHealthCheck launches a periodic check of storage reachability.
// Updates storageOK and broadcasts server.state on transitions. // Updates storageOK and broadcasts server.state on transitions.
func (ss *ServerState) StartStorageHealthCheck() { func (ss *ServerState) StartStorageHealthCheck() {
if ss.storage == nil { if ss.storage == nil {

View File

@@ -224,10 +224,8 @@ func (s *Server) buildSchemasSettings() map[string]any {
func (s *Server) buildStorageSettings(ctx context.Context) map[string]any { func (s *Server) buildStorageSettings(ctx context.Context) map[string]any {
result := map[string]any{ result := map[string]any{
"enabled": true, "enabled": true,
"endpoint": s.cfg.Storage.Endpoint, "backend": "filesystem",
"bucket": s.cfg.Storage.Bucket, "root_dir": s.cfg.Storage.Filesystem.RootDir,
"use_ssl": s.cfg.Storage.UseSSL,
"region": s.cfg.Storage.Region,
} }
if s.storage != nil { if s.storage != nil {
if err := s.storage.Ping(ctx); err != nil { if err := s.storage.Ping(ctx); err != nil {

View File

@@ -31,8 +31,8 @@ func newSettingsTestServer(t *testing.T) *Server {
MaxConnections: 10, MaxConnections: 10,
}, },
Storage: config.StorageConfig{ Storage: config.StorageConfig{
Endpoint: "minio:9000", Bucket: "silo", Region: "us-east-1", Backend: "filesystem",
AccessKey: "minioadmin", SecretKey: "miniosecret", Filesystem: config.FilesystemConfig{RootDir: "/tmp/silo-test"},
}, },
Schemas: config.SchemasConfig{Directory: "/etc/silo/schemas", Default: "kindred-rd"}, Schemas: config.SchemasConfig{Directory: "/etc/silo/schemas", Default: "kindred-rd"},
Auth: config.AuthConfig{ Auth: config.AuthConfig{

View File

@@ -18,6 +18,7 @@ type Config struct {
Odoo OdooConfig `yaml:"odoo"` Odoo OdooConfig `yaml:"odoo"`
Auth AuthConfig `yaml:"auth"` Auth AuthConfig `yaml:"auth"`
Jobs JobsConfig `yaml:"jobs"` Jobs JobsConfig `yaml:"jobs"`
Workflows WorkflowsConfig `yaml:"workflows"`
Modules ModulesConfig `yaml:"modules"` Modules ModulesConfig `yaml:"modules"`
} }
@@ -109,15 +110,9 @@ type DatabaseConfig struct {
MaxConnections int `yaml:"max_connections"` MaxConnections int `yaml:"max_connections"`
} }
// StorageConfig holds object storage settings. // StorageConfig holds file storage settings.
type StorageConfig struct { type StorageConfig struct {
Backend string `yaml:"backend"` // "minio" (default) or "filesystem" Backend string `yaml:"backend"` // "filesystem"
Endpoint string `yaml:"endpoint"`
AccessKey string `yaml:"access_key"`
SecretKey string `yaml:"secret_key"`
Bucket string `yaml:"bucket"`
UseSSL bool `yaml:"use_ssl"`
Region string `yaml:"region"`
Filesystem FilesystemConfig `yaml:"filesystem"` Filesystem FilesystemConfig `yaml:"filesystem"`
} }
@@ -146,6 +141,11 @@ type JobsConfig struct {
DefaultPriority int `yaml:"default_priority"` // default 100 DefaultPriority int `yaml:"default_priority"` // default 100
} }
// WorkflowsConfig holds approval workflow definition settings.
type WorkflowsConfig struct {
Directory string `yaml:"directory"` // default /etc/silo/workflows
}
// OdooConfig holds Odoo ERP integration settings. // OdooConfig holds Odoo ERP integration settings.
type OdooConfig struct { type OdooConfig struct {
Enabled bool `yaml:"enabled"` Enabled bool `yaml:"enabled"`
@@ -183,9 +183,6 @@ func Load(path string) (*Config, error) {
if cfg.Database.MaxConnections == 0 { if cfg.Database.MaxConnections == 0 {
cfg.Database.MaxConnections = 10 cfg.Database.MaxConnections = 10
} }
if cfg.Storage.Region == "" {
cfg.Storage.Region = "us-east-1"
}
if cfg.Schemas.Directory == "" { if cfg.Schemas.Directory == "" {
cfg.Schemas.Directory = "/etc/silo/schemas" cfg.Schemas.Directory = "/etc/silo/schemas"
} }
@@ -204,6 +201,9 @@ func Load(path string) (*Config, error) {
if cfg.Jobs.DefaultPriority == 0 { if cfg.Jobs.DefaultPriority == 0 {
cfg.Jobs.DefaultPriority = 100 cfg.Jobs.DefaultPriority = 100
} }
if cfg.Workflows.Directory == "" {
cfg.Workflows.Directory = "/etc/silo/workflows"
}
// Override with environment variables // Override with environment variables
if v := os.Getenv("SILO_DB_HOST"); v != "" { if v := os.Getenv("SILO_DB_HOST"); v != "" {
@@ -218,14 +218,8 @@ func Load(path string) (*Config, error) {
if v := os.Getenv("SILO_DB_PASSWORD"); v != "" { if v := os.Getenv("SILO_DB_PASSWORD"); v != "" {
cfg.Database.Password = v cfg.Database.Password = v
} }
if v := os.Getenv("SILO_MINIO_ENDPOINT"); v != "" { if v := os.Getenv("SILO_STORAGE_ROOT_DIR"); v != "" {
cfg.Storage.Endpoint = v cfg.Storage.Filesystem.RootDir = v
}
if v := os.Getenv("SILO_MINIO_ACCESS_KEY"); v != "" {
cfg.Storage.AccessKey = v
}
if v := os.Getenv("SILO_MINIO_SECRET_KEY"); v != "" {
cfg.Storage.SecretKey = v
} }
// Auth defaults // Auth defaults

View File

@@ -0,0 +1,212 @@
package db
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// ItemApproval represents a row in the item_approvals table.
type ItemApproval struct {
ID string
ItemID string
WorkflowName string
ECONumber *string
State string // draft | pending | approved | rejected
UpdatedAt time.Time
UpdatedBy *string
Signatures []ApprovalSignature // populated by WithSignatures methods
}
// ApprovalSignature represents a row in the approval_signatures table.
type ApprovalSignature struct {
ID string
ApprovalID string
Username string
Role string
Status string // pending | approved | rejected
SignedAt *time.Time
Comment *string
}
// ItemApprovalRepository provides item_approvals database operations.
type ItemApprovalRepository struct {
db *DB
}
// NewItemApprovalRepository creates a new item approval repository.
func NewItemApprovalRepository(db *DB) *ItemApprovalRepository {
return &ItemApprovalRepository{db: db}
}
// Create inserts a new approval row. The ID is populated on return.
func (r *ItemApprovalRepository) Create(ctx context.Context, a *ItemApproval) error {
return r.db.pool.QueryRow(ctx, `
INSERT INTO item_approvals (item_id, workflow_name, eco_number, state, updated_by)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, updated_at
`, a.ItemID, a.WorkflowName, a.ECONumber, a.State, a.UpdatedBy).Scan(&a.ID, &a.UpdatedAt)
}
// AddSignature inserts a new signature row. The ID is populated on return.
func (r *ItemApprovalRepository) AddSignature(ctx context.Context, s *ApprovalSignature) error {
return r.db.pool.QueryRow(ctx, `
INSERT INTO approval_signatures (approval_id, username, role, status)
VALUES ($1, $2, $3, $4)
RETURNING id
`, s.ApprovalID, s.Username, s.Role, s.Status).Scan(&s.ID)
}
// GetWithSignatures returns a single approval with its signatures.
func (r *ItemApprovalRepository) GetWithSignatures(ctx context.Context, approvalID string) (*ItemApproval, error) {
a := &ItemApproval{}
err := r.db.pool.QueryRow(ctx, `
SELECT id, item_id, workflow_name, eco_number, state, updated_at, updated_by
FROM item_approvals
WHERE id = $1
`, approvalID).Scan(&a.ID, &a.ItemID, &a.WorkflowName, &a.ECONumber, &a.State, &a.UpdatedAt, &a.UpdatedBy)
if err != nil {
if err == pgx.ErrNoRows {
return nil, nil
}
return nil, fmt.Errorf("getting approval: %w", err)
}
sigs, err := r.signaturesForApproval(ctx, approvalID)
if err != nil {
return nil, err
}
a.Signatures = sigs
return a, nil
}
// ListByItemWithSignatures returns all approvals for an item, each with signatures.
func (r *ItemApprovalRepository) ListByItemWithSignatures(ctx context.Context, itemID string) ([]*ItemApproval, error) {
rows, err := r.db.pool.Query(ctx, `
SELECT id, item_id, workflow_name, eco_number, state, updated_at, updated_by
FROM item_approvals
WHERE item_id = $1
ORDER BY updated_at DESC
`, itemID)
if err != nil {
return nil, fmt.Errorf("listing approvals: %w", err)
}
defer rows.Close()
var approvals []*ItemApproval
var approvalIDs []string
for rows.Next() {
a := &ItemApproval{}
if err := rows.Scan(&a.ID, &a.ItemID, &a.WorkflowName, &a.ECONumber, &a.State, &a.UpdatedAt, &a.UpdatedBy); err != nil {
return nil, fmt.Errorf("scanning approval: %w", err)
}
approvals = append(approvals, a)
approvalIDs = append(approvalIDs, a.ID)
}
if len(approvalIDs) == 0 {
return approvals, nil
}
// Batch-fetch all signatures
sigRows, err := r.db.pool.Query(ctx, `
SELECT id, approval_id, username, role, status, signed_at, comment
FROM approval_signatures
WHERE approval_id = ANY($1)
ORDER BY username
`, approvalIDs)
if err != nil {
return nil, fmt.Errorf("listing signatures: %w", err)
}
defer sigRows.Close()
sigMap := make(map[string][]ApprovalSignature)
for sigRows.Next() {
var s ApprovalSignature
if err := sigRows.Scan(&s.ID, &s.ApprovalID, &s.Username, &s.Role, &s.Status, &s.SignedAt, &s.Comment); err != nil {
return nil, fmt.Errorf("scanning signature: %w", err)
}
sigMap[s.ApprovalID] = append(sigMap[s.ApprovalID], s)
}
for _, a := range approvals {
a.Signatures = sigMap[a.ID]
if a.Signatures == nil {
a.Signatures = []ApprovalSignature{}
}
}
return approvals, nil
}
// UpdateState updates the approval state and updated_by.
func (r *ItemApprovalRepository) UpdateState(ctx context.Context, approvalID, state, updatedBy string) error {
_, err := r.db.pool.Exec(ctx, `
UPDATE item_approvals
SET state = $2, updated_by = $3, updated_at = now()
WHERE id = $1
`, approvalID, state, updatedBy)
if err != nil {
return fmt.Errorf("updating approval state: %w", err)
}
return nil
}
// GetSignatureForUser returns the signature for a specific user on an approval.
func (r *ItemApprovalRepository) GetSignatureForUser(ctx context.Context, approvalID, username string) (*ApprovalSignature, error) {
s := &ApprovalSignature{}
err := r.db.pool.QueryRow(ctx, `
SELECT id, approval_id, username, role, status, signed_at, comment
FROM approval_signatures
WHERE approval_id = $1 AND username = $2
`, approvalID, username).Scan(&s.ID, &s.ApprovalID, &s.Username, &s.Role, &s.Status, &s.SignedAt, &s.Comment)
if err != nil {
if err == pgx.ErrNoRows {
return nil, nil
}
return nil, fmt.Errorf("getting signature: %w", err)
}
return s, nil
}
// UpdateSignature updates a signature's status, comment, and signed_at timestamp.
func (r *ItemApprovalRepository) UpdateSignature(ctx context.Context, sigID, status string, comment *string) error {
_, err := r.db.pool.Exec(ctx, `
UPDATE approval_signatures
SET status = $2, comment = $3, signed_at = now()
WHERE id = $1
`, sigID, status, comment)
if err != nil {
return fmt.Errorf("updating signature: %w", err)
}
return nil
}
// signaturesForApproval returns all signatures for a single approval.
func (r *ItemApprovalRepository) signaturesForApproval(ctx context.Context, approvalID string) ([]ApprovalSignature, error) {
rows, err := r.db.pool.Query(ctx, `
SELECT id, approval_id, username, role, status, signed_at, comment
FROM approval_signatures
WHERE approval_id = $1
ORDER BY username
`, approvalID)
if err != nil {
return nil, fmt.Errorf("listing signatures: %w", err)
}
defer rows.Close()
var sigs []ApprovalSignature
for rows.Next() {
var s ApprovalSignature
if err := rows.Scan(&s.ID, &s.ApprovalID, &s.Username, &s.Role, &s.Status, &s.SignedAt, &s.Comment); err != nil {
return nil, fmt.Errorf("scanning signature: %w", err)
}
sigs = append(sigs, s)
}
if sigs == nil {
sigs = []ApprovalSignature{}
}
return sigs, nil
}

View File

@@ -14,7 +14,7 @@ type ItemFile struct {
ContentType string ContentType string
Size int64 Size int64
ObjectKey string ObjectKey string
StorageBackend string // "minio" or "filesystem" StorageBackend string
CreatedAt time.Time CreatedAt time.Time
} }
@@ -31,7 +31,7 @@ func NewItemFileRepository(db *DB) *ItemFileRepository {
// Create inserts a new item file record. // Create inserts a new item file record.
func (r *ItemFileRepository) Create(ctx context.Context, f *ItemFile) error { func (r *ItemFileRepository) Create(ctx context.Context, f *ItemFile) error {
if f.StorageBackend == "" { if f.StorageBackend == "" {
f.StorageBackend = "minio" f.StorageBackend = "filesystem"
} }
err := r.db.pool.QueryRow(ctx, err := r.db.pool.QueryRow(ctx,
`INSERT INTO item_files (item_id, filename, content_type, size, object_key, storage_backend) `INSERT INTO item_files (item_id, filename, content_type, size, object_key, storage_backend)
@@ -49,7 +49,7 @@ func (r *ItemFileRepository) Create(ctx context.Context, f *ItemFile) error {
func (r *ItemFileRepository) ListByItem(ctx context.Context, itemID string) ([]*ItemFile, error) { func (r *ItemFileRepository) ListByItem(ctx context.Context, itemID string) ([]*ItemFile, error) {
rows, err := r.db.pool.Query(ctx, rows, err := r.db.pool.Query(ctx,
`SELECT id, item_id, filename, content_type, size, object_key, `SELECT id, item_id, filename, content_type, size, object_key,
COALESCE(storage_backend, 'minio'), created_at COALESCE(storage_backend, 'filesystem'), created_at
FROM item_files WHERE item_id = $1 ORDER BY created_at`, FROM item_files WHERE item_id = $1 ORDER BY created_at`,
itemID, itemID,
) )
@@ -74,7 +74,7 @@ func (r *ItemFileRepository) Get(ctx context.Context, id string) (*ItemFile, err
f := &ItemFile{} f := &ItemFile{}
err := r.db.pool.QueryRow(ctx, err := r.db.pool.QueryRow(ctx,
`SELECT id, item_id, filename, content_type, size, object_key, `SELECT id, item_id, filename, content_type, size, object_key,
COALESCE(storage_backend, 'minio'), created_at COALESCE(storage_backend, 'filesystem'), created_at
FROM item_files WHERE id = $1`, FROM item_files WHERE id = $1`,
id, id,
).Scan(&f.ID, &f.ItemID, &f.Filename, &f.ContentType, &f.Size, &f.ObjectKey, &f.StorageBackend, &f.CreatedAt) ).Scan(&f.ID, &f.ItemID, &f.Filename, &f.ContentType, &f.Size, &f.ObjectKey, &f.StorageBackend, &f.CreatedAt)

View File

@@ -26,7 +26,7 @@ type Item struct {
UpdatedBy *string UpdatedBy *string
SourcingType string // "manufactured" or "purchased" SourcingType string // "manufactured" or "purchased"
LongDescription *string // extended description LongDescription *string // extended description
ThumbnailKey *string // MinIO key for item thumbnail ThumbnailKey *string // storage key for item thumbnail
} }
// Revision represents a revision record. // Revision represents a revision record.
@@ -39,7 +39,7 @@ type Revision struct {
FileVersion *string FileVersion *string
FileChecksum *string FileChecksum *string
FileSize *int64 FileSize *int64
FileStorageBackend string // "minio" or "filesystem" FileStorageBackend string
ThumbnailKey *string ThumbnailKey *string
CreatedAt time.Time CreatedAt time.Time
CreatedBy *string CreatedBy *string
@@ -308,7 +308,7 @@ func (r *ItemRepository) CreateRevision(ctx context.Context, rev *Revision) erro
} }
if rev.FileStorageBackend == "" { if rev.FileStorageBackend == "" {
rev.FileStorageBackend = "minio" rev.FileStorageBackend = "filesystem"
} }
err = r.db.pool.QueryRow(ctx, ` err = r.db.pool.QueryRow(ctx, `
@@ -347,7 +347,7 @@ func (r *ItemRepository) GetRevisions(ctx context.Context, itemID string) ([]*Re
if hasStatusColumn { if hasStatusColumn {
rows, err = r.db.pool.Query(ctx, ` rows, err = r.db.pool.Query(ctx, `
SELECT id, item_id, revision_number, properties, file_key, file_version, SELECT id, item_id, revision_number, properties, file_key, file_version,
file_checksum, file_size, COALESCE(file_storage_backend, 'minio'), file_checksum, file_size, COALESCE(file_storage_backend, 'filesystem'),
thumbnail_key, created_at, created_by, comment, thumbnail_key, created_at, created_by, comment,
COALESCE(status, 'draft') as status, COALESCE(labels, ARRAY[]::TEXT[]) as labels COALESCE(status, 'draft') as status, COALESCE(labels, ARRAY[]::TEXT[]) as labels
FROM revisions FROM revisions
@@ -386,7 +386,7 @@ func (r *ItemRepository) GetRevisions(ctx context.Context, itemID string) ([]*Re
) )
rev.Status = "draft" rev.Status = "draft"
rev.Labels = []string{} rev.Labels = []string{}
rev.FileStorageBackend = "minio" rev.FileStorageBackend = "filesystem"
} }
if err != nil { if err != nil {
return nil, fmt.Errorf("scanning revision: %w", err) return nil, fmt.Errorf("scanning revision: %w", err)
@@ -420,7 +420,7 @@ func (r *ItemRepository) GetRevision(ctx context.Context, itemID string, revisio
if hasStatusColumn { if hasStatusColumn {
err = r.db.pool.QueryRow(ctx, ` err = r.db.pool.QueryRow(ctx, `
SELECT id, item_id, revision_number, properties, file_key, file_version, SELECT id, item_id, revision_number, properties, file_key, file_version,
file_checksum, file_size, COALESCE(file_storage_backend, 'minio'), file_checksum, file_size, COALESCE(file_storage_backend, 'filesystem'),
thumbnail_key, created_at, created_by, comment, thumbnail_key, created_at, created_by, comment,
COALESCE(status, 'draft') as status, COALESCE(labels, ARRAY[]::TEXT[]) as labels COALESCE(status, 'draft') as status, COALESCE(labels, ARRAY[]::TEXT[]) as labels
FROM revisions FROM revisions
@@ -443,7 +443,7 @@ func (r *ItemRepository) GetRevision(ctx context.Context, itemID string, revisio
) )
rev.Status = "draft" rev.Status = "draft"
rev.Labels = []string{} rev.Labels = []string{}
rev.FileStorageBackend = "minio" rev.FileStorageBackend = "filesystem"
} }
if err == pgx.ErrNoRows { if err == pgx.ErrNoRows {

View File

@@ -64,6 +64,26 @@ type HistoryEntry struct {
Labels []string `json:"labels"` Labels []string `json:"labels"`
} }
// ApprovalEntry represents one entry in silo/approvals.json.
type ApprovalEntry struct {
ID string `json:"id"`
WorkflowName string `json:"workflow"`
ECONumber string `json:"eco_number,omitempty"`
State string `json:"state"`
UpdatedAt string `json:"updated_at"`
UpdatedBy string `json:"updated_by,omitempty"`
Signatures []SignatureEntry `json:"signatures"`
}
// SignatureEntry represents one signer in an approval.
type SignatureEntry struct {
Username string `json:"username"`
Role string `json:"role"`
Status string `json:"status"`
SignedAt string `json:"signed_at,omitempty"`
Comment string `json:"comment,omitempty"`
}
// PackInput holds all the data needed to repack silo/ entries in a .kc file. // PackInput holds all the data needed to repack silo/ entries in a .kc file.
// Each field is optional — nil/empty means the entry is omitted from the ZIP. // Each field is optional — nil/empty means the entry is omitted from the ZIP.
type PackInput struct { type PackInput struct {
@@ -71,6 +91,7 @@ type PackInput struct {
Metadata *Metadata Metadata *Metadata
History []HistoryEntry History []HistoryEntry
Dependencies []Dependency Dependencies []Dependency
Approvals []ApprovalEntry
} }
// Extract opens a ZIP archive from data and parses the silo/ directory. // Extract opens a ZIP archive from data and parses the silo/ directory.

View File

@@ -83,6 +83,11 @@ func Pack(original []byte, input *PackInput) ([]byte, error) {
return nil, fmt.Errorf("kc: writing dependencies.json: %w", err) return nil, fmt.Errorf("kc: writing dependencies.json: %w", err)
} }
} }
if input.Approvals != nil {
if err := writeJSONEntry(zw, "silo/approvals.json", input.Approvals); err != nil {
return nil, fmt.Errorf("kc: writing approvals.json: %w", err)
}
}
if err := zw.Close(); err != nil { if err := zw.Close(); err != nil {
return nil, fmt.Errorf("kc: closing zip writer: %w", err) return nil, fmt.Errorf("kc: closing zip writer: %w", err)

View File

@@ -50,7 +50,7 @@ type Registry struct {
var builtinModules = []ModuleInfo{ var builtinModules = []ModuleInfo{
{ID: Core, Name: "Core PDM", Description: "Items, revisions, files, BOM, search, import/export", Required: true, Version: "0.2"}, {ID: Core, Name: "Core PDM", Description: "Items, revisions, files, BOM, search, import/export", Required: true, Version: "0.2"},
{ID: Schemas, Name: "Schemas", Description: "Part numbering schema parsing and segment management", Required: true}, {ID: Schemas, Name: "Schemas", Description: "Part numbering schema parsing and segment management", Required: true},
{ID: Storage, Name: "Storage", Description: "MinIO/S3 file storage, presigned uploads", Required: true}, {ID: Storage, Name: "Storage", Description: "Filesystem storage", Required: true},
{ID: Auth, Name: "Authentication", Description: "Local, LDAP, OIDC authentication and RBAC", DefaultEnabled: true}, {ID: Auth, Name: "Authentication", Description: "Local, LDAP, OIDC authentication and RBAC", DefaultEnabled: true},
{ID: Projects, Name: "Projects", Description: "Project management and item tagging", DefaultEnabled: true}, {ID: Projects, Name: "Projects", Description: "Project management and item tagging", DefaultEnabled: true},
{ID: Audit, Name: "Audit", Description: "Audit logging, completeness scoring", DefaultEnabled: true}, {ID: Audit, Name: "Audit", Description: "Audit logging, completeness scoring", DefaultEnabled: true},

View File

@@ -3,6 +3,7 @@ package storage
import ( import (
"context" "context"
"fmt"
"io" "io"
"net/url" "net/url"
"time" "time"
@@ -19,3 +20,21 @@ type FileStore interface {
PresignPut(ctx context.Context, key string, expiry time.Duration) (*url.URL, error) PresignPut(ctx context.Context, key string, expiry time.Duration) (*url.URL, error)
Ping(ctx context.Context) error Ping(ctx context.Context) error
} }
// PutResult contains the result of a put operation.
type PutResult struct {
Key string
VersionID string
Size int64
Checksum string
}
// FileKey generates a storage key for an item file.
func FileKey(partNumber string, revision int) string {
return fmt.Sprintf("items/%s/rev%d.FCStd", partNumber, revision)
}
// ThumbnailKey generates a storage key for a thumbnail.
func ThumbnailKey(partNumber string, revision int) string {
return fmt.Sprintf("thumbnails/%s/rev%d.png", partNumber, revision)
}

View File

@@ -1,174 +0,0 @@
package storage
import (
"context"
"fmt"
"io"
"net/url"
"time"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
)
// Config holds MinIO connection settings.
type Config struct {
Endpoint string
AccessKey string
SecretKey string
Bucket string
UseSSL bool
Region string
}
// Compile-time check: *Storage implements FileStore.
var _ FileStore = (*Storage)(nil)
// Storage wraps MinIO client operations.
type Storage struct {
client *minio.Client
bucket string
}
// Connect creates a new MinIO storage client.
func Connect(ctx context.Context, cfg Config) (*Storage, error) {
client, err := minio.New(cfg.Endpoint, &minio.Options{
Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""),
Secure: cfg.UseSSL,
Region: cfg.Region,
})
if err != nil {
return nil, fmt.Errorf("creating minio client: %w", err)
}
// Ensure bucket exists with versioning
exists, err := client.BucketExists(ctx, cfg.Bucket)
if err != nil {
return nil, fmt.Errorf("checking bucket: %w", err)
}
if !exists {
if err := client.MakeBucket(ctx, cfg.Bucket, minio.MakeBucketOptions{
Region: cfg.Region,
}); err != nil {
return nil, fmt.Errorf("creating bucket: %w", err)
}
// Enable versioning
if err := client.EnableVersioning(ctx, cfg.Bucket); err != nil {
return nil, fmt.Errorf("enabling versioning: %w", err)
}
}
return &Storage{client: client, bucket: cfg.Bucket}, nil
}
// PutResult contains the result of a put operation.
type PutResult struct {
Key string
VersionID string
Size int64
Checksum string
}
// Put uploads a file to storage.
func (s *Storage) Put(ctx context.Context, key string, reader io.Reader, size int64, contentType string) (*PutResult, error) {
info, err := s.client.PutObject(ctx, s.bucket, key, reader, size, minio.PutObjectOptions{
ContentType: contentType,
})
if err != nil {
return nil, fmt.Errorf("uploading object: %w", err)
}
return &PutResult{
Key: key,
VersionID: info.VersionID,
Size: info.Size,
Checksum: info.ChecksumSHA256,
}, nil
}
// Get downloads a file from storage.
func (s *Storage) Get(ctx context.Context, key string) (io.ReadCloser, error) {
obj, err := s.client.GetObject(ctx, s.bucket, key, minio.GetObjectOptions{})
if err != nil {
return nil, fmt.Errorf("getting object: %w", err)
}
return obj, nil
}
// GetVersion downloads a specific version of a file.
func (s *Storage) GetVersion(ctx context.Context, key, versionID string) (io.ReadCloser, error) {
obj, err := s.client.GetObject(ctx, s.bucket, key, minio.GetObjectOptions{
VersionID: versionID,
})
if err != nil {
return nil, fmt.Errorf("getting object version: %w", err)
}
return obj, nil
}
// Delete removes a file from storage.
func (s *Storage) Delete(ctx context.Context, key string) error {
if err := s.client.RemoveObject(ctx, s.bucket, key, minio.RemoveObjectOptions{}); err != nil {
return fmt.Errorf("removing object: %w", err)
}
return nil
}
// Exists checks if an object exists in storage.
func (s *Storage) Exists(ctx context.Context, key string) (bool, error) {
_, err := s.client.StatObject(ctx, s.bucket, key, minio.StatObjectOptions{})
if err != nil {
resp := minio.ToErrorResponse(err)
if resp.Code == "NoSuchKey" {
return false, nil
}
return false, fmt.Errorf("checking object existence: %w", err)
}
return true, nil
}
// Ping checks if the storage backend is reachable by verifying the bucket exists.
func (s *Storage) Ping(ctx context.Context) error {
_, err := s.client.BucketExists(ctx, s.bucket)
return err
}
// Bucket returns the bucket name.
func (s *Storage) Bucket() string {
return s.bucket
}
// PresignPut generates a presigned PUT URL for direct browser upload.
func (s *Storage) PresignPut(ctx context.Context, key string, expiry time.Duration) (*url.URL, error) {
u, err := s.client.PresignedPutObject(ctx, s.bucket, key, expiry)
if err != nil {
return nil, fmt.Errorf("generating presigned put URL: %w", err)
}
return u, nil
}
// Copy copies an object within the same bucket from srcKey to dstKey.
func (s *Storage) Copy(ctx context.Context, srcKey, dstKey string) error {
src := minio.CopySrcOptions{
Bucket: s.bucket,
Object: srcKey,
}
dst := minio.CopyDestOptions{
Bucket: s.bucket,
Object: dstKey,
}
if _, err := s.client.CopyObject(ctx, dst, src); err != nil {
return fmt.Errorf("copying object: %w", err)
}
return nil
}
// FileKey generates a storage key for an item file.
func FileKey(partNumber string, revision int) string {
return fmt.Sprintf("items/%s/rev%d.FCStd", partNumber, revision)
}
// ThumbnailKey generates a storage key for a thumbnail.
func ThumbnailKey(partNumber string, revision int) string {
return fmt.Sprintf("thumbnails/%s/rev%d.png", partNumber, revision)
}

View File

@@ -0,0 +1,156 @@
// Package workflow handles YAML approval workflow definition parsing and validation.
package workflow
import (
"fmt"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
// Workflow represents an approval workflow definition loaded from YAML.
type Workflow struct {
Name string `yaml:"name" json:"name"`
Version int `yaml:"version" json:"version"`
Description string `yaml:"description" json:"description"`
States []string `yaml:"states" json:"states"`
Gates []Gate `yaml:"gates" json:"gates"`
Rules Rules `yaml:"rules" json:"rules"`
}
// Gate defines a required or optional signature role in a workflow.
type Gate struct {
Role string `yaml:"role" json:"role"`
Label string `yaml:"label" json:"label"`
Required bool `yaml:"required" json:"required"`
}
// Rules defines how signatures determine state transitions.
type Rules struct {
AnyReject string `yaml:"any_reject" json:"any_reject"`
AllRequiredApprove string `yaml:"all_required_approve" json:"all_required_approve"`
}
// WorkflowFile wraps a workflow for YAML parsing.
type WorkflowFile struct {
Workflow Workflow `yaml:"workflow"`
}
var requiredStates = []string{"draft", "pending", "approved", "rejected"}
// Load reads a workflow definition from a YAML file.
func Load(path string) (*Workflow, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading workflow file: %w", err)
}
var wf WorkflowFile
if err := yaml.Unmarshal(data, &wf); err != nil {
return nil, fmt.Errorf("parsing workflow YAML: %w", err)
}
w := &wf.Workflow
if w.Version <= 0 {
w.Version = 1
}
if err := w.Validate(); err != nil {
return nil, fmt.Errorf("validating %s: %w", path, err)
}
return w, nil
}
// LoadAll reads all workflow definitions from a directory.
func LoadAll(dir string) (map[string]*Workflow, error) {
workflows := make(map[string]*Workflow)
entries, err := os.ReadDir(dir)
if err != nil {
return nil, fmt.Errorf("reading workflows directory: %w", err)
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
if !strings.HasSuffix(entry.Name(), ".yaml") && !strings.HasSuffix(entry.Name(), ".yml") {
continue
}
path := filepath.Join(dir, entry.Name())
w, err := Load(path)
if err != nil {
return nil, fmt.Errorf("loading %s: %w", entry.Name(), err)
}
workflows[w.Name] = w
}
return workflows, nil
}
// Validate checks that the workflow definition is well-formed.
func (w *Workflow) Validate() error {
if w.Name == "" {
return fmt.Errorf("workflow name is required")
}
// Validate states include all required states
stateSet := make(map[string]bool, len(w.States))
for _, s := range w.States {
stateSet[s] = true
}
for _, rs := range requiredStates {
if !stateSet[rs] {
return fmt.Errorf("workflow must include state %q", rs)
}
}
// Validate gates
if len(w.Gates) == 0 {
return fmt.Errorf("workflow must have at least one gate")
}
for i, g := range w.Gates {
if g.Role == "" {
return fmt.Errorf("gate %d: role is required", i)
}
if g.Label == "" {
return fmt.Errorf("gate %d: label is required", i)
}
}
// Validate rules reference valid states
if w.Rules.AnyReject != "" && !stateSet[w.Rules.AnyReject] {
return fmt.Errorf("rules.any_reject references unknown state %q", w.Rules.AnyReject)
}
if w.Rules.AllRequiredApprove != "" && !stateSet[w.Rules.AllRequiredApprove] {
return fmt.Errorf("rules.all_required_approve references unknown state %q", w.Rules.AllRequiredApprove)
}
return nil
}
// RequiredGates returns only the gates where Required is true.
func (w *Workflow) RequiredGates() []Gate {
var gates []Gate
for _, g := range w.Gates {
if g.Required {
gates = append(gates, g)
}
}
return gates
}
// HasRole returns true if the workflow defines a gate with the given role.
func (w *Workflow) HasRole(role string) bool {
for _, g := range w.Gates {
if g.Role == role {
return true
}
}
return false
}

View File

@@ -0,0 +1,167 @@
package workflow
import (
"os"
"path/filepath"
"testing"
)
func TestLoad_Valid(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test.yaml")
os.WriteFile(path, []byte(`
workflow:
name: test-wf
version: 1
description: "Test workflow"
states: [draft, pending, approved, rejected]
gates:
- role: reviewer
label: "Review"
required: true
rules:
any_reject: rejected
all_required_approve: approved
`), 0644)
w, err := Load(path)
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if w.Name != "test-wf" {
t.Errorf("Name = %q, want %q", w.Name, "test-wf")
}
if w.Version != 1 {
t.Errorf("Version = %d, want 1", w.Version)
}
if len(w.Gates) != 1 {
t.Fatalf("Gates count = %d, want 1", len(w.Gates))
}
if w.Gates[0].Role != "reviewer" {
t.Errorf("Gates[0].Role = %q, want %q", w.Gates[0].Role, "reviewer")
}
}
func TestLoad_MissingState(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "bad.yaml")
os.WriteFile(path, []byte(`
workflow:
name: bad
states: [draft, pending]
gates:
- role: r
label: "R"
required: true
`), 0644)
_, err := Load(path)
if err == nil {
t.Fatal("expected error for missing required states")
}
}
func TestLoad_NoGates(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "no-gates.yaml")
os.WriteFile(path, []byte(`
workflow:
name: no-gates
states: [draft, pending, approved, rejected]
gates: []
`), 0644)
_, err := Load(path)
if err == nil {
t.Fatal("expected error for no gates")
}
}
func TestLoadAll(t *testing.T) {
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "a.yaml"), []byte(`
workflow:
name: alpha
states: [draft, pending, approved, rejected]
gates:
- role: r
label: "R"
required: true
rules:
any_reject: rejected
all_required_approve: approved
`), 0644)
os.WriteFile(filepath.Join(dir, "b.yml"), []byte(`
workflow:
name: beta
states: [draft, pending, approved, rejected]
gates:
- role: r
label: "R"
required: true
rules:
any_reject: rejected
`), 0644)
// Non-yaml file should be ignored
os.WriteFile(filepath.Join(dir, "readme.txt"), []byte("ignore me"), 0644)
wfs, err := LoadAll(dir)
if err != nil {
t.Fatalf("LoadAll() error: %v", err)
}
if len(wfs) != 2 {
t.Fatalf("LoadAll() count = %d, want 2", len(wfs))
}
if wfs["alpha"] == nil {
t.Error("missing workflow 'alpha'")
}
if wfs["beta"] == nil {
t.Error("missing workflow 'beta'")
}
}
func TestRequiredGates(t *testing.T) {
w := &Workflow{
Gates: []Gate{
{Role: "engineer", Label: "Eng", Required: true},
{Role: "quality", Label: "QA", Required: false},
{Role: "manager", Label: "Mgr", Required: true},
},
}
rg := w.RequiredGates()
if len(rg) != 2 {
t.Fatalf("RequiredGates() count = %d, want 2", len(rg))
}
if rg[0].Role != "engineer" || rg[1].Role != "manager" {
t.Errorf("RequiredGates() roles = %v, want [engineer, manager]", rg)
}
}
func TestHasRole(t *testing.T) {
w := &Workflow{
Gates: []Gate{
{Role: "engineer", Label: "Eng", Required: true},
},
}
if !w.HasRole("engineer") {
t.Error("HasRole(engineer) = false, want true")
}
if w.HasRole("manager") {
t.Error("HasRole(manager) = true, want false")
}
}
func TestValidate_InvalidRuleState(t *testing.T) {
w := &Workflow{
Name: "bad-rule",
States: []string{"draft", "pending", "approved", "rejected"},
Gates: []Gate{{Role: "r", Label: "R", Required: true}},
Rules: Rules{AnyReject: "nonexistent"},
}
if err := w.Validate(); err == nil {
t.Fatal("expected error for invalid rule state reference")
}
}

View File

@@ -0,0 +1,2 @@
-- Add workflow_name column to item_approvals for YAML-configurable approval workflows.
ALTER TABLE item_approvals ADD COLUMN workflow_name TEXT NOT NULL DEFAULT 'default';

View File

@@ -0,0 +1,3 @@
-- Change default storage backend from 'minio' to 'filesystem'.
ALTER TABLE item_files ALTER COLUMN storage_backend SET DEFAULT 'filesystem';
ALTER TABLE revisions ALTER COLUMN file_storage_backend SET DEFAULT 'filesystem';

View File

@@ -8,7 +8,6 @@
# - SSH access to the target host # - SSH access to the target host
# - /etc/silo/silod.env must exist on target with credentials filled in # - /etc/silo/silod.env must exist on target with credentials filled in
# - PostgreSQL reachable from target (set SILO_DB_HOST to override) # - PostgreSQL reachable from target (set SILO_DB_HOST to override)
# - MinIO reachable from target (set SILO_MINIO_HOST to override)
# #
# Environment variables: # Environment variables:
# SILO_DEPLOY_TARGET - target host (default: silo.example.internal) # SILO_DEPLOY_TARGET - target host (default: silo.example.internal)

View File

@@ -1,108 +0,0 @@
#!/bin/bash
# Migrate storage from MinIO to filesystem on a remote Silo host.
#
# Builds the migrate-storage binary locally, uploads it to the target host,
# then runs it over SSH using credentials from /etc/silo/silod.env.
#
# Usage: ./scripts/migrate-storage.sh <silo-host> <psql-host> <minio-host> [flags...]
#
# Examples:
# ./scripts/migrate-storage.sh silo.kindred.internal psql.kindred.internal minio.kindred.internal -dry-run -verbose
# ./scripts/migrate-storage.sh silo.kindred.internal psql.kindred.internal minio.kindred.internal
set -euo pipefail
if [ $# -lt 3 ]; then
echo "Usage: $0 <silo-host> <psql-host> <minio-host> [flags...]"
echo " flags are passed to migrate-storage (e.g. -dry-run -verbose)"
exit 1
fi
TARGET="$1"
DB_HOST="$2"
MINIO_HOST="$3"
shift 3
EXTRA_FLAGS="$*"
DEST_DIR="/opt/silo/data"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="${SCRIPT_DIR}/.."
echo "=== Migrate Storage: MinIO -> Filesystem ==="
echo " Target: ${TARGET}"
echo " DB host: ${DB_HOST}"
echo " MinIO: ${MINIO_HOST}"
echo " Dest: ${DEST_DIR}"
[ -n "$EXTRA_FLAGS" ] && echo " Flags: ${EXTRA_FLAGS}"
echo ""
# --- Build locally ---
echo "[1/3] Building migrate-storage binary..."
cd "$PROJECT_DIR"
GOOS=linux GOARCH=amd64 go build -o migrate-storage ./cmd/migrate-storage
echo " Built: $(du -h migrate-storage | cut -f1)"
# --- Upload ---
echo "[2/3] Uploading to ${TARGET}..."
scp migrate-storage "${TARGET}:/tmp/migrate-storage"
rm -f migrate-storage
# --- Run remotely ---
echo "[3/3] Running migration on ${TARGET}..."
ssh "$TARGET" DB_HOST="$DB_HOST" MINIO_HOST="$MINIO_HOST" DEST_DIR="$DEST_DIR" EXTRA_FLAGS="$EXTRA_FLAGS" bash -s <<'REMOTE'
set -euo pipefail
CONFIG_DIR="/etc/silo"
# Source credentials
if [ ! -f "$CONFIG_DIR/silod.env" ]; then
echo "ERROR: $CONFIG_DIR/silod.env not found on $(hostname)"
exit 1
fi
set -a
source "$CONFIG_DIR/silod.env"
set +a
# Ensure destination directory exists
sudo mkdir -p "$DEST_DIR"
sudo chown silo:silo "$DEST_DIR" 2>/dev/null || true
chmod +x /tmp/migrate-storage
# Write temporary config with the provided hosts
cat > /tmp/silo-migrate.yaml <<EOF
database:
host: "${DB_HOST}"
port: 5432
name: "silo"
user: "silo"
password: "${SILO_DB_PASSWORD}"
sslmode: "require"
max_connections: 5
storage:
endpoint: "${MINIO_HOST}:9000"
access_key: "${SILO_MINIO_ACCESS_KEY}"
secret_key: "${SILO_MINIO_SECRET_KEY}"
bucket: "silo"
use_ssl: false
region: "us-east-1"
EOF
chmod 600 /tmp/silo-migrate.yaml
echo " Config written to /tmp/silo-migrate.yaml"
echo " Starting migration..."
echo ""
# Run the migration
/tmp/migrate-storage -config /tmp/silo-migrate.yaml -dest "$DEST_DIR" $EXTRA_FLAGS
# Clean up
rm -f /tmp/silo-migrate.yaml /tmp/migrate-storage
echo ""
echo " Cleaned up temp files."
REMOTE
echo ""
echo "=== Migration complete ==="
echo " Files written to ${TARGET}:${DEST_DIR}"

View File

@@ -138,12 +138,6 @@ fi
PG_PASSWORD_DEFAULT="$(generate_secret 16)" PG_PASSWORD_DEFAULT="$(generate_secret 16)"
prompt_secret POSTGRES_PASSWORD "PostgreSQL password" "$PG_PASSWORD_DEFAULT" prompt_secret POSTGRES_PASSWORD "PostgreSQL password" "$PG_PASSWORD_DEFAULT"
# MinIO
MINIO_AK_DEFAULT="$(generate_secret 10)"
MINIO_SK_DEFAULT="$(generate_secret 16)"
prompt_secret MINIO_ACCESS_KEY "MinIO access key" "$MINIO_AK_DEFAULT"
prompt_secret MINIO_SECRET_KEY "MinIO secret key" "$MINIO_SK_DEFAULT"
# OpenLDAP # OpenLDAP
LDAP_ADMIN_PW_DEFAULT="$(generate_secret 16)" LDAP_ADMIN_PW_DEFAULT="$(generate_secret 16)"
prompt_secret LDAP_ADMIN_PASSWORD "LDAP admin password" "$LDAP_ADMIN_PW_DEFAULT" prompt_secret LDAP_ADMIN_PASSWORD "LDAP admin password" "$LDAP_ADMIN_PW_DEFAULT"
@@ -173,10 +167,6 @@ cat > "${OUTPUT_DIR}/.env" << EOF
# PostgreSQL # PostgreSQL
POSTGRES_PASSWORD=${POSTGRES_PASSWORD} POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
# MinIO
MINIO_ACCESS_KEY=${MINIO_ACCESS_KEY}
MINIO_SECRET_KEY=${MINIO_SECRET_KEY}
# OpenLDAP # OpenLDAP
LDAP_ADMIN_PASSWORD=${LDAP_ADMIN_PASSWORD} LDAP_ADMIN_PASSWORD=${LDAP_ADMIN_PASSWORD}
LDAP_USERS=${LDAP_USERS} LDAP_USERS=${LDAP_USERS}
@@ -235,12 +225,9 @@ database:
max_connections: 10 max_connections: 10
storage: storage:
endpoint: "minio:9000" backend: "filesystem"
access_key: "${SILO_MINIO_ACCESS_KEY}" filesystem:
secret_key: "${SILO_MINIO_SECRET_KEY}" root_dir: "/var/lib/silo/data"
bucket: "silo-files"
use_ssl: false
region: "us-east-1"
schemas: schemas:
directory: "/etc/silo/schemas" directory: "/etc/silo/schemas"
@@ -306,8 +293,6 @@ echo " deployments/config.docker.yaml - server configuration"
echo "" echo ""
echo -e "${BOLD}Credentials:${NC}" echo -e "${BOLD}Credentials:${NC}"
echo " PostgreSQL: silo / ${POSTGRES_PASSWORD}" echo " PostgreSQL: silo / ${POSTGRES_PASSWORD}"
echo " MinIO: ${MINIO_ACCESS_KEY} / ${MINIO_SECRET_KEY}"
echo " MinIO Console: http://localhost:9001"
echo " LDAP Admin: cn=admin,dc=silo,dc=local / ${LDAP_ADMIN_PASSWORD}" echo " LDAP Admin: cn=admin,dc=silo,dc=local / ${LDAP_ADMIN_PASSWORD}"
echo " LDAP User: ${LDAP_USERS} / ${LDAP_PASSWORDS}" echo " LDAP User: ${LDAP_USERS} / ${LDAP_PASSWORDS}"
echo " Silo Admin: ${SILO_ADMIN_USERNAME} / ${SILO_ADMIN_PASSWORD} (local fallback)" echo " Silo Admin: ${SILO_ADMIN_USERNAME} / ${SILO_ADMIN_PASSWORD} (local fallback)"

View File

@@ -30,7 +30,6 @@ INSTALL_DIR="/opt/silo"
CONFIG_DIR="/etc/silo" CONFIG_DIR="/etc/silo"
GO_VERSION="1.24.0" GO_VERSION="1.24.0"
DB_HOST="${SILO_DB_HOST:-psql.example.internal}" DB_HOST="${SILO_DB_HOST:-psql.example.internal}"
MINIO_HOST="${SILO_MINIO_HOST:-minio.example.internal}"
log_info() { echo -e "${BLUE}[INFO]${NC} $*"; } log_info() { echo -e "${BLUE}[INFO]${NC} $*"; }
log_success() { echo -e "${GREEN}[OK]${NC} $*"; } log_success() { echo -e "${GREEN}[OK]${NC} $*"; }
@@ -165,11 +164,6 @@ if [[ ! -f "${ENV_FILE}" ]]; then
# Database: silo, User: silo # Database: silo, User: silo
SILO_DB_PASSWORD= SILO_DB_PASSWORD=
# MinIO credentials (${MINIO_HOST})
# User: silouser
SILO_MINIO_ACCESS_KEY=silouser
SILO_MINIO_SECRET_KEY=
# Authentication # Authentication
# Session secret (required when auth is enabled) # Session secret (required when auth is enabled)
SILO_SESSION_SECRET= SILO_SESSION_SECRET=
@@ -225,10 +219,7 @@ echo ""
echo "2. Verify database connectivity:" echo "2. Verify database connectivity:"
echo " psql -h ${DB_HOST} -U silo -d silo -c 'SELECT 1'" echo " psql -h ${DB_HOST} -U silo -d silo -c 'SELECT 1'"
echo "" echo ""
echo "3. Verify MinIO connectivity:" echo "3. Run the deployment:"
echo " curl -I http://${MINIO_HOST}:9000/minio/health/live"
echo ""
echo "4. Run the deployment:"
echo " sudo ${INSTALL_DIR}/src/scripts/deploy.sh" echo " sudo ${INSTALL_DIR}/src/scripts/deploy.sh"
echo "" echo ""
echo "After deployment, manage the service with:" echo "After deployment, manage the service with:"

View File

@@ -0,0 +1,25 @@
workflow:
name: engineering-change
version: 1
description: "Standard engineering change order with peer review and manager approval"
states:
- draft
- pending
- approved
- rejected
gates:
- role: engineer
label: "Peer Review"
required: true
- role: manager
label: "Manager Approval"
required: true
- role: quality
label: "Quality Sign-off"
required: false
rules:
any_reject: rejected
all_required_approve: approved

View File

@@ -0,0 +1,19 @@
workflow:
name: quick-review
version: 1
description: "Single reviewer approval for minor changes"
states:
- draft
- pending
- approved
- rejected
gates:
- role: reviewer
label: "Review"
required: true
rules:
any_reject: rejected
all_required_approve: approved