From e0cd3cfe746293887ff8854e07d59715e45e73cc Mon Sep 17 00:00:00 2001 From: lemony-cricket Date: Wed, 10 Dec 2025 21:36:58 -0500 Subject: [PATCH 1/2] add ai-delegate server --- caddy/Caddyfile | 5 ++ compose.override.yml | 36 ++++++++ compose.yml | 33 +++----- compose.yml.originalpullfromremote | 129 ----------------------------- 4 files changed, 52 insertions(+), 151 deletions(-) create mode 100644 compose.override.yml delete mode 100644 compose.yml.originalpullfromremote diff --git a/caddy/Caddyfile b/caddy/Caddyfile index 06273bb..a350056 100644 --- a/caddy/Caddyfile +++ b/caddy/Caddyfile @@ -25,6 +25,11 @@ reverse_proxy http://data-generator:8001 } + # Route /ai-delegate to ai-delegate service + handle_path /ai-delegate/* { + reverse_proxy http://ai-delegate:3000 + } + # Route /generator to generator service with websocket support redir /generator /generator/ handle /generator/* { diff --git a/compose.override.yml b/compose.override.yml new file mode 100644 index 0000000..6486890 --- /dev/null +++ b/compose.override.yml @@ -0,0 +1,36 @@ +services: + db: + image: registry.gitlab.syncad.com/peerverity/ratings/db:local + build: + context: ../ratings + dockerfile: docker/db/Dockerfile + flyway: + image: registry.gitlab.syncad.com/peerverity/ratings/flyway:local + build: + context: ../ratings + dockerfile: docker/flyway/Dockerfile + ui: + image: registry.gitlab.syncad.com/peerverity/ratings/ui:local + build: + context: ../ratings + dockerfile: docker/ui/Dockerfile + api: + image: registry.gitlab.syncad.com/peerverity/ratings/api:local + build: + context: ../ratings + dockerfile: docker/api/Dockerfile + caddy: + image: registry.gitlab.syncad.com/peerverity/ratings-stack/caddy:local + build: + context: ./caddy + dockerfile: Dockerfile + generator: + image: registry.gitlab.syncad.com/peerverity/generator/generator:local + build: + context: ../data-generator + dockerfile: docker/data-generator/Dockerfile + ai-delegate: + image: registry.gitlab.syncad.com/peerverity/ai-delegate/ai-delegate:local + build: + context: ../ai-delegate + dockerfile: docker/ai-delegate/Dockerfile diff --git a/compose.yml b/compose.yml index aef9909..c84e147 100644 --- a/compose.yml +++ b/compose.yml @@ -2,10 +2,7 @@ name: ${PROJECT_NAME:-ratings} services: db: - build: - context: ../ratings - dockerfile: docker/db/Dockerfile - image: ratings/db:local + image: registry.gitlab.syncad.com/peerverity/ratings/db:${RATINGS_TAG:-${TAG:-latest}} shm_size: 128mb environment: POSTGRES_DB: ratings @@ -24,20 +21,14 @@ services: healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] flyway: - build: - context: ../ratings - dockerfile: docker/flyway/Dockerfile - image: ratings/flyway:local + image: registry.gitlab.syncad.com/peerverity/ratings/flyway:${RATINGS_TAG:-${TAG:-latest}} depends_on: db: condition: service_healthy entrypoint: /entrypoint.sh command: ${FLYWAY_COMMAND} ui: - build: - context: ../ratings - dockerfile: docker/ui/Dockerfile - image: ratings/ui:local + image: registry.gitlab.syncad.com/peerverity/ratings/ui:${RATINGS_TAG:-${TAG:-latest}} postgrest: image: postgrest/postgrest environment: @@ -50,10 +41,7 @@ services: condition: service_completed_successfully restart: true api: - build: - context: ../ratings - dockerfile: docker/api/Dockerfile - image: ratings/api:local + image: registry.gitlab.syncad.com/peerverity/ratings/api:${RATINGS_TAG:-${TAG:-latest}} environment: DATABASE_URL: postgresql://postgres@db:5432/ratings ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY} @@ -83,10 +71,12 @@ services: - source: pgadmin_passwords target: /pgadmin4/pgpass caddy: + image: registry.gitlab.syncad.com/peerverity/ratings-stack/caddy:${RATINGS_STACK_TAG:-${TAG:-latest}} build: context: ./caddy dockerfile: Dockerfile - image: ratings-stack/caddy:local + cache_from: + - registry.gitlab.syncad.com/peerverity/ratings-stack/caddy:latest cap_add: - NET_ADMIN ports: @@ -106,11 +96,8 @@ services: - type: volume source: caddy_config target: /config - data-generator: - build: - context: ../data-generator - dockerfile: docker/data-generator/Dockerfile - image: data-generator/data-generator:local + generator: + image: registry.gitlab.syncad.com/peerverity/generator/generator:${GENERATOR_TAG:-${TAG:-latest}} environment: PV_DATABASE_URL: postgresql://generator@db/ratings UID: ${UID:-0} @@ -123,6 +110,8 @@ services: volumes: - ${GENERATOR_DATA_ROOT:-./generator/data}:/app/generator/data - ${GENERATOR_PVAIU_DATA_ROOT:-./generator/pvaiu}:/app/generator/pvaiu + ai-delegate: + image: registry.gitlab.syncad.com/peerverity/ai-delegate/ai-delegate:${AI_DELEGATE_TAG:-${TAG:-latest}} volumes: caddy_data: caddy_config: diff --git a/compose.yml.originalpullfromremote b/compose.yml.originalpullfromremote deleted file mode 100644 index 2dfe31b..0000000 --- a/compose.yml.originalpullfromremote +++ /dev/null @@ -1,129 +0,0 @@ -name: ${PROJECT_NAME:-ratings} - -services: - db: - image: registry.gitlab.syncad.com/peerverity/ratings/db:${RATINGS_TAG:-${TAG:-latest}} - shm_size: 128mb - environment: - POSTGRES_DB: ratings - # trust auth is immediately disabled during database init - POSTGRES_HOST_AUTH_METHOD: trust - PGUSER: postgres - # default to running as root, since the default db/pgdata directory we reference below does - # not exist yet. Running as root ensures the container can create the pgdata directory on - # first startup. - # - # a better way to run will be to manually create the db/pgdata directory, then override - # UID and GID in the .env file to match your login user - user: "${UID:-0}:${GID:-0}" - volumes: - - ${DATABASE_ROOT:-./db/pgdata}:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres"] - flyway: - image: registry.gitlab.syncad.com/peerverity/ratings/flyway:${RATINGS_TAG:-${TAG:-latest}} - depends_on: - db: - condition: service_healthy - entrypoint: /entrypoint.sh - command: ${FLYWAY_COMMAND} - ui: - image: registry.gitlab.syncad.com/peerverity/ratings/ui:${RATINGS_TAG:-${TAG:-latest}} - postgrest: - image: postgrest/postgrest - environment: - PGRST_ADMIN_SERVER_PORT: 3001 - PGRST_DB_URI: postgresql://web_anon@db/ratings?application_name=postgrest - PGRST_DB_SCHEMA: api_v1 - PGRST_DB_ANON_ROLE: web_anon - depends_on: - flyway: - condition: service_completed_successfully - restart: true - api: - image: registry.gitlab.syncad.com/peerverity/ratings/api:${RATINGS_TAG:-${TAG:-latest}} - environment: - DATABASE_URL: postgresql://postgres@db:5432/ratings - ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY} - depends_on: - flyway: - condition: service_completed_successfully - restart: true - pgadmin: - image: dpage/pgadmin4:latest - environment: - PGADMIN_DEFAULT_EMAIL: "admin@peerverity.info" - PGADMIN_DEFAULT_PASSWORD: "admin" - healthcheck: - test: ["CMD-SHELL", "wget --timeout=2 -nv -t1 --spider 127.0.0.1/misc/ping || exit 1"] - interval: 10s - timeout: 3s - retries: 10 - start_period: 1m - init: true - volumes: - - type: volume - source: pgadmin_data - target: /var/lib/pgadmin - configs: - - source: pgadmin_servers - target: /pgadmin4/servers.json - - source: pgadmin_passwords - target: /pgadmin4/pgpass - caddy: - image: registry.gitlab.syncad.com/peerverity/ratings-stack/caddy:${RATINGS_STACK_TAG:-${TAG:-latest}} - build: - context: ./caddy - dockerfile: Dockerfile - cache_from: - - registry.gitlab.syncad.com/peerverity/ratings-stack/caddy:latest - cap_add: - - NET_ADMIN - ports: - - "${HTTP_PORT:-80}:80" - - "${HTTPS_PORT:-443}:443" - environment: - CADDY_SITES: ${CADDY_SITES:-http://} - ADMIN_ENDPOINT_PROTOCOL: ${ADMIN_ENDPOINT_PROTOCOL:-https} - volumes: - - type: bind - source: ./caddy/snippets - target: /etc/caddy/snippets - read_only: true - - type: volume - source: caddy_data - target: /data - - type: volume - source: caddy_config - target: /config - generator: - image: registry.gitlab.syncad.com/peerverity/generator/generator:${GENERATOR_TAG:-${TAG:-latest}} - profiles: - - disabled - environment: - PV_DATABASE_URL: postgresql://generator@db/ratings - UID: ${UID:-0} - GID: ${GID:-0} - secrets: - - anthropic_key - depends_on: - flyway: - condition: service_completed_successfully - volumes: - - ${GENERATOR_DATA_ROOT:-./generator/data}:/app/generator/data - - ${GENERATOR_PVAIU_DATA_ROOT:-./generator/pvaiu}:/app/generator/pvaiu -volumes: - caddy_data: - caddy_config: - pgadmin_data: - -configs: - # this config pre-loads the database server into pgadmin so you don't have to add it by hand - pgadmin_servers: - file: ./pgadmin/servers.json - pgadmin_passwords: - file: ./pgadmin/pgpass - -secrets: - anthropic_key: - environment: ANTHROPIC_API_KEY -- GitLab From e4190aa9c6b3403eb95b8382245504cde62ffbb1 Mon Sep 17 00:00:00 2001 From: lemony-cricket Date: Thu, 11 Dec 2025 14:42:40 -0500 Subject: [PATCH 2/2] return of the new and improved dev scripts --- .env.example | 3 + .gitignore | 5 + README.md | 54 +++++- compose.override.yml | 36 ---- compose.yml | 4 +- package.json | 14 ++ scripts/compose-utils.js | 194 +++++++++++++++++++ scripts/config-local.js | 60 ++++++ scripts/config-upstream.js | 87 +++++++++ scripts/setup.js | 380 +++++++++++++++++++++++++++++++++++++ 10 files changed, 794 insertions(+), 43 deletions(-) create mode 100644 .gitignore delete mode 100644 compose.override.yml create mode 100644 package.json create mode 100644 scripts/compose-utils.js create mode 100644 scripts/config-local.js create mode 100644 scripts/config-upstream.js create mode 100644 scripts/setup.js diff --git a/.env.example b/.env.example index 95619f2..b6ea874 100644 --- a/.env.example +++ b/.env.example @@ -27,3 +27,6 @@ # Needs to remain uncommented, or else be specified in the environment, # or you'll get an annoying error every time you bring up new containers. ANTHROPIC_API_KEY= + +# Flyway command, empty by default +FLYWAY_COMMAND= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..14d7d18 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +.env +package-lock.json +generator/ +db/ \ No newline at end of file diff --git a/README.md b/README.md index af82b15..0aa922b 100644 --- a/README.md +++ b/README.md @@ -25,10 +25,56 @@ sudo apt-get update sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin ``` +## Initial Setup + +Run the setup script to initialize your environment: +```bash +npm install +npm run setup +``` + +This will: +- Create `.env` from `.env.example` if it doesn't exist +- Create required directories (`db/pgdata`, `generator/data`, `generator/pvaiu`) +- Set UID/GID in `.env` for proper file ownership +- Prompt for your Anthropic API key (optional) + +Options: +- `npm run setup --non-interactive` - Use defaults, no prompts +- `npm run setup --reset-db` - Delete and reinitialize the database directory +- `npm run setup --help` - Show help + +## Local vs Upstream Mode + +By default, the stack pulls pre-built images from the GitLab registry. To build from local source code instead: + +```bash +npm run mode:local # Switch to local builds +npm run mode:upstream # Switch back to registry images +``` + +**Local mode** adds build configurations to `compose.override.yml` for services that have Dockerfiles in sibling directories. Clone any repositories you want to build locally alongside `ratings-stack`: + +``` +parent-directory/ # e.g. /home/user/projects/peerverity/ + ratings-stack/ # This repository + ratings/ # Clone for local UI, API, DB, Flyway builds + data-generator/ # Clone for local generator builds + ai-delegate/ # Clone for local AI delegate builds (affects Docker server image ONLY, see note below) +``` + +The script auto-discovers which services can be built locally based on which sibling repositories exist and contain the expected Dockerfiles. + +**Upstream mode** removes the local build configurations, reverting to registry images. + +Both commands preserve any custom overrides you've added to `compose.override.yml`. + +**Note:** Local mode only affects Docker image builds. Package dependencies (npm, pip, etc.) are still installed from their canonical sources (usually, the GitLab package registry) unless you manually configure otherwise. + ## Bring up the stack Basic usage: -``` +```bash docker compose up --detach ``` Then go to http://127.0.0.1/ for the app @@ -42,8 +88,6 @@ docker compose build caddy ## Customization -The stack can be tweaked by overriding variables in the `.env` file in this directory. If you don't have such a -file, create a new one in this directory, templated off of `.env.example`. +The stack can be tweaked by overriding variables in the `.env` file (created by `npm run setup`). See `.env.example` for available options. -By default, the stack will persist its database in the `db/pgdata` directory. You can change this by overriding -`DATABASE_ROOT` in your `.env` file. +By default, the stack will persist its database in the `db/pgdata` directory. You can change this by setting `DATABASE_ROOT` in your `.env` file. diff --git a/compose.override.yml b/compose.override.yml deleted file mode 100644 index 6486890..0000000 --- a/compose.override.yml +++ /dev/null @@ -1,36 +0,0 @@ -services: - db: - image: registry.gitlab.syncad.com/peerverity/ratings/db:local - build: - context: ../ratings - dockerfile: docker/db/Dockerfile - flyway: - image: registry.gitlab.syncad.com/peerverity/ratings/flyway:local - build: - context: ../ratings - dockerfile: docker/flyway/Dockerfile - ui: - image: registry.gitlab.syncad.com/peerverity/ratings/ui:local - build: - context: ../ratings - dockerfile: docker/ui/Dockerfile - api: - image: registry.gitlab.syncad.com/peerverity/ratings/api:local - build: - context: ../ratings - dockerfile: docker/api/Dockerfile - caddy: - image: registry.gitlab.syncad.com/peerverity/ratings-stack/caddy:local - build: - context: ./caddy - dockerfile: Dockerfile - generator: - image: registry.gitlab.syncad.com/peerverity/generator/generator:local - build: - context: ../data-generator - dockerfile: docker/data-generator/Dockerfile - ai-delegate: - image: registry.gitlab.syncad.com/peerverity/ai-delegate/ai-delegate:local - build: - context: ../ai-delegate - dockerfile: docker/ai-delegate/Dockerfile diff --git a/compose.yml b/compose.yml index c84e147..7636dea 100644 --- a/compose.yml +++ b/compose.yml @@ -96,8 +96,8 @@ services: - type: volume source: caddy_config target: /config - generator: - image: registry.gitlab.syncad.com/peerverity/generator/generator:${GENERATOR_TAG:-${TAG:-latest}} + data-generator: + image: registry.gitlab.syncad.com/peerverity/data-generator/data-generator:${GENERATOR_TAG:-${TAG:-latest}} environment: PV_DATABASE_URL: postgresql://generator@db/ratings UID: ${UID:-0} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ec31828 --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "ratings-stack", + "version": "1.0.0", + "private": true, + "description": "Docker Compose orchestration for PeerVerity ratings system", + "scripts": { + "setup": "node scripts/setup.js", + "mode:local": "node scripts/config-local.js", + "mode:upstream": "node scripts/config-upstream.js" + }, + "devDependencies": { + "js-yaml": "^4.1.0" + } +} diff --git a/scripts/compose-utils.js b/scripts/compose-utils.js new file mode 100644 index 0000000..a511d67 --- /dev/null +++ b/scripts/compose-utils.js @@ -0,0 +1,194 @@ +const fs = require('fs'); +const path = require('path'); +const yaml = require('js-yaml'); + +const COMPOSE_FILE = path.join(__dirname, '..', 'compose.yml'); +const OVERRIDE_FILE = path.join(__dirname, '..', 'compose.override.yml'); +const REGISTRY_PREFIX = 'registry.gitlab.syncad.com/peerverity/'; + +/** + * Read and parse a YAML file, returning empty object if it doesn't exist + */ +function readYamlFile(filePath) { + if (!fs.existsSync(filePath)) { + return {}; + } + const content = fs.readFileSync(filePath, 'utf8'); + return yaml.load(content) || {}; +} + +/** + * Write config to YAML file, or delete if empty + */ +function writeYamlFile(filePath, config) { + if (!config || Object.keys(config).length === 0) { + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + return true; // file was deleted + } + return false; + } + + const content = yaml.dump(config, { + indent: 2, + lineWidth: -1, + sortKeys: false, + quotingType: "'", + forceQuotes: false + }); + + fs.writeFileSync(filePath, content, 'utf8'); + return false; // file was written +} + +/** + * Deep merge source into target (mutates target) + * Source values overwrite target values for same keys + */ +function deepMerge(target, source) { + for (const key of Object.keys(source)) { + const sourceVal = source[key]; + const targetVal = target[key]; + + if (sourceVal && typeof sourceVal === 'object' && !Array.isArray(sourceVal)) { + if (!targetVal || typeof targetVal !== 'object' || Array.isArray(targetVal)) { + target[key] = {}; + } + deepMerge(target[key], sourceVal); + } else { + target[key] = sourceVal; + } + } + return target; +} + +/** + * Extract base image path and service name from a Docker image reference + * Example: "registry.gitlab.syncad.com/peerverity/ratings/db:${TAG}" + * → { baseImage: "registry.gitlab.syncad.com/peerverity/ratings/db", serviceName: "db" } + */ +function parseImageReference(imageRef) { + // Remove tag (everything after : including variable substitutions) + const baseImage = imageRef.split(':')[0]; + + // Extract service name (last path segment) + const segments = baseImage.split('/'); + const serviceName = segments[segments.length - 1]; + + return { baseImage, serviceName }; +} + +/** + * Search for a Dockerfile for the given service name + * Returns { context, dockerfile } relative to ratings-stack dir, or null if not found + */ +function findDockerfile(serviceName) { + const stackDir = path.join(__dirname, '..'); + const parentDir = path.join(stackDir, '..'); + + // Search patterns in priority order + const searchPatterns = [ + // 1. ./{serviceName}/Dockerfile (e.g., ./caddy/Dockerfile) + { context: `./${serviceName}`, dockerfile: 'Dockerfile' }, + // 2. ./docker/{serviceName}/Dockerfile + { context: '.', dockerfile: `docker/${serviceName}/Dockerfile` }, + ]; + + // Check patterns in current directory first + for (const pattern of searchPatterns) { + const dockerfilePath = path.join(stackDir, pattern.context, pattern.dockerfile); + if (fs.existsSync(dockerfilePath)) { + return pattern; + } + } + + // Search sibling directories for docker/{serviceName}/Dockerfile + try { + const siblings = fs.readdirSync(parentDir, { withFileTypes: true }) + .filter(dirent => dirent.isDirectory()) + .map(dirent => dirent.name) + .filter(name => name !== 'ratings-stack'); // exclude self + + for (const sibling of siblings) { + // Check ../sibling/docker/{serviceName}/Dockerfile + const dockerSubdir = path.join(parentDir, sibling, 'docker', serviceName, 'Dockerfile'); + if (fs.existsSync(dockerSubdir)) { + return { + context: `../${sibling}`, + dockerfile: `docker/${serviceName}/Dockerfile` + }; + } + + // Check ../sibling/{serviceName}/Dockerfile + const directPath = path.join(parentDir, sibling, serviceName, 'Dockerfile'); + if (fs.existsSync(directPath)) { + return { + context: `../${sibling}`, + dockerfile: `${serviceName}/Dockerfile` + }; + } + } + } catch (err) { + // Ignore errors reading parent directory + } + + return null; +} + +/** + * Discover all services that can be built locally + * Returns { services: { [serviceName]: { image, build: { context, dockerfile } } } } + */ +function discoverLocalBuilds() { + const compose = readYamlFile(COMPOSE_FILE); + const overrides = { services: {} }; + const discovered = []; + const notFound = []; + + if (!compose.services) { + return { overrides, discovered, notFound }; + } + + for (const [serviceName, serviceConfig] of Object.entries(compose.services)) { + if (!serviceConfig.image) continue; + + // Only process our registry images + if (!serviceConfig.image.startsWith(REGISTRY_PREFIX)) continue; + + const { baseImage } = parseImageReference(serviceConfig.image); + const dockerfileInfo = findDockerfile(serviceName); + + if (dockerfileInfo) { + overrides.services[serviceName] = { + image: `${baseImage}:local`, + build: { + context: dockerfileInfo.context, + dockerfile: dockerfileInfo.dockerfile + } + }; + discovered.push(serviceName); + } else { + notFound.push(serviceName); + } + } + + return { overrides, discovered, notFound }; +} + +/** + * Get list of service names that would be managed by local build overrides + */ +function getManagedServiceNames() { + const { discovered } = discoverLocalBuilds(); + return discovered; +} + +module.exports = { + COMPOSE_FILE, + OVERRIDE_FILE, + readYamlFile, + writeYamlFile, + deepMerge, + discoverLocalBuilds, + getManagedServiceNames +}; diff --git a/scripts/config-local.js b/scripts/config-local.js new file mode 100644 index 0000000..89b8919 --- /dev/null +++ b/scripts/config-local.js @@ -0,0 +1,60 @@ +#!/usr/bin/env node +/** + * Add local build overrides to compose.override.yml + * Automatically discovers services with Dockerfiles in the workspace + * Preserves any existing user customizations + */ + +const { + OVERRIDE_FILE, + readYamlFile, + writeYamlFile, + deepMerge, + discoverLocalBuilds +} = require('./compose-utils'); + +function main() { + console.log('Discovering local build configurations...\n'); + + const { overrides, discovered, notFound } = discoverLocalBuilds(); + + if (discovered.length === 0) { + console.log('No local Dockerfiles found for any services.'); + if (notFound.length > 0) { + console.log('\nServices without local Dockerfiles:'); + for (const service of notFound) { + console.log(` - ${service}`); + } + } + return; + } + + // Read existing config (or empty if file doesn't exist) + const config = readYamlFile(OVERRIDE_FILE); + + // Deep merge local overrides into existing config + deepMerge(config, overrides); + + // Write the result + writeYamlFile(OVERRIDE_FILE, config); + + console.log('Local build configuration applied to compose.override.yml\n'); + console.log('Services configured for local build:'); + for (const service of discovered) { + const buildConfig = overrides.services[service].build; + console.log(` - ${service}`); + console.log(` context: ${buildConfig.context}`); + console.log(` dockerfile: ${buildConfig.dockerfile}`); + } + + if (notFound.length > 0) { + console.log('\nServices without local Dockerfiles (skipped):'); + for (const service of notFound) { + console.log(` - ${service}`); + } + } + + console.log('\nRun "docker compose build" to build local images.'); +} + +main(); diff --git a/scripts/config-upstream.js b/scripts/config-upstream.js new file mode 100644 index 0000000..5915d04 --- /dev/null +++ b/scripts/config-upstream.js @@ -0,0 +1,87 @@ +#!/usr/bin/env node +/** + * Remove local build overrides from compose.override.yml + * Automatically discovers which services to clean up + * Preserves any user customizations that are not part of local overrides + */ + +const fs = require('fs'); +const { + OVERRIDE_FILE, + readYamlFile, + writeYamlFile, + getManagedServiceNames +} = require('./compose-utils'); + +/** + * Remove local build overrides from config + * Only removes 'image' and 'build' keys for managed services + */ +function removeLocalOverrides(config, managedServices) { + if (!config.services) return config; + + for (const serviceName of managedServices) { + if (config.services[serviceName]) { + // Remove only the keys we manage + delete config.services[serviceName].image; + delete config.services[serviceName].build; + + // If service is now empty, remove it entirely + if (Object.keys(config.services[serviceName]).length === 0) { + delete config.services[serviceName]; + } + } + } + + // If services object is now empty, remove it + if (config.services && Object.keys(config.services).length === 0) { + delete config.services; + } + + return config; +} + +function main() { + console.log('Removing local build overrides...\n'); + + // Check if file exists + if (!fs.existsSync(OVERRIDE_FILE)) { + console.log('No compose.override.yml found - already using upstream configuration.'); + return; + } + + // Discover which services we manage + const managedServices = getManagedServiceNames(); + + if (managedServices.length === 0) { + console.log('No manageable services discovered.'); + return; + } + + // Read existing config + let config = readYamlFile(OVERRIDE_FILE); + + // Remove local overrides + config = removeLocalOverrides(config, managedServices); + + // Write result (or delete file if empty) + const wasDeleted = writeYamlFile(OVERRIDE_FILE, config); + + if (wasDeleted) { + console.log('All local overrides removed. compose.override.yml deleted.'); + } else if (Object.keys(config).length === 0) { + console.log('All local overrides removed. compose.override.yml deleted.'); + } else { + console.log('Local build overrides removed from compose.override.yml'); + console.log('User customizations preserved.'); + } + + console.log('\nServices switched to upstream images:'); + for (const service of managedServices) { + console.log(` - ${service}`); + } + + console.log('\nRun "docker compose pull" to fetch upstream images.'); +} + +main(); diff --git a/scripts/setup.js b/scripts/setup.js new file mode 100644 index 0000000..6cc97a9 --- /dev/null +++ b/scripts/setup.js @@ -0,0 +1,380 @@ +#!/usr/bin/env node +/** + * Setup script for ratings-stack + * Cross-platform port of predicate-testing/docker/setup.sh + * + * Usage: npm run setup [OPTIONS] + * + * Options: + * --reset-db Delete and reinitialize the database directory + * --non-interactive Disable all prompts and take default choices + * --help Show this help message and exit + */ + +const fs = require('fs'); +const path = require('path'); +const readline = require('readline'); +const { execSync } = require('child_process'); + +const SCRIPT_DIR = path.join(__dirname, '..'); +const ENV_FILE = path.join(SCRIPT_DIR, '.env'); +const ENV_EXAMPLE_FILE = path.join(SCRIPT_DIR, '.env.example'); + +const isWindows = process.platform === 'win32'; + +// Directory configuration with defaults +const DIR_CONFIG = { + DATABASE_ROOT: './db/pgdata', + GENERATOR_DATA_ROOT: './generator/data', + GENERATOR_PVAIU_DATA_ROOT: './generator/pvaiu' +}; + +// ----------------------------------------------------------------------------- +// CLI Argument Parsing +// ----------------------------------------------------------------------------- + +function parseArgs() { + const args = process.argv.slice(2); + const options = { + resetDb: false, + nonInteractive: false, + help: false + }; + + for (const arg of args) { + if (arg === '--help' || arg === '-h') { + options.help = true; + } else if (arg === '--non-interactive') { + options.nonInteractive = true; + } else if (arg === '--reset-db') { + options.resetDb = true; + } + } + + return options; +} + +function printHelp() { + console.log(`Usage: npm run setup [OPTIONS] + +Options: + --reset-db Delete and reinitialize the database directory + --non-interactive Disable all prompts and take default choices + --help Show this help message and exit + +This script sets up the environment and database directories for the project.`); +} + +// ----------------------------------------------------------------------------- +// Environment File Utilities +// ----------------------------------------------------------------------------- + +/** + * Get the value of a variable from the .env file or from the environment + * Priority: environment > uncommented .env > commented .env default + */ +function getEnvVar(varName, envFile) { + let value = ''; + + if (fs.existsSync(envFile)) { + const content = fs.readFileSync(envFile, 'utf8'); + const lines = content.split('\n'); + + // Find commented default value + const commentedRegex = new RegExp(`^\\s*#\\s*${varName}\\s*=(.*)$`); + // Find uncommented value + const uncommentedRegex = new RegExp(`^\\s*${varName}\\s*=(.*)$`); + + for (const line of lines) { + const commentedMatch = line.match(commentedRegex); + if (commentedMatch) { + value = commentedMatch[1].trim(); + } + const uncommentedMatch = line.match(uncommentedRegex); + if (uncommentedMatch) { + value = uncommentedMatch[1].trim(); + } + } + } + + // Environment variable overrides file + if (process.env[varName] !== undefined) { + value = process.env[varName]; + } + + return value; +} + +/** + * Set or update a variable in the .env file + * Finds the last line that sets or comments the variable and replaces it + */ +function setEnvVar(varName, varValue, envFile) { + if (!fs.existsSync(envFile)) { + fs.writeFileSync(envFile, `${varName}=${varValue}\n`, 'utf8'); + return; + } + + const content = fs.readFileSync(envFile, 'utf8'); + const lines = content.split('\n'); + + // Find the last line that sets or comments this variable + const regex = new RegExp(`^\\s*#?\\s*${varName}\\s*=`); + let lastMatchIndex = -1; + + for (let i = 0; i < lines.length; i++) { + if (regex.test(lines[i])) { + lastMatchIndex = i; + } + } + + if (lastMatchIndex >= 0) { + // Replace the last occurrence + lines[lastMatchIndex] = `${varName}=${varValue}`; + } else { + // Append to end + lines.push(`${varName}=${varValue}`); + } + + fs.writeFileSync(envFile, lines.join('\n'), 'utf8'); +} + +// ----------------------------------------------------------------------------- +// Directory Utilities +// ----------------------------------------------------------------------------- + +/** + * Build an absolute directory path from env var with fallback default + */ +function buildDirPath(varName, defaultRel, envFile) { + let relPath = getEnvVar(varName, envFile); + if (!relPath) { + relPath = defaultRel; + } + const absPath = path.resolve(SCRIPT_DIR, relPath); + console.log(`$${varName} is ${absPath}`); + return absPath; +} + +/** + * Check ownership of a directory (Unix only) + * Returns { owned: boolean, uid: number, gid: number } + */ +function checkOwnership(dirPath) { + if (isWindows) { + return { owned: true, skip: true }; + } + + try { + const stats = fs.statSync(dirPath); + const currentUid = process.getuid(); + const currentGid = process.getgid(); + + return { + owned: stats.uid === currentUid && stats.gid === currentGid, + uid: stats.uid, + gid: stats.gid, + currentUid, + currentGid + }; + } catch (err) { + return { owned: true, skip: true }; + } +} + +/** + * Remove a directory, retrying with sudo if permission denied (Unix only) + */ +function removeDir(dirPath) { + try { + fs.rmSync(dirPath, { recursive: true, force: true }); + } catch (err) { + if (isWindows) { + throw err; + } + console.log('Retrying with sudo...'); + execSync(`sudo rm -rf "${dirPath}"`, { stdio: 'inherit' }); + } +} + +/** + * Fix ownership of a directory (Unix only) + */ +function fixOwnership(dirPath, uid, gid) { + if (isWindows) return; + + try { + // chownSync only works on the directory itself, need recursive + execSync(`chown -R ${uid}:${gid} "${dirPath}"`, { stdio: 'inherit' }); + } catch (err) { + console.log('Retrying with sudo...'); + execSync(`sudo chown -R ${uid}:${gid} "${dirPath}"`, { stdio: 'inherit' }); + } +} + +/** + * Ensure a directory exists and optionally fix ownership + */ +async function ensureDirAndOwnership(varName, dirPath, options, rl) { + // Create directory if it doesn't exist + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + console.log(`$${varName} was not found; created it`); + } + + // Check ownership (Unix only) + if (isWindows) return; + + const ownership = checkOwnership(dirPath); + if (ownership.skip) return; + + if (!ownership.owned) { + console.log(`$${varName} is owned by UID ${ownership.uid}.`); + + let fixPerms = false; + if (options.nonInteractive) { + console.log('Will not ask to take ownership (--non-interactive).'); + } else { + fixPerms = await promptYesNo(rl, 'Would you like to take ownership [y/N]? '); + } + + if (fixPerms) { + fixOwnership(dirPath, ownership.currentUid, ownership.currentGid); + console.log(`Ownership of ${dirPath} changed to ${ownership.currentUid}:${ownership.currentGid}.`); + } else { + console.log('Ownership not changed.'); + } + } +} + +// ----------------------------------------------------------------------------- +// Interactive Prompts +// ----------------------------------------------------------------------------- + +function createReadlineInterface() { + return readline.createInterface({ + input: process.stdin, + output: process.stdout + }); +} + +function promptYesNo(rl, question) { + return new Promise((resolve) => { + rl.question(question, (answer) => { + resolve(/^[Yy]$/.test(answer.trim())); + }); + }); +} + +function promptInput(rl, question) { + return new Promise((resolve) => { + rl.question(question, (answer) => { + resolve(answer); + }); + }); +} + +// ----------------------------------------------------------------------------- +// Main +// ----------------------------------------------------------------------------- + +async function main() { + const options = parseArgs(); + + if (options.help) { + printHelp(); + process.exit(0); + } + + // Create .env from .env.example if .env does not exist + if (!fs.existsSync(ENV_FILE) && fs.existsSync(ENV_EXAMPLE_FILE)) { + fs.copyFileSync(ENV_EXAMPLE_FILE, ENV_FILE); + console.log('.env file created from .env.example'); + } + + // Build directory paths + const dirs = {}; + for (const [varName, defaultPath] of Object.entries(DIR_CONFIG)) { + dirs[varName] = buildDirPath(varName, defaultPath, ENV_FILE); + } + + const rl = createReadlineInterface(); + + try { + // Handle --reset-db + if (options.resetDb) { + const dbDir = dirs.DATABASE_ROOT; + if (fs.existsSync(dbDir)) { + let confirmReset = false; + if (options.nonInteractive) { + // User passed both --reset-db and --non-interactive, that's affirmative + confirmReset = true; + } else { + confirmReset = await promptYesNo(rl, + 'Are you sure you want to delete $DATABASE_ROOT and all its contents? [y/N]: '); + } + + if (confirmReset) { + removeDir(dbDir); + console.log(`${dbDir} has been deleted.`); + } else { + console.log('Aborted.'); + } + } else { + console.log('Nothing to delete.'); + } + } + + // Ensure directories exist and check ownership + for (const [varName, dirPath] of Object.entries(dirs)) { + await ensureDirAndOwnership(varName, dirPath, options, rl); + } + + // Set UID and GID in .env only if DATABASE_ROOT is owned by current user (Unix only) + if (!isWindows) { + const dbDir = dirs.DATABASE_ROOT; + if (fs.existsSync(dbDir)) { + const ownership = checkOwnership(dbDir); + if (!ownership.skip && ownership.owned) { + setEnvVar('UID', String(ownership.currentUid), ENV_FILE); + setEnvVar('GID', String(ownership.currentGid), ENV_FILE); + console.log('UID and GID set in .env file'); + } else if (!ownership.skip) { + console.log('$DATABASE_ROOT is not owned by the current user/group; skipping UID/GID set in .env.'); + console.log('It is suggested to run "npm run setup -- --reset-db" to fix this (any data will be lost).'); + } + } + } + + // Check for ANTHROPIC_API_KEY + const anthropicKey = getEnvVar('ANTHROPIC_API_KEY', ENV_FILE); + if (!anthropicKey) { + console.log('$ANTHROPIC_API_KEY is not set in your environment or .env file.'); + + let setKey = false; + if (options.nonInteractive) { + console.log('Will not ask to set $ANTHROPIC_API_KEY (--non-interactive).'); + } else { + console.log('Would you like to set your Anthropic API key in .env now?'); + console.log('WARNING: do NOT do this if you\'re deploying a public instance!'); + setKey = await promptYesNo(rl, 'Set API key [y/N]? '); + } + + if (setKey) { + const userKey = await promptInput(rl, 'Enter your Anthropic API key: '); + setEnvVar('ANTHROPIC_API_KEY', userKey, ENV_FILE); + console.log('ANTHROPIC_API_KEY set in .env file'); + } else { + setEnvVar('ANTHROPIC_API_KEY', '', ENV_FILE); + console.log('ANTHROPIC_API_KEY left blank.'); + } + } + } finally { + rl.close(); + } +} + +main().catch((err) => { + console.error('Setup failed:', err.message); + process.exit(1); +}); -- GitLab