feat: production release with React SPA, file attachments, and deploy tooling

Backend:
- Add file_handlers.go: presigned upload/download for item attachments
- Add item_files.go: item file and thumbnail DB operations
- Add migration 011: item_files table and thumbnail_key column
- Update items/projects/relationships DB with extended field support
- Update routes: React SPA serving from web/dist, file upload endpoints
- Update auth handlers and middleware for cookie + bearer token auth
- Remove Go HTML templates (replaced by React SPA)
- Update storage client for presigned URL generation

Frontend:
- Add TagInput component for tag/keyword entry
- Add SVG assets for Silo branding and UI icons
- Update API client and types for file uploads, auth, extended fields
- Update AuthContext for session-based auth flow
- Update LoginPage, ProjectsPage, SchemasPage, SettingsPage
- Fix tsconfig.node.json

Deployment:
- Update config.prod.yaml: single-binary SPA layout at /opt/silo
- Update silod.service: ReadOnlyPaths for /opt/silo
- Add scripts/deploy.sh: build, package, ship, migrate, start
- Update docker-compose.yaml and Dockerfile
- Add frontend-spec.md design document
This commit is contained in:
Forbes
2026-02-07 13:35:22 -06:00
parent d61f939d84
commit 50923cf56d
49 changed files with 4674 additions and 7915 deletions

View File

@@ -1,458 +1,154 @@
#!/usr/bin/env bash
#!/bin/bash
# Deploy Silo to silo.kindred.internal
#
# Silo Deployment Script
# Pulls from git and deploys silod on the local machine
# Usage: ./scripts/deploy.sh [host]
# host defaults to silo.kindred.internal
#
# Usage:
# sudo ./scripts/deploy.sh [options]
#
# Options:
# --no-pull Skip git pull (use current checkout)
# --no-build Skip build (use existing binary)
# --restart-only Only restart the service
# --status Show service status and exit
# --help Show this help message
#
# This script should be run on silo.kindred.internal as root or with sudo.
# Prerequisites:
# - SSH access to the target host
# - /etc/silo/silod.env must exist on target with credentials filled in
# - PostgreSQL reachable from target at psql.kindred.internal
# - MinIO reachable from target at minio.kindred.internal
set -euo pipefail
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Configuration
REPO_URL="${SILO_REPO_URL:-https://gitea.kindred.internal/kindred/silo-0062.git}"
REPO_BRANCH="${SILO_BRANCH:-main}"
INSTALL_DIR="/opt/silo"
TARGET="${1:-silo.kindred.internal}"
DEPLOY_DIR="/opt/silo"
CONFIG_DIR="/etc/silo"
BINARY_NAME="silod"
SERVICE_NAME="silod"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="${SCRIPT_DIR}/.."
# Flags
DO_PULL=true
DO_BUILD=true
RESTART_ONLY=false
STATUS_ONLY=false
echo "=== Silo Deploy to ${TARGET} ==="
# Functions
log_info() {
echo -e "${BLUE}[INFO]${NC} $*"
}
# --- Build locally ---
echo "[1/6] Building Go binary..."
cd "$PROJECT_DIR"
GOOS=linux GOARCH=amd64 go build -o silod ./cmd/silod
log_success() {
echo -e "${GREEN}[OK]${NC} $*"
}
echo "[2/6] Building React frontend..."
cd "$PROJECT_DIR/web"
npm run build
log_warn() {
echo -e "${YELLOW}[WARN]${NC} $*"
}
# --- Package ---
echo "[3/6] Packaging..."
STAGING=$(mktemp -d)
trap "rm -rf $STAGING" EXIT
log_error() {
echo -e "${RED}[ERROR]${NC} $*" >&2
}
mkdir -p "$STAGING/bin"
mkdir -p "$STAGING/web"
mkdir -p "$STAGING/schemas"
mkdir -p "$STAGING/migrations"
die() {
log_error "$*"
cp "$PROJECT_DIR/silod" "$STAGING/bin/silod"
cp -r "$PROJECT_DIR/web/dist" "$STAGING/web/dist"
cp "$PROJECT_DIR/schemas/"*.yaml "$STAGING/schemas/"
cp "$PROJECT_DIR/migrations/"*.sql "$STAGING/migrations/"
cp "$PROJECT_DIR/deployments/config.prod.yaml" "$STAGING/config.yaml"
cp "$PROJECT_DIR/deployments/systemd/silod.service" "$STAGING/silod.service"
cp "$PROJECT_DIR/deployments/systemd/silod.env.example" "$STAGING/silod.env.example"
TARBALL=$(mktemp --suffix=.tar.gz)
tar -czf "$TARBALL" -C "$STAGING" .
echo " Package: $(du -h "$TARBALL" | cut -f1)"
# --- Deploy ---
echo "[4/6] Uploading to ${TARGET}..."
scp "$TARBALL" "${TARGET}:/tmp/silo-deploy.tar.gz"
echo "[5/6] Installing on ${TARGET}..."
ssh "$TARGET" bash -s <<'REMOTE'
set -euo pipefail
DEPLOY_DIR="/opt/silo"
CONFIG_DIR="/etc/silo"
# Create directories
sudo mkdir -p "$DEPLOY_DIR/bin" "$DEPLOY_DIR/web" "$DEPLOY_DIR/schemas" "$DEPLOY_DIR/migrations"
sudo mkdir -p "$CONFIG_DIR"
# Stop service if running
if systemctl is-active --quiet silod 2>/dev/null; then
echo " Stopping silod..."
sudo systemctl stop silod
fi
# Extract
echo " Extracting..."
sudo tar -xzf /tmp/silo-deploy.tar.gz -C "$DEPLOY_DIR"
sudo chmod +x "$DEPLOY_DIR/bin/silod"
rm -f /tmp/silo-deploy.tar.gz
# Install config if not present (don't overwrite existing)
if [ ! -f "$CONFIG_DIR/config.yaml" ]; then
echo " Installing default config..."
sudo cp "$DEPLOY_DIR/config.yaml" "$CONFIG_DIR/config.yaml" 2>/dev/null || true
fi
# Install env template if not present
if [ ! -f "$CONFIG_DIR/silod.env" ]; then
echo " WARNING: /etc/silo/silod.env does not exist!"
echo " Copying template..."
sudo cp "$DEPLOY_DIR/silod.env.example" "$CONFIG_DIR/silod.env"
echo " Edit /etc/silo/silod.env with your credentials before starting the service."
fi
# Install systemd service
echo " Installing systemd service..."
sudo cp "$DEPLOY_DIR/silod.service" /etc/systemd/system/silod.service
# Set ownership
sudo chown -R silo:silo "$DEPLOY_DIR" 2>/dev/null || true
sudo chmod 600 "$CONFIG_DIR/silod.env" 2>/dev/null || true
echo " Files installed to $DEPLOY_DIR"
REMOTE
echo "[6/6] Running migrations and starting service..."
ssh "$TARGET" bash -s <<'REMOTE'
set -euo pipefail
DEPLOY_DIR="/opt/silo"
CONFIG_DIR="/etc/silo"
# Source env for migration
if [ -f "$CONFIG_DIR/silod.env" ]; then
set -a
source "$CONFIG_DIR/silod.env"
set +a
fi
# Run migrations
if command -v psql &>/dev/null && [ -n "${SILO_DB_PASSWORD:-}" ]; then
echo " Running migrations..."
for f in "$DEPLOY_DIR/migrations/"*.sql; do
echo " $(basename "$f")"
PGPASSWORD="$SILO_DB_PASSWORD" psql \
-h psql.kindred.internal -p 5432 \
-U silo -d silo \
-f "$f" -q 2>&1 | grep -v "already exists" || true
done
echo " Migrations complete."
else
echo " WARNING: psql not available or SILO_DB_PASSWORD not set, skipping migrations."
echo " Run migrations manually: PGPASSWORD=... psql -h psql.kindred.internal -U silo -d silo -f /opt/silo/migrations/NNN_name.sql"
fi
# Start service
echo " Starting silod..."
sudo systemctl daemon-reload
sudo systemctl enable silod
sudo systemctl start silod
sleep 2
if systemctl is-active --quiet silod; then
echo " silod is running!"
else
echo " ERROR: silod failed to start. Check: journalctl -u silod -n 50"
exit 1
}
show_help() {
head -18 "$0" | grep -E '^#' | sed 's/^# *//'
exit 0
}
check_root() {
if [[ $EUID -ne 0 ]]; then
die "This script must be run as root (use sudo)"
fi
}
check_dependencies() {
log_info "Checking dependencies..."
# Add common Go install locations to PATH
if [[ -d /usr/local/go/bin ]]; then
export PATH=$PATH:/usr/local/go/bin
fi
if [[ -d /opt/go/bin ]]; then
export PATH=$PATH:/opt/go/bin
fi
local missing=()
command -v git >/dev/null 2>&1 || missing+=("git")
command -v go >/dev/null 2>&1 || missing+=("go (golang)")
command -v systemctl >/dev/null 2>&1 || missing+=("systemctl")
if [[ ${#missing[@]} -gt 0 ]]; then
die "Missing required commands: ${missing[*]}"
fi
# Check Go version
local go_version
go_version=$(go version | grep -oP 'go\d+\.\d+' | head -1)
log_info "Found Go version: ${go_version}"
log_success "All dependencies available"
}
setup_directories() {
log_info "Setting up directories..."
# Create directories if they don't exist
mkdir -p "${INSTALL_DIR}/bin"
mkdir -p "${INSTALL_DIR}/src"
mkdir -p "${CONFIG_DIR}/schemas"
mkdir -p /var/log/silo
# Create silo user if it doesn't exist
if ! id -u silo >/dev/null 2>&1; then
useradd -r -m -d "${INSTALL_DIR}" -s /sbin/nologin -c "Silo Service" silo
log_info "Created silo user"
fi
log_success "Directories ready"
}
git_pull() {
log_info "Pulling latest code from ${REPO_BRANCH}..."
local src_dir="${INSTALL_DIR}/src"
if [[ -d "${src_dir}/.git" ]]; then
# Existing checkout - pull updates
cd "${src_dir}"
git fetch origin
git checkout "${REPO_BRANCH}"
git reset --hard "origin/${REPO_BRANCH}"
log_success "Updated to $(git rev-parse --short HEAD)"
else
# Fresh clone
log_info "Cloning repository..."
rm -rf "${src_dir}"
git clone --branch "${REPO_BRANCH}" "${REPO_URL}" "${src_dir}"
cd "${src_dir}"
log_success "Cloned $(git rev-parse --short HEAD)"
fi
# Show version info
local version
version=$(git describe --tags --always --dirty 2>/dev/null || git rev-parse --short HEAD)
log_info "Version: ${version}"
}
build_binary() {
log_info "Building ${BINARY_NAME}..."
local src_dir="${INSTALL_DIR}/src"
cd "${src_dir}"
# Get version from git
local version
version=$(git describe --tags --always --dirty 2>/dev/null || git rev-parse --short HEAD)
local ldflags="-w -s -X main.Version=${version}"
# Build
CGO_ENABLED=0 go build -ldflags="${ldflags}" -o "${INSTALL_DIR}/bin/${BINARY_NAME}" ./cmd/silod
if [[ ! -f "${INSTALL_DIR}/bin/${BINARY_NAME}" ]]; then
die "Build failed: binary not found"
fi
chmod 755 "${INSTALL_DIR}/bin/${BINARY_NAME}"
local size
size=$(du -h "${INSTALL_DIR}/bin/${BINARY_NAME}" | cut -f1)
log_success "Built ${BINARY_NAME} (${size})"
}
install_config() {
log_info "Installing configuration..."
local src_dir="${INSTALL_DIR}/src"
# Install config file if it doesn't exist or is different
if [[ ! -f "${CONFIG_DIR}/config.yaml" ]]; then
cp "${src_dir}/deployments/config.prod.yaml" "${CONFIG_DIR}/config.yaml"
chmod 644 "${CONFIG_DIR}/config.yaml"
chown root:silo "${CONFIG_DIR}/config.yaml"
log_info "Installed config.yaml"
else
log_info "Config file exists, not overwriting"
fi
# Install schemas (always update)
rm -rf "${CONFIG_DIR}/schemas/"*
cp -r "${src_dir}/schemas/"* "${CONFIG_DIR}/schemas/"
chmod -R 644 "${CONFIG_DIR}/schemas/"*
chown -R root:silo "${CONFIG_DIR}/schemas"
log_success "Schemas installed"
# Check environment file
if [[ ! -f "${CONFIG_DIR}/silod.env" ]]; then
cp "${src_dir}/deployments/systemd/silod.env.example" "${CONFIG_DIR}/silod.env"
chmod 600 "${CONFIG_DIR}/silod.env"
chown root:silo "${CONFIG_DIR}/silod.env"
log_warn "Created ${CONFIG_DIR}/silod.env - EDIT THIS FILE WITH CREDENTIALS!"
fi
}
install_systemd() {
log_info "Installing systemd service..."
local src_dir="${INSTALL_DIR}/src"
local service_file="${src_dir}/deployments/systemd/silod.service"
if [[ -f "${service_file}" ]]; then
cp "${service_file}" /etc/systemd/system/silod.service
chmod 644 /etc/systemd/system/silod.service
systemctl daemon-reload
log_success "Systemd service installed"
else
die "Service file not found: ${service_file}"
fi
}
set_permissions() {
log_info "Setting permissions..."
chown -R silo:silo "${INSTALL_DIR}"
chown root:silo "${CONFIG_DIR}"
chmod 750 "${CONFIG_DIR}"
chown silo:silo /var/log/silo
chmod 750 /var/log/silo
# Binary should be owned by root but executable by silo
chown root:root "${INSTALL_DIR}/bin/${BINARY_NAME}"
chmod 755 "${INSTALL_DIR}/bin/${BINARY_NAME}"
log_success "Permissions set"
}
restart_service() {
log_info "Restarting ${SERVICE_NAME} service..."
# Enable if not already
systemctl enable "${SERVICE_NAME}" >/dev/null 2>&1 || true
# Restart
systemctl restart "${SERVICE_NAME}"
# Wait for startup
sleep 2
if systemctl is-active --quiet "${SERVICE_NAME}"; then
log_success "Service started successfully"
else
log_error "Service failed to start"
journalctl -u "${SERVICE_NAME}" -n 20 --no-pager || true
die "Deployment failed: service not running"
fi
}
run_migrations() {
log_info "Running database migrations..."
local src_dir="${INSTALL_DIR}/src"
local migrations_dir="${src_dir}/migrations"
local env_file="${CONFIG_DIR}/silod.env"
if [[ ! -d "${migrations_dir}" ]]; then
die "Migrations directory not found: ${migrations_dir}"
fi
# Source env file for DB password
if [[ -f "${env_file}" ]]; then
# shellcheck disable=SC1090
source "${env_file}"
fi
# Read DB config from production config
local db_host db_port db_name db_user db_password
db_host=$(grep -A10 '^database:' "${CONFIG_DIR}/config.yaml" | grep 'host:' | head -1 | awk '{print $2}' | tr -d '"')
db_port=$(grep -A10 '^database:' "${CONFIG_DIR}/config.yaml" | grep 'port:' | head -1 | awk '{print $2}')
db_name=$(grep -A10 '^database:' "${CONFIG_DIR}/config.yaml" | grep 'name:' | head -1 | awk '{print $2}' | tr -d '"')
db_user=$(grep -A10 '^database:' "${CONFIG_DIR}/config.yaml" | grep 'user:' | head -1 | awk '{print $2}' | tr -d '"')
db_password="${SILO_DB_PASSWORD:-}"
db_host="${db_host:-localhost}"
db_port="${db_port:-5432}"
db_name="${db_name:-silo}"
db_user="${db_user:-silo}"
if [[ -z "${db_password}" ]]; then
log_warn "SILO_DB_PASSWORD not set — skipping migrations"
log_warn "Run migrations manually: psql -h ${db_host} -U ${db_user} -d ${db_name} -f migrations/009_auth.sql"
return 0
fi
# Check psql is available
if ! command -v psql >/dev/null 2>&1; then
log_warn "psql not found — skipping automatic migrations"
log_warn "Run migrations manually: psql -h ${db_host} -U ${db_user} -d ${db_name} -f migrations/009_auth.sql"
return 0
fi
# Wait for database to be reachable
local retries=0
while ! PGPASSWORD="${db_password}" psql -h "${db_host}" -p "${db_port}" -U "${db_user}" -d "${db_name}" -c '\q' 2>/dev/null; do
retries=$((retries + 1))
if [[ ${retries} -ge 5 ]]; then
log_warn "Could not connect to database after 5 attempts — skipping migrations"
return 0
fi
log_info "Waiting for database... (attempt ${retries}/5)"
sleep 2
done
# Apply each migration, skipping ones that have already been applied
local applied=0
local skipped=0
for migration in "${migrations_dir}"/*.sql; do
if [[ ! -f "${migration}" ]]; then
continue
fi
local name
name=$(basename "${migration}")
if PGPASSWORD="${db_password}" psql -h "${db_host}" -p "${db_port}" \
-U "${db_user}" -d "${db_name}" -f "${migration}" 2>/dev/null; then
log_info "Applied: ${name}"
applied=$((applied + 1))
else
skipped=$((skipped + 1))
fi
done
log_success "Migrations complete (${applied} applied, ${skipped} already present)"
}
verify_deployment() {
log_info "Verifying deployment..."
# Wait a moment for service to fully start
sleep 2
# Check health endpoint
local health_status
health_status=$(curl -sf http://localhost:8080/health 2>/dev/null || echo "FAILED")
if [[ "${health_status}" == *"ok"* ]] || [[ "${health_status}" == *"healthy"* ]] || [[ "${health_status}" == "{}" ]]; then
log_success "Health check passed"
else
log_warn "Health check returned: ${health_status}"
fi
# Check ready endpoint (includes DB and MinIO)
local ready_status
ready_status=$(curl -sf http://localhost:8080/ready 2>/dev/null || echo "FAILED")
if [[ "${ready_status}" == *"ok"* ]] || [[ "${ready_status}" == *"ready"* ]] || [[ "${ready_status}" == "{}" ]]; then
log_success "Readiness check passed (DB and MinIO connected)"
else
log_warn "Readiness check returned: ${ready_status}"
log_warn "Check credentials in ${CONFIG_DIR}/silod.env"
fi
# Show version
log_info "Deployed version: $("${INSTALL_DIR}/bin/${BINARY_NAME}" --version 2>/dev/null || echo 'unknown')"
}
show_status() {
echo ""
log_info "Service Status"
echo "============================================"
systemctl status "${SERVICE_NAME}" --no-pager -l || true
echo ""
echo "Recent logs:"
journalctl -u "${SERVICE_NAME}" -n 10 --no-pager || true
}
# Parse arguments
while [[ $# -gt 0 ]]; do
case $1 in
--no-pull)
DO_PULL=false
shift
;;
--no-build)
DO_BUILD=false
shift
;;
--restart-only)
RESTART_ONLY=true
shift
;;
--status)
STATUS_ONLY=true
shift
;;
--help|-h)
show_help
;;
*)
die "Unknown option: $1"
;;
esac
done
# Main execution
main() {
echo ""
log_info "Silo Deployment Script"
log_info "======================"
echo ""
check_root
if [[ "${STATUS_ONLY}" == "true" ]]; then
show_status
exit 0
fi
if [[ "${RESTART_ONLY}" == "true" ]]; then
restart_service
verify_deployment
exit 0
fi
check_dependencies
setup_directories
if [[ "${DO_PULL}" == "true" ]]; then
git_pull
else
log_info "Skipping git pull (--no-pull)"
cd "${INSTALL_DIR}/src"
fi
if [[ "${DO_BUILD}" == "true" ]]; then
build_binary
else
log_info "Skipping build (--no-build)"
fi
install_config
run_migrations
install_systemd
set_permissions
restart_service
verify_deployment
echo ""
log_success "============================================"
log_success "Deployment complete!"
log_success "============================================"
echo ""
echo "Useful commands:"
echo " sudo systemctl status silod # Check service status"
echo " sudo journalctl -u silod -f # Follow logs"
echo " curl http://localhost:8080/health # Health check"
echo ""
}
main "$@"
fi
REMOTE
echo ""
echo "=== Deploy complete ==="
echo " Backend: https://${TARGET} (port 8080)"
echo " React SPA served from /opt/silo/web/dist/"
echo " Logs: ssh ${TARGET} journalctl -u silod -f"