diff --git a/.gitignore b/.gitignore index c372c2f..1af761d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,13 @@ -node0/ -node1/ -node2/ -peers.json -event_initiator.key -event_initiator.identity.json -dev/node-configs/config.yaml -cookies.txt +# Generated dev config files dev/config.yaml dev/config.rescanner.yaml +dev/config.indexer.yaml + +# Generated compose env +.fystack.compose.env + +# Generated MPC node configs (created by `fystack init`) +dev/node-configs/ + +# Misc +cookies.txt diff --git a/CLI_USAGE.md b/CLI_USAGE.md new file mode 100644 index 0000000..db83a76 --- /dev/null +++ b/CLI_USAGE.md @@ -0,0 +1,447 @@ +# Fystack CLI Usage Guide + +The `fystack` CLI manages the entire self-host stack lifecycle: guided setup, MPC node initialization, Docker Compose operations, image version management, and clean resets. + +--- + +## Installation + +### Option 1 — Install binary (recommended) + +```bash +go install github.com/fystack/fystack-selfhost-scripts/cmd/fystack@latest +``` + +This places the `fystack` binary in `$GOPATH/bin` (typically `~/go/bin`). Make sure that directory is on your `$PATH`: + +```bash +export PATH="$PATH:$(go env GOPATH)/bin" +``` + +Verify: + +```bash +fystack version +``` + +### Option 2 — Build from source + +```bash +git clone git@github.com:fystack/fystack-selfhost-scripts.git +cd fystack-selfhost-scripts +go build -o fystack ./cmd/fystack +sudo mv fystack /usr/local/bin/ +``` + +### Option 3 — Run directly with `go run` + +From the repository root, prefix every command with `go run ./cmd/fystack`: + +```bash +go run ./cmd/fystack +``` + +All examples in this document use `fystack `. + +--- + +## Prerequisites + +- Docker and Docker Compose are installed and running. +- Go 1.26 or newer is installed when building from source or using `go run`. +- You are logged in to the Fystack Labs Docker registry: + +```bash +docker login -u fystacklabs +``` + +- A CoinMarketCap API key is needed only if you choose CoinMarketCap as the price provider. Binance does not require a key. +- On Windows, run the CLI inside WSL2. + +--- + +## Fast Path + +For a first local setup: + +```bash +fystack setup +``` + +The guided setup: + +- lets you choose the environment with arrow keys +- creates missing dev config files from templates +- asks whether to overwrite existing dev config files +- lets you choose Binance or CoinMarketCap as the price provider +- prompts for a CoinMarketCap API key only when CoinMarketCap is selected +- generates MPC node configs using the bundled `mpcium-cli` container +- asks whether to deploy the dev stack immediately + +Interactive selections use Bubble Tea. Use arrow keys, `j`/`k`, or Enter to accept. + +--- + +## Manual Path + +Run each step individually: + +```bash +fystack doctor --env dev +fystack init --env dev +fystack deploy --env dev +fystack status --env dev +``` + +After deployment, open the portal at `http://localhost:8015`. + +--- + +## Global Flags + +All commands accept: + +```bash +--env dev # default +--env prod +``` + +--- + +## Commands + +### `setup` + +Run the guided first-time setup: + +```bash +fystack setup +``` + +Creates these files when missing: + +```text +dev/config.yaml +dev/config.rescanner.yaml +dev/config.indexer.yaml +``` + +If any dev config files already exist, setup asks whether to overwrite them from the templates. If you choose CoinMarketCap and `dev/config.yaml` already has a key, pressing Enter keeps the current value. + +--- + +### `doctor` + +Check local prerequisites and required stack files: + +```bash +fystack doctor --env dev +``` + +Checks: + +- `stack.versions.yaml` +- the selected environment's Docker Compose file +- required dev config files +- Docker availability +- Docker Compose availability + +Run this when setup fails or before deploying. + +--- + +### `init` + +Generate MPC node configuration files for the dev stack: + +```bash +fystack init --env dev +``` + +Generates peer identities, a cluster config, and per-node identity material using the bundled `mpcium-cli` Docker image. If `dev/node-configs` already contains files the command skips generation to avoid overwriting existing node material. + +To intentionally regenerate: + +```bash +fystack init --env dev --force +``` + +--- + +### `deploy` + +Deploy the selected Docker Compose stack: + +```bash +fystack deploy --env dev +``` + +For `dev`, the CLI starts infrastructure services first, discovers generated MPC node configs, then starts the MPC services. If no node configs exist, run `fystack init --env dev` first. + +The CLI writes `.fystack.compose.env` before running Docker Compose. This generated file contains image pins from `stack.versions.yaml`. + +--- + +### `status` + +Show Docker Compose service status: + +```bash +fystack status --env dev +``` + +--- + +### `restart` + +Restart selected Docker Compose services: + +```bash +fystack restart --env dev +``` + +With no service names, an interactive checkbox list opens. Use Space to select, `a` to toggle all, Enter to restart. + +Restart specific services directly: + +```bash +fystack restart apex rescanner --env dev +fystack restart mpcium0 mpcium1 mpcium2 --env dev +``` + +--- + +### `logs` + +Show recent Docker Compose logs: + +```bash +fystack logs --env dev +fystack logs apex --env dev +fystack logs mpcium0 --tail 500 --env dev +``` + +The default tail is `200`. + +--- + +### `check-updates` + +Check Docker image tags for newer semver releases (read-only): + +```bash +fystack check-updates --env dev +``` + +Reports available newer tags but does not rewrite `stack.versions.yaml` or restart services. + +--- + +### `update` + +Update pinned Docker image versions in `stack.versions.yaml`: + +```bash +fystack update --env dev +``` + +With no service names, an interactive checkbox list of available app updates opens. After writing the selected pins, the CLI asks whether to deploy those updated services now. + +Update every available semver-tagged service: + +```bash +fystack update --env dev --all +``` + +Update and deploy immediately: + +```bash +fystack update --env dev --all --deploy +``` + +Update specific services: + +```bash +fystack update --env dev apex mpcium +``` + +`--all` and named-service updates are script-friendly: they update pins and print a deploy reminder without opening prompts unless `--deploy` is passed. + +Infrastructure services (MongoDB, PostgreSQL, Redis, NATS, Consul) are intentionally excluded from app updates. + +--- + +### `reset` + +Remove all generated files to restore the working tree to a clean clone state: + +```bash +fystack reset +``` + +Prompts for confirmation before removing: + +```text +dev/config.yaml +dev/config.rescanner.yaml +dev/config.indexer.yaml +dev/node-configs/ +.fystack.compose.env +``` + +Templates and source files are never touched. Skip the confirmation prompt for scripts: + +```bash +fystack reset --force +``` + +After a reset, run `fystack setup` to start fresh. + +--- + +### `destroy` + +Permanently tear down the entire stack — stops and removes all containers, Docker networks, and volumes, then wipes all generated files: + +```bash +fystack destroy --env dev +``` + +The command prints a destroy plan first, showing exactly what will be removed, then asks for confirmation: + +``` +Destroy plan: + environment : dev + compose file: dev/docker-compose.yaml + + The following will be permanently destroyed: + - All running containers for this environment + - All Docker networks created by Compose + - All Docker volumes (database data, MPC key shares, etc.) + - dev/config.yaml + - dev/config.rescanner.yaml + - dev/config.indexer.yaml + - dev/node-configs/ + - .fystack.compose.env + + This cannot be undone. +``` + +Skip the confirmation prompt for scripts: + +```bash +fystack destroy --env dev --force +``` + +After a destroy, run `fystack setup` to start from scratch. + +--- + +### `version` + +Print the CLI version: + +```bash +fystack version +``` + +--- + +## Files Managed by the CLI + +| File | Description | +|------|-------------| +| `dev/config.yaml` | Main Apex dev config. `setup` writes the CoinMarketCap API key and the event initiator private key here. | +| `dev/config.rescanner.yaml` | Rescanner dev config, copied from template when missing. | +| `dev/config.indexer.yaml` | Multichain indexer dev config, copied from template when missing. | +| `dev/node-configs/` | MPC node config directory generated by `init`. | +| `.fystack.compose.env` | Generated Compose environment file written by commands that run Docker Compose. | +| `stack.versions.yaml` | Source of Docker image pins. The `update` command rewrites this file. | + +--- + +## Common Workflows + +### First setup without immediate deploy + +```bash +fystack setup # choose "no" when asked to deploy +fystack doctor --env dev +fystack deploy --env dev +``` + +### Check the stack after deploy + +```bash +fystack status --env dev +fystack logs --env dev +``` + +### Restart selected services + +```bash +fystack restart --env dev +# or by name: +fystack restart apex rescanner --env dev +``` + +### Check and apply image updates + +```bash +fystack check-updates --env dev +fystack update --env dev apex mpcium +fystack deploy --env dev +``` + +Or in one step: + +```bash +fystack update --env dev --all --deploy +``` + +### Start fresh + +```bash +fystack reset --force +fystack setup +``` + +--- + +## Troubleshooting + +### Missing config files + +If `doctor`, `init`, or `deploy` reports missing dev config files: + +```bash +fystack setup +``` + +Or copy the templates manually: + +```bash +cp ./dev/config.yaml.template ./dev/config.yaml +cp ./dev/config.rescanner.yaml.template ./dev/config.rescanner.yaml +cp ./dev/config.indexer.yaml.template ./dev/config.indexer.yaml +``` + +### No MPC node configs found + +```bash +fystack init --env dev +fystack deploy --env dev +``` + +### Docker login or image pull failures + +```bash +docker compose version +docker login -u fystacklabs +fystack doctor --env dev +fystack deploy --env dev +``` + +### Existing node configs are reused + +`init` skips generation when `dev/node-configs` already has entries. Use `--force` to regenerate, or choose overwrite in guided setup. Only regenerate when you intentionally want new node material — existing key shares will be invalidated. diff --git a/README.md b/README.md index b460d43..78b960d 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # Fystack Self-host Quick Start Guide -Welcome to **Fystack**! This guide will help you get up and running quickly with our infrastructure, including: +Welcome to **Fystack**! This guide helps you get up and running with the self-hosted infrastructure: - **Apex**: The backend core API -- **MPCIUM**: Self-hosted MPC nodes for secure signing and key management +- **MPCIUM**: Self-hosted MPC nodes for secure threshold signing and key management ## Platform Support @@ -27,8 +27,6 @@ With Fystack you keep full ownership of: - **Key material and policies** with no external dependencies - **Threshold signature security** that scales across on-prem and private cloud setups -Run `./fystack-ignite.sh` to spin up the entire test stack in minutes. The quick start gives you hands-on access to Apex plus a local MPCIUM cluster so you can explore the architecture, build against real services, and validate the workflow before production. - --- ## Components @@ -39,15 +37,15 @@ Run `./fystack-ignite.sh` to spin up the entire test stack in minutes. The quick The API backend that handles: -- Wallet and User management +- Wallet and user management - Key orchestration and policy enforcement - Audit logging -- API Keys +- API keys - Transaction indexing ### 2. MPCIUM (MPC Nodes) -Each node runs part of the threshold signing/keygen logic (based on Binance's `tss-lib`) and communicates securely with Apex and otherpeers. +Each node runs part of the threshold signing/keygen logic (based on Binance's `tss-lib`) and communicates securely with Apex and other peers. ### 3. Multichain Indexer @@ -61,8 +59,8 @@ Reindexes block gaps to ensure complete blockchain data coverage, filling in any ## Prerequisites -- Docker and Docker Compose installed -- Bash shell +- Docker and Docker Compose installed and running +- Go 1.26 or newer (for the CLI) - Internet connection - Recommended system: 4 vCPU, 4 GB RAM @@ -70,9 +68,9 @@ Reindexes block gaps to ensure complete blockchain data coverage, filling in any > **Non-Windows users can skip this section.** -Windows users must run the scripts inside **WSL2** (Windows Subsystem for Linux). Docker Desktop for Windows already uses WSL2 under the hood, so this is the natural fit. +Windows users must run everything inside **WSL2** (Windows Subsystem for Linux). -**1. Install WSL2** (if you haven't already) +**1. Install WSL2** Open PowerShell as Administrator and run: @@ -80,31 +78,72 @@ Open PowerShell as Administrator and run: wsl --install ``` -This installs WSL2 with Ubuntu by default. Restart your machine when prompted. +Restart when prompted. This installs WSL2 with Ubuntu by default. **2. Install Docker Desktop for Windows** -Download and install [Docker Desktop](https://www.docker.com/products/docker-desktop/). During setup, ensure **"Use the WSL 2 based engine"** is checked (it's the default). +Download [Docker Desktop](https://www.docker.com/products/docker-desktop/). During setup, ensure **"Use the WSL 2 based engine"** is checked. Then go to **Settings > Resources > WSL Integration** and enable it for your distro. + +**3. Open a WSL2 terminal** + +All subsequent commands must be run from a WSL2 terminal, not from PowerShell or CMD. + +```bash +wsl +``` + +--- -After installation, go to Docker Desktop **Settings > Resources > WSL Integration** and enable integration for your WSL2 distro (e.g., Ubuntu). +## Install the CLI -**3. Install `jq` inside WSL2** +The `fystack` CLI manages the entire stack lifecycle: setup, deploy, status, updates, and reset. -Open your WSL2 terminal (Ubuntu) and run: +### Option 1 — Install binary (recommended) ```bash -sudo apt update && sudo apt install -y jq +go install github.com/fystack/fystack-selfhost-scripts/cmd/fystack@latest ``` -**4. Open a WSL2 terminal** +This places the `fystack` binary in `$GOPATH/bin` (typically `~/go/bin`). Make sure that directory is on your `$PATH`: + +```bash +export PATH="$PATH:$(go env GOPATH)/bin" +``` -All subsequent commands must be run from within a WSL2 terminal, not from PowerShell or CMD. The `docker` command inside WSL2 talks to Docker Desktop automatically. +Verify: ```bash -wsl +fystack version +``` + +### Option 2 — Build from source + +```bash +git clone git@github.com:fystack/fystack-selfhost-scripts.git +cd fystack-selfhost-scripts +go build -o fystack ./cmd/fystack +sudo mv fystack /usr/local/bin/ +``` + +Verify: + +```bash +fystack version ``` -## Quick Start Steps +### Option 3 — Run directly with `go run` + +If you don't want to install a binary, run commands directly from the repository root: + +```bash +go run ./cmd/fystack +``` + +All examples in this guide use `fystack `. Substitute `go run ./cmd/fystack ` if you are using Option 3. + +--- + +## Quick Start > **📺 Video Tutorial:** Watch the complete setup walkthrough on YouTube: > @@ -119,231 +158,211 @@ cd fystack-selfhost-scripts ### 2. Docker Login -First, authenticate with the Fystack Labs Docker registry: +Authenticate with the Fystack Labs Docker registry: ```bash docker login -u fystacklabs ``` -When prompted, enter your password. - > **Need the Docker password?** Join the Fystack Telegram community to get access: > > [![Telegram](https://img.shields.io/badge/Telegram-Join%20Community-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/+9AtC0z8sS79iZjFl) -### 3. Copy config files from the template - -``` -cp ./dev/config.yaml.template ./dev/config.yaml -cp ./dev/config.rescanner.yaml.template ./dev/config.rescanner.yaml -cp ./dev/config.indexer.yaml.template ./dev/config.indexer.yaml -``` - -> **Important:** You don't need to update all configuration values. The only mandatory configuration is the **CoinMarketCap API key**. Visit https://pro.coinmarketcap.com/login/ to create a CoinMarketCap API key, then add it to `config.yaml` under the `price_providers` configuration. - -### 4. Make Start Script Executable +### 3. Run Guided Setup -Change to the root directory, make the start script executable. -Make sure you are at the root folder of the project +The single `setup` command handles everything interactively: ```bash -chmod +x ./fystack-ignite.sh +fystack setup ``` -### 5. Start the Fystack Cluster +It will: -Run the complete setup and startup script at the root folder of the project. +- Create dev config files from templates (or ask to overwrite if they already exist) +- Let you choose Binance (no API key) or CoinMarketCap as the price provider +- Generate MPC node configs using the bundled `mpcium-cli` container +- Optionally deploy the dev stack immediately + +### 4. Deploy (if you skipped it in setup) ```bash -./fystack-ignite.sh +fystack deploy --env dev ``` -This script will automatically: - -- Generate MPCIUM node configurations -- Start all Docker Compose services -- Extract encryption keys -- Configure and restart the Apex service - -Services started include: +### 5. Visit the Fystack Portal -- **NATS messaging server** (port 4223) -- **Consul service discovery** (port 8501) -- **PostgreSQL database** (port 5433) -- **Redis** (port 6380) -- **MongoDB** (port 27018) -- **Apex API** (port 8150) -- **3 MPC nodes** (node0, node1, node2) -- **Automatic peer registration** - -### 6. Visit the Fystack Portal - -Once all services are running, you can access the Fystack portal at [http://localhost:8015](http://localhost:8015) +Once all services are running, open the portal at [http://localhost:8015](http://localhost:8015) ![Fystack Portal](images/fystack-portal.png) -### 7. Verify the Setup - -Check that all services are running: +### 6. Verify the Setup ```bash -docker compose -f ./dev/docker-compose.yaml ps +fystack status --env dev ``` -You should see all services in the "Up" state. - -### 8. View Logs (Optional) +--- -Monitor the cluster logs: +## CLI Reference ```bash -# View all service logs -docker compose -f ./dev/docker-compose.yaml logs -f - -# View logs from a specific node -docker compose -f ./dev/docker-compose.yaml logs -f mpcium-node0 +fystack setup # Guided first-time setup +fystack doctor --env dev # Check prerequisites and required files +fystack init --env dev # Generate MPC node configs +fystack deploy --env dev # Deploy the Docker Compose stack +fystack status --env dev # Show running service status +fystack restart --env dev # Restart services (interactive selector) +fystack restart apex rescanner # Restart specific services +fystack logs --env dev # Show recent logs (all services) +fystack logs apex --tail 500 # Show logs for one service +fystack check-updates --env dev # Check for newer image tags +fystack update --env dev --all # Update all app image pins +fystack update --env dev --all --deploy # Update pins and redeploy +fystack reset # Remove all generated files (clean slate) +fystack destroy --env dev # Stop stack, wipe volumes, remove all generated files +fystack version # Print CLI version ``` -## What's Running +See [CLI_USAGE.md](CLI_USAGE.md) for the full command reference, flags, and troubleshooting notes. + +--- -Your Fystack cluster includes: +## What's Running -> **Note:** PostgreSQL, Redis, MongoDB, NATS, and Consul ports have been increased by 1 to avoid conflicts with your development environment. All ports are bound to 127.0.0.1 (localhost) for security. +> **Note:** PostgreSQL, Redis, MongoDB, NATS, and Consul ports are offset by 1 to avoid conflicts with your local dev environment. All ports are bound to `127.0.0.1`. | Service | Purpose | Port | -| ------------------------ | -------------------------------------------- | ----- | +|--------------------------|----------------------------------------------|-------| | **NATS Server** | Messaging layer for node communication | 4223 | | **Consul** | Service discovery and health checks | 8501 | | **PostgreSQL** | Database for custody operations | 5433 | | **Redis** | In-memory data store | 6380 | | **MongoDB** | Document database | 27018 | | **Apex API** | Main API service | 8150 | -| **Migrate** | Database migration service | - | -| **Rescanner** | Reindexes block gaps for complete data | - | -| **Multichain Indexer** | Indexes blockchain transactions in real-time | - | -| **Fystack UI Community** | Community web interface for Fystack | 8015 | +| **Migrate** | Database migration service | — | +| **Rescanner** | Reindexes block gaps for complete data | — | +| **Multichain Indexer** | Indexes blockchain transactions in real-time | — | +| **Fystack UI Community** | Community web interface | 8015 | | **MPC Node 0** | First MPC node | 8080 | | **MPC Node 1** | Second MPC node | 8081 | | **MPC Node 2** | Third MPC node | 8082 | -| **MPCIUM Init** | Peer registration service | - | -## E2E Testing - -Once your Fystack cluster is running, you can test the wallet creation flow using the provided end-to-end test script. - -### 1. Make Test Script Executable - -Make the E2E test script executable: - -```bash -chmod +x ./e2e/create-wallet.sh -``` +--- -### 2. Run the E2E Test +## E2E Testing -Execute the wallet creation test: +Once the stack is running, test the wallet creation flow: ```bash ./e2e/create-wallet.sh ``` -This script will: +This script registers a test user, signs in, creates a workspace and session, and creates an MPC wallet. -- Register a test user -- Sign in and create a workspace -- Start a session -- Create an MPC wallet - -### 3. Check Apex API Logs - -Monitor the Apex API logs to verify successful wallet creation: +### Check Apex logs after the test ```bash -docker compose -f ./dev/docker-compose.yaml logs -f apex +fystack logs apex --env dev ``` -**Expected successful output:** +**Expected output:** ``` -3:15AM INF Enqueueing message -3:15AM INF Message published message len=407 topic=auditlog.event.dispatch -3:15AM INF Received message meta={"Consumer":"event","Domain":"","NumDelivered":1,"NumPending":0,"Sequence":{"consumer_seq":2,"stream_seq":2},"Stream":"auditlog","Timestamp":"2025-08-06T03:15:55.887251886Z"} -3:15AM INF Message Acknowledged meta={"Consumer":"event","Domain":"","NumDelivered":1,"NumPending":0,"Sequence":{"consumer_seq":2,"stream_seq":2},"Stream":"auditlog","Timestamp":"2025-08-06T03:15:55.887251886Z"} -3:15AM INF Received message meta={"Consumer":"mpc_keygen_result","Domain":"","NumDelivered":1,"NumPending":0,"Sequence":{"consumer_seq":1,"stream_seq":1},"Stream":"mpc","Timestamp":"2025-08-06T03:15:59.081703423Z"} -3:15AM INF Process MPC generation successfully walletID=a8f47f60-540d-4d23-8743-6fd8b5abe5ce -3:15AM INF Message Acknowledged meta={"Consumer":"mpc_keygen_result","Domain":"","NumDelivered":1,"NumPending":0,"Sequence":{"consumer_seq":1,"stream_seq":1},"Stream":"mpc","Timestamp":"2025-08-06T03:15:59.081703423Z"} +3:15AM INF Process MPC generation successfully walletID=a8f47f60-... ``` -### 4. Check MPC Node Logs - -Verify the MPC key generation process on any node (e.g., node0): +### Check MPC node logs ```bash -docker compose -f ./dev/docker-compose.yaml logs -f mpcium-node0 +fystack logs mpcium0 --env dev ``` -**Expected successful output:** +**Expected output:** ``` -2025-08-06 03:15:55.000 INF Initializing session with partyID: {1,keygen}, peerIDs [{0,keygen} {1,keygen} {2,keygen}] -2025-08-06 03:15:55.000 INF [INITIALIZED] Initialized session successfully partyID: {1,keygen}, peerIDs [{0,keygen} {1,keygen} {2,keygen}], walletID a8f47f60-540d-4d23-8743-6fd8b5abe5ce, threshold = 2 -2025-08-06 03:15:55.000 INF Initializing session with partyID: {1,keygen}, peerIDs [{0,keygen} {1,keygen} {2,keygen}] -2025-08-06 03:15:55.000 INF [INITIALIZED] Initialized session successfully partyID: {1,keygen}, peerIDs [{0,keygen} {1,keygen} {2,keygen}], walletID a8f47f60-540d-4d23-8743-6fd8b5abe5ce, threshold = 2 -2025-08-06 03:15:56.000 INF Starting to generate key ECDSA walletID=a8f47f60-540d-4d23-8743-6fd8b5abe5ce -2025-08-06 03:15:56.000 INF Starting to generate key EDDSA walletID=a8f47f60-540d-4d23-8743-6fd8b5abe5ce -2025-08-06 03:15:59.000 INF Publishing message consumerName=mpc_keygen_result topic=mpc.mpc_keygen_result.a8f47f60-540d-4d23-8743-6fd8b5abe5ce -2025-08-06 03:15:59.000 INF [COMPLETED KEY GEN] Key generation completed successfully walletID=a8f47f60-540d-4d23-8743-6fd8b5abe5ce +INF [COMPLETED KEY GEN] Key generation completed successfully walletID=a8f47f60-... ``` -The successful completion of these logs indicates that your MPC wallet has been created and the key generation process completed across all nodes. +--- ## Deploying on a VPS / Reverse Proxy -By default, the Fystack UI connects to the Apex API at `http://localhost:8150`. If you're deploying on a VPS (e.g., Linode, DigitalOcean) behind a reverse proxy, you need to set the `API_BASE_URL` environment variable so the UI can reach the API. +By default, the Fystack UI connects to the Apex API at `http://localhost:8150`. When deploying on a VPS behind a reverse proxy, set `API_BASE_URL` so the UI can reach the API. -**Option 1: Export the variable before running the script** +**Option 1: Export before deploy** ```bash export API_BASE_URL=https://api.yourdomain.com -./fystack-ignite.sh +fystack deploy --env dev ``` -**Option 2: Create a `.env` file in the `dev/` directory** +**Option 2: `.env` file in `dev/`** ```bash echo "API_BASE_URL=https://api.yourdomain.com" > dev/.env -./fystack-ignite.sh +fystack deploy --env dev ``` -Replace `https://api.yourdomain.com` with the public URL where your Apex API is accessible through your reverse proxy. - ### Changing the API URL later -To update the `API_BASE_URL` after the initial setup (e.g., switching domains): - ```bash export API_BASE_URL=https://new-domain.com docker compose -f ./dev/docker-compose.yaml up -d --force-recreate fystack-ui-community ``` -This recreates the UI container with the new URL. No other services are affected. +--- + +## Updating Images + +Check for newer app image versions: + +```bash +fystack check-updates --env dev +``` + +Update all available semver-tagged services and redeploy: + +```bash +fystack update --env dev --all --deploy +``` + +Update specific services: + +```bash +fystack update --env dev apex mpcium +fystack deploy --env dev +``` + +--- + +## Resetting and Destroying -## Update version +### Reset (files only) -To update version of a component (New docker image version) +Remove all generated files and return the repository to a freshly-cloned state, without touching running containers: +```bash +fystack reset ``` -cd dev -docker compose pull apex -docker compose up -d --no-deps --force-recreate apex + +### Destroy (everything) + +Tear down the entire stack — stops all containers, removes Docker networks and volumes, then wipes all generated files: + +```bash +fystack destroy --env dev ``` +The command prints a plan showing exactly what will be removed and asks for confirmation before proceeding. This cannot be undone — all database data and MPC key shares stored in volumes are permanently lost. + +After a destroy, run `fystack setup` to start from scratch. + --- ## ⚠️ Important Notice > **WARNING: This setup is for testing environments only.** > -> For **manual deployment, Docker Compose, or Kubernetes deployment** for **maximum security**, please contact the **Fystack team** to get support and guidance on enterprise-grade deployment configurations. +> For **manual deployment, Docker Compose, or Kubernetes deployment** for **maximum security**, please contact the **Fystack team** for enterprise-grade deployment guidance. > > [![Telegram](https://img.shields.io/badge/Telegram-Contact%20Fystack%20Team-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/+IsRhPyWuOFxmNmM9) diff --git a/cmd/fystack/main.go b/cmd/fystack/main.go new file mode 100644 index 0000000..7d1eed6 --- /dev/null +++ b/cmd/fystack/main.go @@ -0,0 +1,15 @@ +package main + +import ( + "fmt" + "os" + + "github.com/fystack/fystack-selfhost-scripts/internal/app" +) + +func main() { + if err := app.NewRootCommand().Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/dev/config.yaml.template b/dev/config.yaml.template index dd845b9..d0975a8 100644 --- a/dev/config.yaml.template +++ b/dev/config.yaml.template @@ -73,6 +73,8 @@ price_providers: coinmarketcap: endpoint: "https://pro-api.coinmarketcap.com/v1/cryptocurrency/quotes/latest" api_key: "" + binance: + endpoint: "https://api.binance.com/api/v3/ticker/price" codex: endpoint: "https://graph.defined.fi/graphql" api_key: "" diff --git a/dev/docker-compose.yaml b/dev/docker-compose.yaml index 99f43cc..1d1aae2 100644 --- a/dev/docker-compose.yaml +++ b/dev/docker-compose.yaml @@ -1,6 +1,6 @@ services: migrate: - image: docker.io/fystacklabs/apex-migrate:1.0.24 + image: ${FYSTACK_APEX_MIGRATE_IMAGE:-docker.io/fystacklabs/apex-migrate:1.0.24} # build: # context: .. # dockerfile: deployments/migrate/Dockerfile @@ -23,7 +23,7 @@ services: # build: # context: .. # dockerfile: deployments/api/Dockerfile - image: docker.io/fystacklabs/apex:1.0.54 + image: ${FYSTACK_APEX_IMAGE:-docker.io/fystacklabs/apex:1.0.54} container_name: apex ports: - "127.0.0.1:8150:8150" @@ -53,7 +53,7 @@ services: - ./config.yaml:/app/config.yaml:ro rescanner: - image: docker.io/fystacklabs/rescanner:1.0.1 + image: ${FYSTACK_RESCANNER_IMAGE:-docker.io/fystacklabs/rescanner:1.0.1} container_name: rescanner environment: - ENVIRONMENT=dev @@ -62,10 +62,10 @@ services: - apex volumes: - shared-data:/app/shared - - ./config.rescanner.yaml:/root/config.rescanner.yaml:ro + - ./config.rescanner.yaml:/app/config.rescanner.yaml:ro multichain-indexer: - image: docker.io/fystacklabs/multichain-indexer:1.0.14 + image: ${FYSTACK_MULTICHAIN_INDEXER_IMAGE:-docker.io/fystacklabs/multichain-indexer:1.0.14} container_name: multichain-indexer command: [ @@ -84,22 +84,18 @@ services: - ./config.indexer.yaml:/app/configs/config.yaml:ro fystack-ui-community: - image: docker.io/fystacklabs/fystack-ui-community:1.0.11 + image: ${FYSTACK_UI_IMAGE:-docker.io/fystacklabs/fystack-ui-ee:1.0.17} container_name: fystack-ui-community ports: - "127.0.0.1:8015:8080" environment: - - API_BASE_URL=${API_BASE_URL:-http://localhost:8150} - entrypoint: ["/ui-entrypoint.sh"] - command: ["nginx", "-g", "daemon off;"] - volumes: - - ./ui-entrypoint.sh:/ui-entrypoint.sh:ro + API_BASE_URL: https://fystack.excelon.io restart: unless-stopped networks: - apex postgres: - image: postgres + image: ${FYSTACK_POSTGRES_IMAGE:-postgres} container_name: postgres_fystack ports: - "127.0.0.1:5433:5432" @@ -116,7 +112,7 @@ services: - apex redis: - image: redis/redis-stack-server:latest + image: ${FYSTACK_REDIS_IMAGE:-redis/redis-stack-server:latest} container_name: redis_fystack ports: - "127.0.0.1:6380:6379" @@ -132,7 +128,7 @@ services: - apex mongo: - image: mongo:7.0 + image: ${FYSTACK_MONGO_IMAGE:-mongo:7.0} container_name: mongo_fystack ports: - "127.0.0.1:27018:27017" @@ -150,7 +146,7 @@ services: - apex nats-server: - image: nats:latest + image: ${FYSTACK_NATS_IMAGE:-nats:latest} container_name: nats-server_fystack command: -js --http_port 8222 ports: @@ -163,7 +159,7 @@ services: - apex consul: - image: consul:1.15.4 + image: ${FYSTACK_CONSUL_IMAGE:-consul:1.15.4} container_name: consul_fystack ports: - "127.0.0.1:8501:8500" @@ -179,7 +175,7 @@ services: - apex ## MPCIUM mpcium0: - image: docker.io/fystacklabs/mpcium:0.3.5 + image: ${FYSTACK_MPCIUM_IMAGE:-docker.io/fystacklabs/mpcium:0.3.5} container_name: mpcium-node0 command: ["start", "-n", "node0", "--peers", "/app/peers.json"] environment: @@ -202,7 +198,7 @@ services: - apex mpcium1: - image: docker.io/fystacklabs/mpcium:0.3.5 + image: ${FYSTACK_MPCIUM_IMAGE:-docker.io/fystacklabs/mpcium:0.3.5} container_name: mpcium-node1 command: ["start", "-n", "node1", "--peers", "/app/peers.json"] environment: @@ -225,7 +221,7 @@ services: - apex mpcium2: - image: docker.io/fystacklabs/mpcium:0.3.5 + image: ${FYSTACK_MPCIUM_IMAGE:-docker.io/fystacklabs/mpcium:0.3.5} container_name: mpcium-node2 command: ["start", "-n", "node2", "--peers", "/app/peers.json"] environment: diff --git a/dev/setup-nodes.sh b/dev/setup-nodes.sh index cb590c8..08c5588 100755 --- a/dev/setup-nodes.sh +++ b/dev/setup-nodes.sh @@ -28,6 +28,7 @@ BASE_DIR=${BASE_DIR:-$SCRIPT_DIR/node-configs} ENCRYPT_KEYS=${ENCRYPT_KEYS:-false} NATS_URL=${NATS_URL:-"nats://nats-server:4222"} CONSUL_ADDRESS=${CONSUL_ADDRESS:-"consul:8500"} +OVERWRITE_EXISTING=${OVERWRITE_EXISTING:-false} # Detect OS for sed compatibility if [[ "$OSTYPE" == "darwin"* ]]; then @@ -144,7 +145,11 @@ cleanup_existing_setup() { if [ "$needs_docker_cleanup" = true ]; then log_warning "Found root-owned files from previous Docker runs, cleaning up with Docker..." - docker run --rm -v "$BASE_DIR:/data" -w /data alpine sh -c "rm -rf node* peers.json config.yaml" + if [ "$OVERWRITE_EXISTING" = "true" ]; then + docker run --rm -v "$BASE_DIR:/data" -w /data alpine sh -c "rm -rf node* peers.json config.yaml event_initiator.* integrity_signer.key" + else + docker run --rm -v "$BASE_DIR:/data" -w /data alpine sh -c "rm -rf node* peers.json config.yaml" + fi fi # Remove any remaining node directories @@ -155,7 +160,7 @@ cleanup_existing_setup() { fi done - # Remove existing files (but preserve event_initiator keys if pk_raw is already set) + # Remove existing generated files. Preserve keys by default to keep running services consistent. for file in peers.json config.yaml; do if [ -f "$BASE_DIR/$file" ]; then log_warning "Removing existing $file" @@ -163,6 +168,15 @@ cleanup_existing_setup() { fi done + if [ "$OVERWRITE_EXISTING" = "true" ]; then + for file in event_initiator.identity.json event_initiator.key event_initiator.key.age integrity_signer.key; do + if [ -f "$BASE_DIR/$file" ]; then + log_warning "Removing existing $file" + rm -f "$BASE_DIR/$file" + fi + done + fi + log_success "Cleanup completed" } @@ -241,7 +255,7 @@ generate_event_initiator() { fi # If pk_raw exists and keys exist, reuse them - if [ -n "$EXISTING_PK_RAW" ] && [ -f "event_initiator.key" ] && [ -f "event_initiator.identity.json" ]; then + if [ "$OVERWRITE_EXISTING" != "true" ] && [ -n "$EXISTING_PK_RAW" ] && [ -f "event_initiator.key" ] && [ -f "event_initiator.identity.json" ]; then log_warning "Existing event initiator found (pk_raw is set)" log_info "Reusing existing keys to maintain consistency with running services" @@ -262,10 +276,10 @@ generate_event_initiator() { # Generate event initiator using mpcium-cli if [ "$ENCRYPT_KEYS" = "true" ]; then - run_mpcium_cli generate-initiator --encrypt + run_mpcium_cli generate-initiator --encrypt --overwrite log_success "Generated encrypted event initiator" else - run_mpcium_cli generate-initiator + run_mpcium_cli generate-initiator --overwrite log_success "Generated unencrypted event initiator" fi fi @@ -594,6 +608,10 @@ main() { ENCRYPT_KEYS=true shift ;; + --overwrite) + OVERWRITE_EXISTING=true + shift + ;; --nats-url) NATS_URL="$2" shift 2 @@ -614,6 +632,7 @@ main() { echo " -t, --threshold THRESHOLD MPC threshold (default: 2)" echo " -e, --environment ENV Environment (development/production, default: development)" echo " --encrypt Encrypt private keys with Age" + echo " --overwrite Regenerate existing node and key material" echo " --nats-url URL NATS server URL (default: nats://nats-server:4222)" echo " --consul-address ADDR Consul address (default: consul:8500)" echo " -d, --directory DIR Base directory for setup (default: script directory/node-configs)" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b292dc1 --- /dev/null +++ b/go.mod @@ -0,0 +1,46 @@ +module github.com/fystack/fystack-selfhost-scripts + +go 1.26 + +require ( + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/google/go-containerregistry v0.20.2 + github.com/spf13/cobra v1.8.1 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/containerd/stargz-snapshotter/estargz v0.14.3 // indirect + github.com/docker/cli v27.1.1+incompatible // indirect + github.com/docker/distribution v2.8.2+incompatible // indirect + github.com/docker/docker-credential-helpers v0.7.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/klauspost/compress v1.16.5 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.0-rc3 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/sirupsen/logrus v1.9.1 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/vbatts/tar-split v0.11.3 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sync v0.8.0 // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.3.8 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b942e53 --- /dev/null +++ b/go.sum @@ -0,0 +1,115 @@ +github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/containerd/stargz-snapshotter/estargz v0.14.3 h1:OqlDCK3ZVUO6C3B/5FSkDwbkEETK84kQgEeFwDC+62k= +github.com/containerd/stargz-snapshotter/estargz v0.14.3/go.mod h1:KY//uOCIkSuNAHhJogcZtrNHdKrA99/FCCRjE3HD36o= +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +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/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/docker/cli v27.1.1+incompatible h1:goaZxOqs4QKxznZjjBWKONQci/MywhtRv2oNn0GkeZE= +github.com/docker/cli v27.1.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/distribution v2.8.2+incompatible h1:T3de5rq0dB1j30rp0sA2rER+m322EBzniBPB6ZIzuh8= +github.com/docker/distribution v2.8.2+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= +github.com/docker/docker-credential-helpers v0.7.0 h1:xtCHsjxogADNZcdv1pKUHXryefjlVRqWqIhk/uXJp0A= +github.com/docker/docker-credential-helpers v0.7.0/go.mod h1:rETQfLdHNT3foU5kuNkFR1R1V12OJRRO5lzt2D1b5X0= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-containerregistry v0.20.2 h1:B1wPJ1SN/S7pB+ZAimcciVD+r+yV/l/DSArMxlbwseo= +github.com/google/go-containerregistry v0.20.2/go.mod h1:z38EKdKh4h7IP2gSfUUqEvalZBqs6AoLeWfUy34nQC8= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/klauspost/compress v1.16.5 h1:IFV2oUNUzZaz+XyusxpLzpzS8Pt5rh0Z16For/djlyI= +github.com/klauspost/compress v1.16.5/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0-rc3 h1:fzg1mXZFj8YdPeNkRXMg+zb88BFV0Ys52cJydRwBkb8= +github.com/opencontainers/image-spec v1.1.0-rc3/go.mod h1:X4pATf0uXsnn3g5aiGIsVnJBR4mxhKzfwmvK/B2NTm8= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sirupsen/logrus v1.9.1 h1:Ou41VVR3nMWWmTiEUnj0OlsgOSCUFgsPAOl6jRIcVtQ= +github.com/sirupsen/logrus v1.9.1/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +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.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/urfave/cli v1.22.12/go.mod h1:sSBEIC79qR6OvcmsD4U3KABeOTxDqQtdDnaFuUN30b8= +github.com/vbatts/tar-split v0.11.3 h1:hLFqsOLQ1SsppQNTMpkpPXClLDfC2A3Zgy9OUU+RVck= +github.com/vbatts/tar-split v0.11.3/go.mod h1:9QlHN18E+fEH7RdG+QAJJcuya3rqT7eXSTY7wGrAokY= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/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-20220906165534-d0df966e6959/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +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/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +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/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0= +gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8= diff --git a/internal/app/assets.go b/internal/app/assets.go new file mode 100644 index 0000000..ef52473 --- /dev/null +++ b/internal/app/assets.go @@ -0,0 +1,6 @@ +package app + +import _ "embed" + +//go:embed assets/banner.txt +var setupBanner string diff --git a/internal/app/assets/banner.txt b/internal/app/assets/banner.txt new file mode 100644 index 0000000..a95ccea --- /dev/null +++ b/internal/app/assets/banner.txt @@ -0,0 +1,6 @@ +███████╗██╗ ██╗███████╗████████╗ █████╗ ██████╗██╗ ██╗ +██╔════╝╚██╗ ██╔╝██╔════╝╚══██╔══╝██╔══██╗██╔════╝██║ ██╔╝ +█████╗ ╚████╔╝ ███████╗ ██║ ███████║██║ █████╔╝ +██╔══╝ ╚██╔╝ ╚════██║ ██║ ██╔══██║██║ ██╔═██╗ +██║ ██║ ███████║ ██║ ██║ ██║╚██████╗██║ ██╗ +╚═╝ ╚═╝ ╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝ diff --git a/internal/app/node_setup.go b/internal/app/node_setup.go new file mode 100644 index 0000000..2f3ce37 --- /dev/null +++ b/internal/app/node_setup.go @@ -0,0 +1,434 @@ +package app + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io/fs" + "math/big" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/fystack/fystack-selfhost-scripts/internal/stack" +) + +const defaultMPCIUMCLIImage = "docker.io/fystacklabs/mpcium-cli:0.3.5" + +type nodeSetupOptions struct { + nodes int + threshold int + environment string + encryptKeys bool + overwrite bool + natsURL string + consulAddress string + cliImage string +} + +func defaultNodeSetupOptions(overwrite bool) nodeSetupOptions { + return nodeSetupOptions{ + nodes: 3, + threshold: 2, + environment: "development", + overwrite: overwrite, + natsURL: "nats://nats-server:4222", + consulAddress: "consul:8500", + cliImage: defaultMPCIUMCLIImage, + } +} + +func runNodeSetup(ctx context.Context, deps dependencies, env stack.Environment, opts nodeSetupOptions) error { + if env.Name != "dev" { + return errors.New("node setup is only implemented for --env dev") + } + if opts.nodes <= 0 { + return errors.New("nodes must be greater than zero") + } + if opts.threshold <= 0 || opts.threshold >= opts.nodes { + return fmt.Errorf("MPC threshold (%d) must be greater than zero and less than number of nodes (%d)", opts.threshold, opts.nodes) + } + if opts.environment == "" { + opts.environment = "development" + } + if opts.natsURL == "" { + opts.natsURL = "nats://nats-server:4222" + } + if opts.consulAddress == "" { + opts.consulAddress = "consul:8500" + } + if opts.cliImage == "" { + opts.cliImage = defaultMPCIUMCLIImage + } + for _, path := range env.RequiredConfigFiles { + if err := requireFile(deps.workDir, path); err != nil { + return fmt.Errorf("%w\nrun `fystack setup` or copy the matching .template file first", err) + } + } + + baseDir := filepath.Join(deps.workDir, "dev", "node-configs") + if hasEntries(baseDir) && !opts.overwrite { + fmt.Fprintln(deps.out, "dev node-configs already exist; skipping setup") + return nil + } + if err := deps.runner.LookPath("docker"); err != nil { + return fmt.Errorf("docker is required for mpcium identity generation: %w", err) + } + if err := os.MkdirAll(baseDir, 0755); err != nil { + return err + } + + fmt.Fprintln(deps.out, "[INFO] Pulling mpcium-cli Docker image...") + if out, err := deps.runner.Run(ctx, deps.workDir, "docker", "pull", opts.cliImage); err != nil { + writeMasked(deps.out, out) + return fmt.Errorf("pull mpcium-cli image: %w", err) + } + + if err := cleanNodeSetup(baseDir, opts); err != nil { + return err + } + if err := generatePeers(ctx, deps, baseDir, opts); err != nil { + return err + } + if err := writeClusterConfig(baseDir, opts); err != nil { + return err + } + if err := configureEventInitiator(ctx, deps, baseDir, opts); err != nil { + return err + } + if err := configureIntegritySigner(deps, baseDir, opts); err != nil { + return err + } + if err := createNodeDirectories(baseDir, opts.nodes); err != nil { + return err + } + if err := generateNodeIdentities(ctx, deps, baseDir, opts); err != nil { + return err + } + if err := distributeNodeIdentities(baseDir, opts.nodes); err != nil { + return err + } + if err := fixNodeFilePermissions(baseDir); err != nil { + return err + } + + fmt.Fprintln(deps.out, "[SUCCESS] MPCIUM node configuration generated") + fmt.Fprintf(deps.out, "[INFO] Generated files in %s\n", relPath(deps.workDir, baseDir)) + fmt.Fprintln(deps.out, "[WARNING] Store the BadgerDB password from dev/node-configs/config.yaml securely") + return nil +} + +func cleanNodeSetup(baseDir string, opts nodeSetupOptions) error { + entries, err := os.ReadDir(baseDir) + if err != nil { + return err + } + for _, entry := range entries { + if entry.IsDir() && strings.HasPrefix(entry.Name(), "node") { + if err := os.RemoveAll(filepath.Join(baseDir, entry.Name())); err != nil { + return err + } + } + } + for _, name := range []string{"peers.json", "config.yaml"} { + if err := removeIfExists(filepath.Join(baseDir, name)); err != nil { + return err + } + } + if opts.overwrite { + for _, name := range []string{"event_initiator.identity.json", "event_initiator.key", "event_initiator.key.age", "integrity_signer.key"} { + if err := removeIfExists(filepath.Join(baseDir, name)); err != nil { + return err + } + } + } + return nil +} + +func generatePeers(ctx context.Context, deps dependencies, baseDir string, opts nodeSetupOptions) error { + fmt.Fprintln(deps.out, "[INFO] Generating peer configuration...") + out, err := runMPCIUMCLI(ctx, deps, baseDir, opts.cliImage, "generate-peers", "-n", fmt.Sprint(opts.nodes), "-o", "peers.json") + writeMasked(deps.out, out) + if err != nil { + return err + } + if err := requireFile(baseDir, "peers.json"); err != nil { + return err + } + peers, err := readPeerIDs(filepath.Join(baseDir, "peers.json")) + if err != nil { + return err + } + for _, peer := range peers { + fmt.Fprintf(deps.out, "[INFO] %s: %s\n", peer.name, peer.id) + } + return nil +} + +func writeClusterConfig(baseDir string, opts nodeSetupOptions) error { + password, err := randomPassword(32) + if err != nil { + return err + } + chainCode, err := randomHex(32) + if err != nil { + return err + } + content := fmt.Sprintf(`nats: + url: %s + +consul: + address: %s + +mpc_threshold: %d +environment: %s +badger_password: "%s" +event_initiator_pubkey: "PLACEHOLDER_WILL_BE_UPDATED" +chain_code: "%s" +db_path: "." +backup_enabled: true +backup_period_seconds: 300 +backup_dir: backups +max_concurrent_keygen: 2 +max_concurrent_signing: 10 +session_warm_up_delay_ms: 500 +`, opts.natsURL, opts.consulAddress, opts.threshold, opts.environment, password, chainCode) + return os.WriteFile(filepath.Join(baseDir, "config.yaml"), []byte(content), 0644) +} + +func configureEventInitiator(ctx context.Context, deps dependencies, baseDir string, opts nodeSetupOptions) error { + mainConfig := filepath.Join(deps.workDir, "dev", "config.yaml") + keyPath := filepath.Join(baseDir, "event_initiator.key") + identityPath := filepath.Join(baseDir, "event_initiator.identity.json") + existingKey, _ := yamlStringAt(mainConfig, "mpc", "signer", "local", "pk_raw") + + if !opts.overwrite && existingKey != "" && fileExists(keyPath) && fileExists(identityPath) { + key, err := os.ReadFile(keyPath) + if err != nil { + return err + } + if strings.TrimSpace(string(key)) != existingKey { + return errors.New("event initiator key does not match dev/config.yaml mpc.signer.local.pk_raw; rerun with --force to regenerate") + } + fmt.Fprintln(deps.out, "[INFO] Reusing existing event initiator") + } else { + args := []string{"generate-initiator", "--overwrite"} + if opts.encryptKeys { + args = append(args, "--encrypt") + } + fmt.Fprintln(deps.out, "[INFO] Generating event initiator...") + out, err := runMPCIUMCLI(ctx, deps, baseDir, opts.cliImage, args...) + writeMasked(deps.out, out) + if err != nil { + return err + } + } + + var identity struct { + PublicKey string `json:"public_key"` + } + if err := readJSON(identityPath, &identity); err != nil { + return err + } + if identity.PublicKey == "" { + return errors.New("event initiator identity missing public_key") + } + if err := setYAMLStringAt(filepath.Join(baseDir, "config.yaml"), identity.PublicKey, "event_initiator_pubkey"); err != nil { + return err + } + + if opts.encryptKeys { + return errors.New("encrypted event initiator keys are not supported by the dev config writer yet") + } + privateKey, err := os.ReadFile(keyPath) + if err != nil { + return err + } + return setYAMLStringAt(mainConfig, strings.TrimSpace(string(privateKey)), "mpc", "signer", "local", "pk_raw") +} + +func configureIntegritySigner(deps dependencies, baseDir string, opts nodeSetupOptions) error { + keyPath := filepath.Join(baseDir, "integrity_signer.key") + key := "" + if !opts.overwrite && fileExists(keyPath) { + data, err := os.ReadFile(keyPath) + if err != nil { + return err + } + key = strings.TrimSpace(string(data)) + if len(key) != 64 { + return errors.New("invalid existing integrity_signer.key length; rerun with --force to regenerate") + } + fmt.Fprintln(deps.out, "[INFO] Reusing existing integrity signer key") + } else { + generated, err := randomHex(32) + if err != nil { + return err + } + key = generated + if err := os.WriteFile(keyPath, []byte(key), 0644); err != nil { + return err + } + } + return setYAMLStringAt(filepath.Join(deps.workDir, "dev", "config.yaml"), key, "integrity", "signer", "ed25519", "private_key") +} + +func createNodeDirectories(baseDir string, nodes int) error { + for i := 0; i < nodes; i++ { + nodeDir := filepath.Join(baseDir, fmt.Sprintf("node%d", i)) + if err := os.MkdirAll(filepath.Join(nodeDir, "identity"), 0755); err != nil { + return err + } + if err := copyFile(filepath.Join(baseDir, "config.yaml"), filepath.Join(nodeDir, "config.yaml"), 0644); err != nil { + return err + } + if err := copyFile(filepath.Join(baseDir, "peers.json"), filepath.Join(nodeDir, "peers.json"), 0644); err != nil { + return err + } + } + return nil +} + +func generateNodeIdentities(ctx context.Context, deps dependencies, baseDir string, opts nodeSetupOptions) error { + for i := 0; i < opts.nodes; i++ { + name := fmt.Sprintf("node%d", i) + nodeDir := filepath.Join(baseDir, name) + args := []string{"generate-identity", "--node", name} + if opts.encryptKeys { + args = append(args, "--encrypt") + } + fmt.Fprintf(deps.out, "[INFO] Generating identity for %s...\n", name) + out, err := runMPCIUMCLI(ctx, deps, nodeDir, opts.cliImage, args...) + writeMasked(deps.out, out) + if err != nil { + return err + } + } + return nil +} + +func distributeNodeIdentities(baseDir string, nodes int) error { + for i := 0; i < nodes; i++ { + sourceNode := fmt.Sprintf("node%d", i) + source := filepath.Join(baseDir, sourceNode, "identity", sourceNode+"_identity.json") + if err := requireFile(filepath.Join(baseDir, sourceNode, "identity"), sourceNode+"_identity.json"); err != nil { + return err + } + for j := 0; j < nodes; j++ { + if i == j { + continue + } + target := filepath.Join(baseDir, fmt.Sprintf("node%d", j), "identity", sourceNode+"_identity.json") + if err := copyFile(source, target, 0644); err != nil { + return err + } + } + } + return nil +} + +func fixNodeFilePermissions(baseDir string) error { + return filepath.WalkDir(baseDir, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + return nil + } + return os.Chmod(path, 0644) + }) +} + +func runMPCIUMCLI(ctx context.Context, deps dependencies, workDir, image string, args ...string) ([]byte, error) { + user := fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid()) + dockerArgs := []string{"run", "--rm", "--user", user, "-e", "USER=nonroot", "-v", workDir + ":/data", "-w", "/data", image} + dockerArgs = append(dockerArgs, args...) + return deps.runner.Run(ctx, deps.workDir, "docker", dockerArgs...) +} + +type peerID struct { + name string + id string +} + +func readPeerIDs(path string) ([]peerID, error) { + var peers map[string]string + if err := readJSON(path, &peers); err != nil { + return nil, err + } + out := make([]peerID, 0, len(peers)) + for name, id := range peers { + out = append(out, peerID{name: name, id: id}) + } + sort.Slice(out, func(i, j int) bool { + return out[i].name < out[j].name + }) + return out, nil +} + +func readJSON(path string, target any) error { + data, err := os.ReadFile(path) + if err != nil { + return err + } + return json.Unmarshal(data, target) +} + +func randomPassword(length int) (string, error) { + const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + var buf strings.Builder + buf.Grow(length) + max := big.NewInt(int64(len(alphabet))) + for buf.Len() < length { + n, err := rand.Int(rand.Reader, max) + if err != nil { + return "", err + } + buf.WriteByte(alphabet[n.Int64()]) + } + return buf.String(), nil +} + +func randomHex(bytes int) (string, error) { + data := make([]byte, bytes) + if _, err := rand.Read(data); err != nil { + return "", err + } + return hex.EncodeToString(data), nil +} + +func copyFile(source, target string, mode fs.FileMode) error { + data, err := os.ReadFile(source) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { + return err + } + return os.WriteFile(target, data, mode) +} + +func removeIfExists(path string) error { + err := os.RemoveAll(path) + if err == nil || os.IsNotExist(err) { + return nil + } + return err +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func relPath(root, path string) string { + rel, err := filepath.Rel(root, path) + if err != nil { + return path + } + return rel +} diff --git a/internal/app/root.go b/internal/app/root.go new file mode 100644 index 0000000..a52b12a --- /dev/null +++ b/internal/app/root.go @@ -0,0 +1,1409 @@ +package app + +import ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "slices" + "sort" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/fystack/fystack-selfhost-scripts/internal/compose" + "github.com/fystack/fystack-selfhost-scripts/internal/mask" + "github.com/fystack/fystack-selfhost-scripts/internal/registry" + "github.com/fystack/fystack-selfhost-scripts/internal/semver" + "github.com/fystack/fystack-selfhost-scripts/internal/stack" + "github.com/fystack/fystack-selfhost-scripts/internal/versions" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" +) + +var ( + Version = "dev" + Commit = "unknown" +) + +type dependencies struct { + workDir string + in io.Reader + out io.Writer + errOut io.Writer + runner compose.Runner + tagLister registry.TagLister + versionFile string + composeEnvFile string +} + +type options struct { + env string +} + +func NewRootCommand() *cobra.Command { + cwd, err := os.Getwd() + if err != nil { + cwd = "." + } + return newRootCommand(dependencies{ + workDir: cwd, + in: os.Stdin, + out: os.Stdout, + errOut: os.Stderr, + runner: compose.OSRunner{}, + tagLister: registry.RemoteTagLister{}, + versionFile: "stack.versions.yaml", + composeEnvFile: ".fystack.compose.env", + }) +} + +func newRootCommand(deps dependencies) *cobra.Command { + opts := &options{env: "dev"} + root := &cobra.Command{ + Use: "fystack", + Short: "Manage the Fystack self-host Docker stack", + SilenceUsage: true, + SilenceErrors: true, + } + root.PersistentFlags().StringVar(&opts.env, "env", "dev", "stack environment: dev or prod") + + root.AddCommand( + versionCommand(deps), + doctorCommand(deps, opts), + setupCommand(deps, opts), + initCommand(deps, opts), + resetCommand(deps, opts), + destroyCommand(deps, opts), + deployCommand(deps, opts), + restartCommand(deps, opts), + statusCommand(deps, opts), + logsCommand(deps, opts), + checkUpdatesCommand(deps, opts), + updateCommand(deps, opts), + ) + + return root +} + +func versionCommand(deps dependencies) *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Print CLI version", + Run: func(cmd *cobra.Command, args []string) { + fmt.Fprintf(deps.out, "fystack %s (%s)\n", Version, Commit) + }, + } +} + +func setupCommand(deps dependencies, opts *options) *cobra.Command { + return &cobra.Command{ + Use: "setup", + Short: "Run a guided setup for the self-host stack", + RunE: func(cmd *cobra.Command, args []string) error { + prompt := newPrompter(deps) + prompt.banner() + envName, err := prompt.selectOption("Select environment", []promptOption{ + {Value: "dev", Label: "dev - local Docker stack"}, + {Value: "prod", Label: "prod - production compose stack"}, + }, defaultEnvironmentIndex(opts.env)) + if err != nil { + return err + } + opts.env = envName + + env, err := resolveEnv(deps, opts.env) + if err != nil { + return err + } + if env.Name != "dev" { + return errors.New("guided setup is only implemented for --env dev") + } + + overwriteConfigs := false + if existing, err := existingDevConfigFiles(deps); err != nil { + return err + } else if len(existing) > 0 { + overwriteConfigs, err = prompt.confirm("Overwrite existing dev config files from templates?", false) + if err != nil { + return err + } + } + + copied, skipped, overwritten, err := ensureDevConfigFiles(deps, overwriteConfigs) + if err != nil { + return err + } + for _, path := range copied { + fmt.Fprintf(deps.out, "created %s\n", path) + } + for _, path := range overwritten { + fmt.Fprintf(deps.out, "overwrote %s\n", path) + } + for _, path := range skipped { + fmt.Fprintf(deps.out, "kept existing %s\n", path) + } + + configPath := filepath.Join(deps.workDir, "dev", "config.yaml") + priceProvider, err := prompt.selectOption("Select price provider", []promptOption{ + {Value: "binance", Label: "Binance - no API key required"}, + {Value: "coinmarketcap", Label: "CoinMarketCap - API key required"}, + }, 0) + if err != nil { + return err + } + if priceProvider == "coinmarketcap" { + currentAPIKey, err := yamlStringAt(configPath, "price_providers", "coinmarketcap", "api_key") + if err != nil { + return err + } + question := "CoinMarketCap API key" + if currentAPIKey != "" { + question = "CoinMarketCap API key (leave empty to keep current value)" + } + apiKey, err := prompt.input(question, currentAPIKey == "") + if err != nil { + return err + } + if apiKey != "" { + if err := setYAMLStringAt(configPath, apiKey, "price_providers", "coinmarketcap", "api_key"); err != nil { + return err + } + fmt.Fprintln(deps.out, "updated dev/config.yaml") + } + } else { + fmt.Fprintln(deps.out, "using Binance price provider; no API key needed") + } + + generateNodes, err := prompt.confirm("Generate MPC node configs now?", true) + if err != nil { + return err + } + if generateNodes { + overwriteNodes := false + if hasEntries(filepath.Join(deps.workDir, "dev", "node-configs")) { + overwriteNodes, err = prompt.confirm("Overwrite existing MPC node configs?", false) + if err != nil { + return err + } + } + if err := runInit(cmd.Context(), deps, env, overwriteNodes); err != nil { + return err + } + } + + deployNow, err := prompt.confirm("Deploy the dev stack now?", false) + if err != nil { + return err + } + if deployNow { + if err := runDeploy(cmd.Context(), deps, env, opts.env); err != nil { + return err + } + } + + fmt.Fprintln(deps.out, "setup complete") + return nil + }, + } +} + +func doctorCommand(deps dependencies, opts *options) *cobra.Command { + return &cobra.Command{ + Use: "doctor", + Short: "Check local prerequisites and stack files", + RunE: func(cmd *cobra.Command, args []string) error { + env, err := resolveEnv(deps, opts.env) + if err != nil { + return err + } + + checks := []string{ + deps.versionFile, + env.ComposeFile, + } + for _, path := range checks { + if err := requireFile(deps.workDir, path); err != nil { + return err + } + } + if env.Name == "dev" { + for _, path := range env.RequiredConfigFiles { + if err := requireFile(deps.workDir, path); err != nil { + return fmt.Errorf("%w\nrun `fystack setup` or copy the matching .template file first", err) + } + } + } + if err := deps.runner.LookPath("docker"); err != nil { + return fmt.Errorf("docker is required: %w", err) + } + if _, err := deps.runner.Run(cmd.Context(), deps.workDir, "docker", "compose", "version"); err != nil { + return fmt.Errorf("docker compose is required: %w", err) + } + fmt.Fprintf(deps.out, "doctor ok for %s\n", env.Name) + return nil + }, + } +} + +func initCommand(deps dependencies, opts *options) *cobra.Command { + var force bool + cmd := &cobra.Command{ + Use: "init", + Short: "Generate MPC node configs for the dev stack", + RunE: func(cmd *cobra.Command, args []string) error { + env, err := resolveEnv(deps, opts.env) + if err != nil { + return err + } + if env.Name != "dev" { + return errors.New("init is only implemented for --env dev") + } + return runInit(cmd.Context(), deps, env, force) + }, + } + cmd.Flags().BoolVar(&force, "force", false, "overwrite existing dev node configs") + return cmd +} + +func resetCommand(deps dependencies, _ *options) *cobra.Command { + var force bool + cmd := &cobra.Command{ + Use: "reset", + Short: "Remove generated files to restore the repository to a clean state", + Long: `Removes files generated by setup/init so the working tree matches +a fresh clone. Specifically removes: + dev/config.yaml + dev/config.rescanner.yaml + dev/config.indexer.yaml + dev/node-configs/ + .fystack.compose.env + +Templates and source files are never touched.`, + RunE: func(cmd *cobra.Command, args []string) error { + if !force { + prompt := newPrompter(deps) + ok, err := prompt.confirm("Remove all generated dev config and node files?", false) + if err != nil { + return err + } + if !ok { + fmt.Fprintln(deps.out, "reset canceled") + return nil + } + } + return runReset(deps) + }, + } + cmd.Flags().BoolVar(&force, "force", false, "skip confirmation prompt") + return cmd +} + +func runReset(deps dependencies) error { + targets := []string{ + filepath.Join(deps.workDir, "dev", "config.yaml"), + filepath.Join(deps.workDir, "dev", "config.rescanner.yaml"), + filepath.Join(deps.workDir, "dev", "config.indexer.yaml"), + filepath.Join(deps.workDir, "dev", "node-configs"), + filepath.Join(deps.workDir, deps.composeEnvFile), + } + for _, path := range targets { + if err := removeIfExists(path); err != nil { + return fmt.Errorf("remove %s: %w", path, err) + } + rel, _ := filepath.Rel(deps.workDir, path) + fmt.Fprintf(deps.out, "removed %s\n", rel) + } + fmt.Fprintln(deps.out, "reset complete") + return nil +} + +func destroyCommand(deps dependencies, opts *options) *cobra.Command { + var force bool + cmd := &cobra.Command{ + Use: "destroy", + Short: "Stop all services, remove containers, networks, and volumes", + Long: "Permanently tears down the entire stack. All data is lost.", + RunE: func(cmd *cobra.Command, args []string) error { + env, err := resolveEnv(deps, opts.env) + if err != nil { + return err + } + + fmt.Fprintln(deps.out, "Destroy plan:") + fmt.Fprintf(deps.out, " environment : %s\n", env.Name) + fmt.Fprintf(deps.out, " compose file: %s\n", env.ComposeFile) + fmt.Fprintln(deps.out, "") + fmt.Fprintln(deps.out, " The following will be permanently destroyed:") + fmt.Fprintln(deps.out, " - All running containers for this environment") + fmt.Fprintln(deps.out, " - All Docker networks created by Compose") + fmt.Fprintln(deps.out, " - All Docker volumes (database data, MPC key shares, etc.)") + if env.Name == "dev" { + fmt.Fprintln(deps.out, " - dev/config.yaml") + fmt.Fprintln(deps.out, " - dev/config.rescanner.yaml") + fmt.Fprintln(deps.out, " - dev/config.indexer.yaml") + fmt.Fprintln(deps.out, " - dev/node-configs/") + fmt.Fprintln(deps.out, " - .fystack.compose.env") + } + fmt.Fprintln(deps.out, "") + fmt.Fprintln(deps.out, " This cannot be undone.") + + if !force { + prompt := newPrompter(deps) + ok, err := prompt.confirm("Do you really want to destroy everything?", false) + if err != nil { + return err + } + if !ok { + fmt.Fprintln(deps.out, "destroy canceled") + return nil + } + } + + if err := writeComposeEnv(deps, opts.env); err != nil { + // non-fatal — compose can still run without a pinned env file + fmt.Fprintf(deps.errOut, "warning: could not write compose env: %v\n", err) + } + fmt.Fprintln(deps.out, "Destroying stack...") + out, err := runCompose(cmd.Context(), deps, env, "down", "--volumes", "--remove-orphans") + writeMasked(deps.out, out) + if err != nil { + return fmt.Errorf("docker compose down: %w", err) + } + + if env.Name == "dev" { + if err := runReset(deps); err != nil { + return err + } + } + + fmt.Fprintln(deps.out, "Destroy complete.") + return nil + }, + } + cmd.Flags().BoolVar(&force, "force", false, "skip confirmation prompt") + return cmd +} + +func deployCommand(deps dependencies, opts *options) *cobra.Command { + return &cobra.Command{ + Use: "deploy", + Short: "Deploy the selected Docker Compose stack", + RunE: func(cmd *cobra.Command, args []string) error { + env, err := resolveEnv(deps, opts.env) + if err != nil { + return err + } + return runDeploy(cmd.Context(), deps, env, opts.env) + }, + } +} + +func restartCommand(deps dependencies, opts *options) *cobra.Command { + return &cobra.Command{ + Use: "restart [service...]", + Short: "Restart selected Docker Compose services", + Args: cobra.ArbitraryArgs, + RunE: func(cmd *cobra.Command, args []string) error { + env, err := resolveEnv(deps, opts.env) + if err != nil { + return err + } + if err := writeComposeEnv(deps, opts.env); err != nil { + return err + } + + services := args + if len(services) == 0 { + available, err := composeServices(filepath.Join(deps.workDir, env.ComposeFile)) + if err != nil { + return err + } + if len(available) == 0 { + return errors.New("no compose services found") + } + prompt := newPrompter(deps) + services, err = prompt.selectValues("Select services to restart", available) + if err != nil { + return err + } + if len(services) == 0 { + fmt.Fprintln(deps.out, "no services selected") + return nil + } + } + + out, err := runCompose(cmd.Context(), deps, env, append([]string{"restart"}, services...)...) + writeMasked(deps.out, out) + return err + }, + } +} + +func statusCommand(deps dependencies, opts *options) *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "Show Docker Compose service status", + RunE: func(cmd *cobra.Command, args []string) error { + env, err := resolveEnv(deps, opts.env) + if err != nil { + return err + } + if err := writeComposeEnv(deps, opts.env); err != nil { + return err + } + out, err := runCompose(cmd.Context(), deps, env, "ps") + writeMasked(deps.out, out) + return err + }, + } +} + +func logsCommand(deps dependencies, opts *options) *cobra.Command { + var tail string + cmd := &cobra.Command{ + Use: "logs [service]", + Short: "Show Docker Compose logs", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + env, err := resolveEnv(deps, opts.env) + if err != nil { + return err + } + if err := writeComposeEnv(deps, opts.env); err != nil { + return err + } + composeArgs := []string{"logs", "--tail", tail} + if len(args) == 1 { + composeArgs = append(composeArgs, args[0]) + } + out, err := runCompose(cmd.Context(), deps, env, composeArgs...) + writeMasked(deps.out, out) + return err + }, + } + cmd.Flags().StringVar(&tail, "tail", "200", "number of log lines to print") + return cmd +} + +func checkUpdatesCommand(deps dependencies, opts *options) *cobra.Command { + return &cobra.Command{ + Use: "check-updates", + Short: "Check app Docker image tags for newer semver releases", + RunE: func(cmd *cobra.Command, args []string) error { + env, err := resolveEnv(deps, opts.env) + if err != nil { + return err + } + results, err := findImageUpdates(cmd.Context(), deps, env.Name) + if err != nil { + return err + } + results = appImageUpdates(results) + if len(results) == 0 { + fmt.Fprintf(deps.out, "no services configured for %s\n", env.Name) + return nil + } + for _, result := range results { + if result.Err != nil { + fmt.Fprintf(deps.out, "%s: update check failed: %v\n", result.Service.Name, result.Err) + continue + } + if result.Skipped { + fmt.Fprintf(deps.out, "%s: skipped non-semver tag %q\n", result.Service.Name, result.Current) + continue + } + if !result.Available { + fmt.Fprintf(deps.out, "%s: current %s\n", result.Service.Name, result.Current) + continue + } + fmt.Fprintf(deps.out, "%s: update available %s -> %s\n", result.Service.Name, result.Current, result.Latest) + } + return nil + }, + } +} + +func updateCommand(deps dependencies, opts *options) *cobra.Command { + var all bool + var deploy bool + cmd := &cobra.Command{ + Use: "update [service...]", + Short: "Update pinned app Docker image versions", + Args: cobra.ArbitraryArgs, + RunE: func(cmd *cobra.Command, args []string) error { + env, err := resolveEnv(deps, opts.env) + if err != nil { + return err + } + results, err := findImageUpdates(cmd.Context(), deps, env.Name) + if err != nil { + return err + } + available := availableUpdates(results) + if len(available) == 0 { + fmt.Fprintln(deps.out, "no semver updates available") + return nil + } + + selected, prompted, err := selectUpdates(deps, available, all, args) + if err != nil { + return err + } + if len(selected) == 0 { + fmt.Fprintln(deps.out, "no updates selected") + return nil + } + + updates := make(map[string]string, len(selected)) + for _, update := range selected { + updates[update.Service.Name] = update.LatestImage + } + if err := versions.UpdateImages(filepath.Join(deps.workDir, deps.versionFile), updates); err != nil { + return err + } + for _, update := range selected { + fmt.Fprintf(deps.out, "updated %s: %s -> %s\n", update.Service.Name, update.Current, update.Latest) + } + + if !deploy && prompted { + prompt := newPrompter(deps) + deploy, err = prompt.confirm("Deploy updated images now?", false) + if err != nil { + return err + } + } + if deploy { + return runDeployUpdates(cmd.Context(), deps, env, opts.env, selected) + } + fmt.Fprintln(deps.out, "run `fystack deploy` when you are ready to apply the new pins") + return nil + }, + } + cmd.Flags().BoolVar(&all, "all", false, "update all services with semver updates") + cmd.Flags().BoolVar(&deploy, "deploy", false, "deploy after updating version pins") + return cmd +} + +type imageUpdate struct { + Service versions.Service + Current string + Latest string + LatestImage string + Available bool + Skipped bool + Err error +} + +func findImageUpdates(ctx context.Context, deps dependencies, envName string) ([]imageUpdate, error) { + defs, err := loadVersions(deps) + if err != nil { + return nil, err + } + services := defs.ForEnvironment(envName) + results := make([]imageUpdate, 0, len(services)) + for _, svc := range services { + current, ok := registry.ImageTag(svc.Image) + result := imageUpdate{Service: svc, Current: current} + if !ok || !semver.Valid(current) { + result.Skipped = true + results = append(results, result) + continue + } + tags, err := deps.tagLister.Tags(ctx, svc.Image) + if err != nil { + result.Err = err + results = append(results, result) + continue + } + latest := semver.Latest(tags) + result.Latest = latest + if latest != "" && semver.Compare(latest, current) > 0 { + result.Available = true + result.LatestImage = replaceImageTag(svc.Image, latest) + } + results = append(results, result) + } + return results, nil +} + +func availableUpdates(results []imageUpdate) []imageUpdate { + available := make([]imageUpdate, 0, len(results)) + for _, result := range appImageUpdates(results) { + if result.Available { + available = append(available, result) + } + } + return available +} + +func appImageUpdates(results []imageUpdate) []imageUpdate { + apps := make([]imageUpdate, 0, len(results)) + for _, result := range results { + if !isInfrastructureService(result.Service.Name) { + apps = append(apps, result) + } + } + return apps +} + +func isInfrastructureService(name string) bool { + switch name { + case "mongo-dev", "mongo-prod", "postgres", "redis", "nats-dev", "nats-prod", "consul": + return true + default: + return false + } +} + +func selectUpdates(deps dependencies, available []imageUpdate, all bool, args []string) ([]imageUpdate, bool, error) { + if all { + return available, false, nil + } + if len(args) > 0 { + byName := make(map[string]imageUpdate, len(available)) + for _, update := range available { + byName[update.Service.Name] = update + } + selected := make([]imageUpdate, 0, len(args)) + for _, name := range args { + update, ok := byName[name] + if !ok { + return nil, false, fmt.Errorf("no semver update available for %s", name) + } + selected = append(selected, update) + } + return selected, false, nil + } + prompt := newPrompter(deps) + selected, err := prompt.selectUpdates("Select app updates to apply", available) + return selected, true, err +} + +func replaceImageTag(image, tag string) string { + idx := strings.LastIndex(image, ":") + if idx < 0 || idx == len(image)-1 { + return image + ":" + tag + } + if slash := strings.LastIndex(image, "/"); slash > idx { + return image + ":" + tag + } + return image[:idx+1] + tag +} + +func runInit(ctx context.Context, deps dependencies, env stack.Environment, force bool) error { + opts := defaultNodeSetupOptions(force) + return runNodeSetup(ctx, deps, env, opts) +} + +func runDeploy(ctx context.Context, deps dependencies, env stack.Environment, envName string) error { + if err := writeComposeEnv(deps, envName); err != nil { + return err + } + if env.Name == "dev" { + out, err := runCompose(ctx, deps, env, append([]string{"up", "-d"}, env.DevInfrastructureServices...)...) + writeMasked(deps.out, out) + if err != nil { + return err + } + mpcServices := discoverMPCServices(filepath.Join(deps.workDir, "dev", "node-configs")) + if len(mpcServices) == 0 { + return errors.New("no MPCIUM node configs found; run `fystack init --env dev` first") + } + out, err = runCompose(ctx, deps, env, append([]string{"up", "-d"}, mpcServices...)...) + writeMasked(deps.out, out) + return err + } + out, err := runCompose(ctx, deps, env, "up", "-d") + writeMasked(deps.out, out) + return err +} + +func runDeployUpdates(ctx context.Context, deps dependencies, env stack.Environment, envName string, updates []imageUpdate) error { + if err := writeComposeEnv(deps, envName); err != nil { + return err + } + services, err := composeServicesForUpdates(deps, env, updates) + if err != nil { + return err + } + if len(services) == 0 { + return errors.New("no compose services found for selected updates") + } + fmt.Fprintf(deps.out, "deploying updated services: %s\n", strings.Join(services, ", ")) + out, err := runCompose(ctx, deps, env, append([]string{"up", "-d"}, services...)...) + writeMasked(deps.out, out) + return err +} + +func composeServicesForUpdates(deps dependencies, env stack.Environment, updates []imageUpdate) ([]string, error) { + available, err := composeServices(filepath.Join(deps.workDir, env.ComposeFile)) + if err != nil { + return nil, err + } + availableSet := make(map[string]bool, len(available)) + for _, service := range available { + availableSet[service] = true + } + + selectedSet := make(map[string]bool, len(updates)) + for _, update := range updates { + name := update.Service.Name + if name == "apex-migrate" { + name = "migrate" + } + if name == "mpcium" { + for _, service := range discoverMPCServices(filepath.Join(deps.workDir, "dev", "node-configs")) { + if availableSet[service] { + selectedSet[service] = true + } + } + for _, service := range available { + if strings.HasPrefix(service, "mpcium") { + selectedSet[service] = true + } + } + continue + } + if availableSet[name] { + selectedSet[name] = true + } + } + + services := make([]string, 0, len(selectedSet)) + for service := range selectedSet { + services = append(services, service) + } + sort.Slice(services, func(i, j int) bool { + return semver.NaturalLess(services[i], services[j]) + }) + return services, nil +} + +func resolveEnv(deps dependencies, name string) (stack.Environment, error) { + env, err := stack.Resolve(name) + if err != nil { + return stack.Environment{}, err + } + if err := requireFile(deps.workDir, env.ComposeFile); err != nil { + return stack.Environment{}, err + } + return env, nil +} + +func runCompose(ctx context.Context, deps dependencies, env stack.Environment, args ...string) ([]byte, error) { + envFile := filepath.Join(deps.workDir, deps.composeEnvFile) + composeArgs := append([]string{"compose", "--env-file", envFile, "-f", "docker-compose.yaml"}, args...) + return deps.runner.Run(ctx, filepath.Join(deps.workDir, env.WorkDir), "docker", composeArgs...) +} + +func writeComposeEnv(deps dependencies, envName string) error { + defs, err := loadVersions(deps) + if err != nil { + return err + } + content := defs.EnvFile(envName) + path := filepath.Join(deps.workDir, deps.composeEnvFile) + return os.WriteFile(path, []byte(content), 0644) +} + +func loadVersions(deps dependencies) (versions.Definitions, error) { + return versions.Load(filepath.Join(deps.workDir, deps.versionFile)) +} + +func composeServices(path string) ([]string, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var doc yaml.Node + if err := yaml.Unmarshal(data, &doc); err != nil { + return nil, err + } + root := yamlNodeAt(&doc) + servicesNode := yamlNodeAt(root, "services") + if servicesNode == nil || servicesNode.Kind != yaml.MappingNode { + return nil, nil + } + services := make([]string, 0, len(servicesNode.Content)/2) + for i := 0; i+1 < len(servicesNode.Content); i += 2 { + services = append(services, servicesNode.Content[i].Value) + } + sort.Strings(services) + return services, nil +} + +func requireFile(root, rel string) error { + path := filepath.Join(root, rel) + info, err := os.Stat(path) + if err != nil { + return fmt.Errorf("required file missing: %s", rel) + } + if info.IsDir() { + return fmt.Errorf("required file is a directory: %s", rel) + } + return nil +} + +func hasEntries(path string) bool { + entries, err := os.ReadDir(path) + return err == nil && len(entries) > 0 +} + +func discoverMPCServices(nodeConfigDir string) []string { + entries, err := os.ReadDir(nodeConfigDir) + if err != nil { + return nil + } + services := make([]string, 0, len(entries)) + for _, entry := range entries { + if !entry.IsDir() || !strings.HasPrefix(entry.Name(), "node") { + continue + } + index := strings.TrimPrefix(entry.Name(), "node") + if index == "" { + continue + } + services = append(services, "mpcium"+index) + } + sort.Slice(services, func(i, j int) bool { + return semver.NaturalLess(strings.TrimPrefix(services[i], "mpcium"), strings.TrimPrefix(services[j], "mpcium")) + }) + return services +} + +func writeMasked(w io.Writer, out []byte) { + if len(out) == 0 { + return + } + fmt.Fprint(w, mask.Sensitive(string(out))) +} + +type promptOption struct { + Value string + Label string +} + +type prompter struct { + deps dependencies + reader *bufio.Reader + colors bool +} + +func newPrompter(deps dependencies) *prompter { + in := deps.in + if in == nil { + in = os.Stdin + } + deps.in = in + outFile, _ := deps.out.(*os.File) + colors := outFile != nil && isInteractive(outFile) && os.Getenv("NO_COLOR") == "" + return &prompter{deps: deps, reader: bufio.NewReader(in), colors: colors} +} + +func (p *prompter) banner() { + lines := strings.Split(strings.TrimRight(setupBanner, "\n"), "\n") + brand := setupBanner + if p.colors { + styled := make([]string, 0, len(lines)) + for i, line := range lines { + switch { + case i < 2: + styled = append(styled, bannerTopStyle.Render(line)) + case i < 4: + styled = append(styled, bannerMidStyle.Render(line)) + case i == 4: + styled = append(styled, bannerBottomStyle.Render(line)) + case i == 5: + styled = append(styled, bannerShadowStyle.Render(line)) + default: + styled = append(styled, bannerCaptionStyle.Render(line)) + } + } + brand = strings.Join(styled, "\n") + } + fmt.Fprintf(p.deps.out, "%s\n\n", brand) +} + +func (p *prompter) confirm(question string, defaultYes bool) (bool, error) { + options := []promptOption{ + {Value: "no", Label: "no"}, + {Value: "yes", Label: "yes"}, + } + if defaultYes { + options = []promptOption{ + {Value: "yes", Label: "yes"}, + {Value: "no", Label: "no"}, + } + } + value, err := p.selectOption(question, options, 0) + if err != nil { + return false, err + } + return value == "yes", nil +} + +func (p *prompter) selectOption(question string, options []promptOption, selected int) (string, error) { + if len(options) == 0 { + return "", errors.New("prompt has no options") + } + if selected < 0 || selected >= len(options) { + selected = 0 + } + if input, ok := p.deps.in.(*os.File); ok && isInteractive(input) { + return p.selectOptionInteractive(input, question, options, selected) + } + + fmt.Fprintln(p.deps.out, p.paint(questionStyle, question)) + for i, option := range options { + fmt.Fprintf(p.deps.out, " %d. %s\n", i+1, option.Label) + } + fmt.Fprintf(p.deps.out, "%s [%s]: ", p.paint(accentStyle, "Choose"), options[selected].Value) + line, err := p.reader.ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return "", err + } + answer := strings.TrimSpace(strings.ToLower(line)) + if answer == "" { + return options[selected].Value, nil + } + for i, option := range options { + if answer == option.Value || answer == strings.ToLower(option.Label) || answer == fmt.Sprint(i+1) { + return option.Value, nil + } + } + return "", fmt.Errorf("invalid choice %q", strings.TrimSpace(line)) +} + +func (p *prompter) selectOptionInteractive(input *os.File, question string, options []promptOption, selected int) (string, error) { + model := selectModel{ + question: question, + options: options, + selected: selected, + colors: p.colors, + } + program := tea.NewProgram(model, tea.WithInput(input), tea.WithOutput(p.deps.out)) + result, err := program.Run() + if err != nil { + return "", err + } + final, ok := result.(selectModel) + if !ok { + return "", errors.New("unexpected prompt model result") + } + if final.canceled { + return "", errors.New("setup canceled") + } + return options[final.selected].Value, nil +} + +func (p *prompter) selectUpdates(question string, updates []imageUpdate) ([]imageUpdate, error) { + options := make([]multiSelectOption, 0, len(updates)) + for _, update := range updates { + options = append(options, multiSelectOption{ + Value: update.Service.Name, + Label: fmt.Sprintf("%s %s -> %s", update.Service.Name, update.Current, update.Latest), + }) + } + selected, err := p.selectMulti(question, options, "all, none, or numbers") + if err != nil { + return nil, err + } + byName := make(map[string]imageUpdate, len(updates)) + for _, update := range updates { + byName[update.Service.Name] = update + } + out := make([]imageUpdate, 0, len(selected)) + for _, value := range selected { + out = append(out, byName[value]) + } + return out, nil +} + +func (p *prompter) selectValues(question string, values []string) ([]string, error) { + options := make([]multiSelectOption, 0, len(values)) + for _, value := range values { + options = append(options, multiSelectOption{Value: value, Label: value}) + } + return p.selectMulti(question, options, "all, none, or numbers") +} + +func (p *prompter) selectMulti(question string, options []multiSelectOption, promptHint string) ([]string, error) { + if input, ok := p.deps.in.(*os.File); ok && isInteractive(input) { + return p.selectMultiInteractive(input, question, options) + } + + fmt.Fprintln(p.deps.out, p.paint(questionStyle, question)) + for i, option := range options { + fmt.Fprintf(p.deps.out, " %d. %s\n", i+1, option.Label) + } + fmt.Fprintf(p.deps.out, "%s [%s]: ", p.paint(accentStyle, "Choose"), promptHint) + line, err := p.reader.ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return nil, err + } + answer := strings.TrimSpace(strings.ToLower(line)) + if answer == "" || answer == "none" { + return nil, nil + } + if answer == "all" { + selected := make([]string, 0, len(options)) + for _, option := range options { + selected = append(selected, option.Value) + } + return selected, nil + } + fields := strings.FieldsFunc(answer, func(r rune) bool { + return r == ',' || r == ' ' + }) + selected := make([]string, 0, len(fields)) + for _, field := range fields { + var index int + if _, err := fmt.Sscanf(field, "%d", &index); err != nil || index < 1 || index > len(options) { + return nil, fmt.Errorf("invalid selection %q", field) + } + selected = append(selected, options[index-1].Value) + } + return selected, nil +} + +func (p *prompter) selectMultiInteractive(input *os.File, question string, options []multiSelectOption) ([]string, error) { + model := multiSelectModel{ + question: question, + options: options, + checked: make(map[int]bool, len(options)), + colors: p.colors, + } + program := tea.NewProgram(model, tea.WithInput(input), tea.WithOutput(p.deps.out)) + result, err := program.Run() + if err != nil { + return nil, err + } + final, ok := result.(multiSelectModel) + if !ok { + return nil, errors.New("unexpected prompt model result") + } + if final.canceled { + return nil, errors.New("selection canceled") + } + selected := make([]string, 0, len(final.checked)) + for i, option := range options { + if final.checked[i] { + selected = append(selected, option.Value) + } + } + return selected, nil +} + +type selectModel struct { + question string + options []promptOption + selected int + colors bool + canceled bool +} + +func (m selectModel) Init() tea.Cmd { + return nil +} + +func (m selectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + key, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + switch key.String() { + case "ctrl+c", "esc": + m.canceled = true + return m, tea.Quit + case "up", "k": + m.selected = (m.selected + len(m.options) - 1) % len(m.options) + case "down", "j": + m.selected = (m.selected + 1) % len(m.options) + case "enter", " ": + return m, tea.Quit + } + return m, nil +} + +func (m selectModel) View() string { + var buf strings.Builder + buf.WriteString(renderMaybe(m.colors, questionStyle, m.question)) + buf.WriteString("\n") + for i, option := range m.options { + prefix := " " + label := renderMaybe(m.colors, dimStyle, option.Label) + if i == m.selected { + prefix = renderMaybe(m.colors, selectedStyle, "> ") + label = renderMaybe(m.colors, selectedStyle, option.Label) + } + buf.WriteString(prefix) + buf.WriteString(label) + buf.WriteString("\n") + } + buf.WriteString(renderMaybe(m.colors, dimStyle, "Use ↑/↓, j/k, enter to select. Esc cancels.")) + buf.WriteString("\n") + return buf.String() +} + +type multiSelectModel struct { + question string + options []multiSelectOption + selected int + checked map[int]bool + colors bool + canceled bool +} + +type multiSelectOption struct { + Value string + Label string +} + +func (m multiSelectModel) Init() tea.Cmd { + return nil +} + +func (m multiSelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + key, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + switch key.String() { + case "ctrl+c", "esc": + m.canceled = true + return m, tea.Quit + case "up", "k": + m.selected = (m.selected + len(m.options) - 1) % len(m.options) + case "down", "j": + m.selected = (m.selected + 1) % len(m.options) + case " ": + m.checked[m.selected] = !m.checked[m.selected] + case "a": + allChecked := len(m.checked) == len(m.options) + for i := range m.options { + m.checked[i] = !allChecked + } + case "enter": + return m, tea.Quit + } + return m, nil +} + +func (m multiSelectModel) View() string { + var buf strings.Builder + buf.WriteString(renderMaybe(m.colors, questionStyle, m.question)) + buf.WriteString("\n") + for i, option := range m.options { + prefix := " " + check := "[ ]" + if m.checked[i] { + check = "[x]" + } + label := fmt.Sprintf("%s %s", check, option.Label) + label = renderMaybe(m.colors, dimStyle, label) + if i == m.selected { + prefix = renderMaybe(m.colors, selectedStyle, "> ") + label = renderMaybe(m.colors, selectedStyle, label) + } + buf.WriteString(prefix) + buf.WriteString(label) + buf.WriteString("\n") + } + buf.WriteString(renderMaybe(m.colors, dimStyle, "Space toggles, a toggles all, enter applies. Esc cancels.")) + buf.WriteString("\n") + return buf.String() +} + +func (p *prompter) input(question string, required bool) (string, error) { + for { + fmt.Fprintf(p.deps.out, "%s: ", p.paint(questionStyle, question)) + line, err := p.reader.ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return "", err + } + value := strings.TrimSpace(line) + if value != "" || !required { + return value, nil + } + fmt.Fprintln(p.deps.out, p.paint(warningStyle, "value is required")) + } +} + +var ( + bannerTopStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#7EA0FF")) + bannerMidStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4F7DEF")) + bannerBottomStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#315EDC")) + bannerShadowStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#2447A8")) + bannerCaptionStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4F7DEF")) + questionStyle = lipgloss.NewStyle().Bold(true) + accentStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#4F7DEF")) + dimStyle = lipgloss.NewStyle().Faint(true) + selectedStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4F7DEF")) + warningStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("220")) +) + +func (p *prompter) paint(style lipgloss.Style, text string) string { + if !p.colors { + return text + } + return style.Render(text) +} + +func renderMaybe(colors bool, style lipgloss.Style, text string) string { + if !colors { + return text + } + return style.Render(text) +} + +func isInteractive(input *os.File) bool { + info, err := input.Stat() + return err == nil && info.Mode()&os.ModeCharDevice != 0 +} + +func defaultEnvironmentIndex(name string) int { + if name == "prod" { + return 1 + } + return 0 +} + +type devConfigFile struct { + Template string + Target string +} + +var devConfigFiles = []devConfigFile{ + {Template: "dev/config.yaml.template", Target: "dev/config.yaml"}, + {Template: "dev/config.rescanner.yaml.template", Target: "dev/config.rescanner.yaml"}, + {Template: "dev/config.indexer.yaml.template", Target: "dev/config.indexer.yaml"}, +} + +func existingDevConfigFiles(deps dependencies) ([]string, error) { + var existing []string + for _, file := range devConfigFiles { + targetPath := filepath.Join(deps.workDir, file.Target) + if _, err := os.Stat(targetPath); err == nil { + existing = append(existing, file.Target) + } else if !os.IsNotExist(err) { + return nil, err + } + } + return existing, nil +} + +func ensureDevConfigFiles(deps dependencies, overwrite bool) ([]string, []string, []string, error) { + var copied []string + var skipped []string + var overwritten []string + for _, file := range devConfigFiles { + targetPath := filepath.Join(deps.workDir, file.Target) + if _, err := os.Stat(targetPath); err == nil { + if !overwrite { + skipped = append(skipped, file.Target) + continue + } + overwritten = append(overwritten, file.Target) + } else if !os.IsNotExist(err) { + return nil, nil, nil, err + } + + templatePath := filepath.Join(deps.workDir, file.Template) + data, err := os.ReadFile(templatePath) + if err != nil { + return nil, nil, nil, fmt.Errorf("read %s: %w", file.Template, err) + } + if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil { + return nil, nil, nil, err + } + if err := os.WriteFile(targetPath, data, 0644); err != nil { + return nil, nil, nil, err + } + if !overwrite || !slices.Contains(overwritten, file.Target) { + copied = append(copied, file.Target) + } + } + return copied, skipped, overwritten, nil +} + + +func yamlStringAt(path string, keys ...string) (string, error) { + node, err := loadYAMLDocument(path) + if err != nil { + return "", err + } + value := yamlNodeAt(node, keys...) + if value == nil { + return "", fmt.Errorf("missing yaml value %s in %s", strings.Join(keys, "."), path) + } + return value.Value, nil +} + +func setYAMLStringAt(path, value string, keys ...string) error { + node, err := loadYAMLDocument(path) + if err != nil { + return err + } + target := yamlNodeAt(node, keys...) + if target == nil { + return fmt.Errorf("missing yaml value %s in %s", strings.Join(keys, "."), path) + } + target.Kind = yaml.ScalarNode + target.Tag = "!!str" + target.Value = value + + var buf bytes.Buffer + encoder := yaml.NewEncoder(&buf) + encoder.SetIndent(2) + if err := encoder.Encode(node); err != nil { + _ = encoder.Close() + return err + } + if err := encoder.Close(); err != nil { + return err + } + return os.WriteFile(path, buf.Bytes(), 0644) +} + +func loadYAMLDocument(path string) (*yaml.Node, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var node yaml.Node + if err := yaml.Unmarshal(data, &node); err != nil { + return nil, err + } + return &node, nil +} + +func yamlNodeAt(node *yaml.Node, keys ...string) *yaml.Node { + if node == nil { + return nil + } + if node.Kind == yaml.DocumentNode && len(node.Content) > 0 { + node = node.Content[0] + } + for _, key := range keys { + if node.Kind != yaml.MappingNode { + return nil + } + var next *yaml.Node + for i := 0; i+1 < len(node.Content); i += 2 { + if node.Content[i].Value == key { + next = node.Content[i+1] + break + } + } + if next == nil { + return nil + } + node = next + } + return node +} diff --git a/internal/app/root_test.go b/internal/app/root_test.go new file mode 100644 index 0000000..98b7b6a --- /dev/null +++ b/internal/app/root_test.go @@ -0,0 +1,872 @@ +package app + +import ( + "bytes" + "context" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +type fakeRunner struct { + output []byte + calls []runnerCall +} + +type runnerCall struct { + dir string + name string + args []string +} + +func (f *fakeRunner) Run(ctx context.Context, dir, name string, args ...string) ([]byte, error) { + f.calls = append(f.calls, runnerCall{dir: dir, name: name, args: append([]string(nil), args...)}) + return f.output, nil +} + +func (f *fakeRunner) LookPath(name string) error { + return nil +} + +type fakeTags map[string][]string + +func (f fakeTags) Tags(ctx context.Context, image string) ([]string, error) { + return f[image], nil +} + +func TestStatusWritesEnvAndRunsCompose(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, "stack.versions.yaml"), `services: + apex: + env: FYSTACK_APEX_IMAGE + image: docker.io/fystacklabs/apex:1.0.54 + environments: [dev] +`) + mustWrite(t, filepath.Join(root, "dev", "docker-compose.yaml"), "services: {}\n") + + var out bytes.Buffer + runner := &fakeRunner{} + cmd := newRootCommand(dependencies{ + workDir: root, + out: &out, + errOut: &out, + runner: runner, + tagLister: fakeTags{}, + versionFile: "stack.versions.yaml", + composeEnvFile: ".fystack.compose.env", + }) + cmd.SetArgs([]string{"--env", "dev", "status"}) + + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + envFile, err := os.ReadFile(filepath.Join(root, ".fystack.compose.env")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(envFile), "FYSTACK_APEX_IMAGE=docker.io/fystacklabs/apex:1.0.54") { + t.Fatalf("env file did not contain apex image:\n%s", envFile) + } + if len(runner.calls) != 1 { + t.Fatalf("expected one runner call, got %d", len(runner.calls)) + } + wantArgs := []string{"compose", "--env-file", filepath.Join(root, ".fystack.compose.env"), "-f", "docker-compose.yaml", "ps"} + if !reflect.DeepEqual(runner.calls[0].args, wantArgs) { + t.Fatalf("args mismatch\nwant: %#v\n got: %#v", wantArgs, runner.calls[0].args) + } + if runner.calls[0].dir != filepath.Join(root, "dev") { + t.Fatalf("unexpected dir %q", runner.calls[0].dir) + } +} + +func TestRestartNamedServicesRunsComposeRestart(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, "stack.versions.yaml"), `services: + apex: + env: FYSTACK_APEX_IMAGE + image: docker.io/fystacklabs/apex:1.0.54 + environments: [dev] +`) + mustWrite(t, filepath.Join(root, "dev", "docker-compose.yaml"), `services: + apex: {} + rescanner: {} +`) + + var out bytes.Buffer + runner := &fakeRunner{} + cmd := newRootCommand(dependencies{ + workDir: root, + out: &out, + errOut: &out, + runner: runner, + tagLister: fakeTags{}, + versionFile: "stack.versions.yaml", + composeEnvFile: ".fystack.compose.env", + }) + cmd.SetArgs([]string{"--env", "dev", "restart", "apex", "rescanner"}) + + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + if len(runner.calls) != 1 { + t.Fatalf("expected one runner call, got %#v", runner.calls) + } + wantArgs := []string{"compose", "--env-file", filepath.Join(root, ".fystack.compose.env"), "-f", "docker-compose.yaml", "restart", "apex", "rescanner"} + if !reflect.DeepEqual(runner.calls[0].args, wantArgs) { + t.Fatalf("args mismatch\nwant: %#v\n got: %#v", wantArgs, runner.calls[0].args) + } +} + +func TestRestartInteractiveFallbackSelectsServices(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, "stack.versions.yaml"), `services: + apex: + env: FYSTACK_APEX_IMAGE + image: docker.io/fystacklabs/apex:1.0.54 + environments: [dev] +`) + mustWrite(t, filepath.Join(root, "dev", "docker-compose.yaml"), `services: + rescanner: {} + apex: {} + mongo: {} +`) + + var out bytes.Buffer + runner := &fakeRunner{} + cmd := newRootCommand(dependencies{ + workDir: root, + in: strings.NewReader("1,3\n"), + out: &out, + errOut: &out, + runner: runner, + tagLister: fakeTags{}, + versionFile: "stack.versions.yaml", + composeEnvFile: ".fystack.compose.env", + }) + cmd.SetArgs([]string{"--env", "dev", "restart"}) + + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + if len(runner.calls) != 1 { + t.Fatalf("expected one runner call, got %#v", runner.calls) + } + wantArgs := []string{"compose", "--env-file", filepath.Join(root, ".fystack.compose.env"), "-f", "docker-compose.yaml", "restart", "apex", "rescanner"} + if !reflect.DeepEqual(runner.calls[0].args, wantArgs) { + t.Fatalf("args mismatch\nwant: %#v\n got: %#v", wantArgs, runner.calls[0].args) + } +} + +func TestCheckUpdatesReportsSemverUpdatesAndSkipsLatest(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, "stack.versions.yaml"), `services: + apex: + env: FYSTACK_APEX_IMAGE + image: docker.io/fystacklabs/apex:1.0.54 + environments: [dev] + nats-dev: + env: FYSTACK_NATS_IMAGE + image: nats:latest + environments: [dev] + mongo-dev: + env: FYSTACK_MONGO_IMAGE + image: mongo:7.0 + environments: [dev] +`) + mustWrite(t, filepath.Join(root, "dev", "docker-compose.yaml"), "services: {}\n") + + var out bytes.Buffer + cmd := newRootCommand(dependencies{ + workDir: root, + out: &out, + errOut: &out, + runner: &fakeRunner{}, + tagLister: fakeTags{ + "docker.io/fystacklabs/apex:1.0.54": {"1.0.53", "1.0.55", "latest", "bad"}, + "mongo:7.0": {"7.0", "8.3.4"}, + }, + versionFile: "stack.versions.yaml", + composeEnvFile: ".fystack.compose.env", + }) + cmd.SetArgs([]string{"--env", "dev", "check-updates"}) + + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + got := out.String() + if !strings.Contains(got, "apex: update available 1.0.54 -> 1.0.55") { + t.Fatalf("missing apex update:\n%s", got) + } + if strings.Contains(got, "nats") || strings.Contains(got, "mongo-dev") { + t.Fatalf("infrastructure services should not be reported:\n%s", got) + } +} + +func TestUpdateAllWritesLatestImagePinsWithoutDeploy(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, "stack.versions.yaml"), `services: + apex: + env: FYSTACK_APEX_IMAGE + image: docker.io/fystacklabs/apex:1.0.54 + environments: [dev] + redis: + env: FYSTACK_REDIS_IMAGE + image: redis:latest + environments: [dev] + mongo-dev: + env: FYSTACK_MONGO_IMAGE + image: mongo:7.0 + environments: [dev] +`) + mustWrite(t, filepath.Join(root, "dev", "docker-compose.yaml"), "services: {}\n") + + var out bytes.Buffer + runner := &fakeRunner{} + cmd := newRootCommand(dependencies{ + workDir: root, + in: strings.NewReader("no\n"), + out: &out, + errOut: &out, + runner: runner, + tagLister: fakeTags{ + "docker.io/fystacklabs/apex:1.0.54": {"1.0.55", "1.0.66"}, + "mongo:7.0": {"7.0", "8.3.4"}, + }, + versionFile: "stack.versions.yaml", + composeEnvFile: ".fystack.compose.env", + }) + cmd.SetArgs([]string{"--env", "dev", "update", "--all"}) + + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(filepath.Join(root, "stack.versions.yaml")) + if err != nil { + t.Fatal(err) + } + gotFile := string(data) + if !strings.Contains(gotFile, "image: docker.io/fystacklabs/apex:1.0.66") { + t.Fatalf("missing updated apex image:\n%s", gotFile) + } + if !strings.Contains(gotFile, "image: redis:latest") { + t.Fatalf("redis should be unchanged:\n%s", gotFile) + } + if !strings.Contains(gotFile, "image: mongo:7.0") { + t.Fatalf("mongo should be skipped by app updates:\n%s", gotFile) + } + if len(runner.calls) != 0 { + t.Fatalf("expected no deploy runner calls, got %#v", runner.calls) + } + gotOut := out.String() + if !strings.Contains(gotOut, "updated apex: 1.0.54 -> 1.0.66") { + t.Fatalf("missing update output:\n%s", gotOut) + } + if !strings.Contains(gotOut, "run `fystack deploy`") { + t.Fatalf("missing deploy reminder:\n%s", gotOut) + } +} + +func TestUpdateInteractiveFallbackSelectsAppUpdates(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, "stack.versions.yaml"), `services: + apex: + env: FYSTACK_APEX_IMAGE + image: docker.io/fystacklabs/apex:1.0.54 + environments: [dev] + rescanner: + env: FYSTACK_RESCANNER_IMAGE + image: docker.io/fystacklabs/rescanner:1.0.1 + environments: [dev] +`) + mustWrite(t, filepath.Join(root, "dev", "docker-compose.yaml"), `services: + apex: {} + rescanner: {} +`) + + var out bytes.Buffer + runner := &fakeRunner{} + cmd := newRootCommand(dependencies{ + workDir: root, + in: strings.NewReader("1\nno\n"), + out: &out, + errOut: &out, + runner: runner, + tagLister: fakeTags{ + "docker.io/fystacklabs/apex:1.0.54": {"1.0.66"}, + "docker.io/fystacklabs/rescanner:1.0.1": {"1.0.2"}, + }, + versionFile: "stack.versions.yaml", + composeEnvFile: ".fystack.compose.env", + }) + cmd.SetArgs([]string{"--env", "dev", "update"}) + + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(filepath.Join(root, "stack.versions.yaml")) + if err != nil { + t.Fatal(err) + } + gotFile := string(data) + if !strings.Contains(gotFile, "image: docker.io/fystacklabs/apex:1.0.66") { + t.Fatalf("missing updated apex image:\n%s", gotFile) + } + if !strings.Contains(gotFile, "image: docker.io/fystacklabs/rescanner:1.0.1") { + t.Fatalf("rescanner should be unchanged:\n%s", gotFile) + } + if len(runner.calls) != 0 { + t.Fatalf("expected no deploy runner calls, got %#v", runner.calls) + } + gotOut := out.String() + if !strings.Contains(gotOut, "Select app updates to apply") { + t.Fatalf("missing update selector:\n%s", gotOut) + } + if !strings.Contains(gotOut, "Deploy updated images now?") { + t.Fatalf("missing deploy prompt:\n%s", gotOut) + } +} + +func TestUpdateDeploysSelectedComposeServices(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, "stack.versions.yaml"), `services: + apex-migrate: + env: FYSTACK_APEX_MIGRATE_IMAGE + image: docker.io/fystacklabs/apex-migrate:1.0.24 + environments: [dev] + mpcium: + env: FYSTACK_MPCIUM_IMAGE + image: docker.io/fystacklabs/mpcium:v1.0.0 + environments: [dev] +`) + mustWrite(t, filepath.Join(root, "dev", "docker-compose.yaml"), `services: + migrate: {} + mpcium1: {} + mpcium0: {} +`) + + var out bytes.Buffer + runner := &fakeRunner{} + cmd := newRootCommand(dependencies{ + workDir: root, + out: &out, + errOut: &out, + runner: runner, + tagLister: fakeTags{ + "docker.io/fystacklabs/apex-migrate:1.0.24": {"1.0.26"}, + "docker.io/fystacklabs/mpcium:v1.0.0": {"v1.0.1"}, + }, + versionFile: "stack.versions.yaml", + composeEnvFile: ".fystack.compose.env", + }) + cmd.SetArgs([]string{"--env", "dev", "update", "apex-migrate", "mpcium", "--deploy"}) + + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + if len(runner.calls) != 1 { + t.Fatalf("expected one deploy runner call, got %#v", runner.calls) + } + wantArgs := []string{"compose", "--env-file", filepath.Join(root, ".fystack.compose.env"), "-f", "docker-compose.yaml", "up", "-d", "migrate", "mpcium0", "mpcium1"} + if !reflect.DeepEqual(runner.calls[0].args, wantArgs) { + t.Fatalf("args mismatch\nwant: %#v\n got: %#v", wantArgs, runner.calls[0].args) + } + if !strings.Contains(out.String(), "deploying updated services: migrate, mpcium0, mpcium1") { + t.Fatalf("missing deploy summary:\n%s", out.String()) + } +} + +func TestUpdateNamedServiceReportsNoUpdates(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, "stack.versions.yaml"), `services: + apex: + env: FYSTACK_APEX_IMAGE + image: docker.io/fystacklabs/apex:1.0.54 + environments: [dev] +`) + mustWrite(t, filepath.Join(root, "dev", "docker-compose.yaml"), "services: {}\n") + + var out bytes.Buffer + cmd := newRootCommand(dependencies{ + workDir: root, + in: strings.NewReader("no\n"), + out: &out, + errOut: &out, + runner: &fakeRunner{}, + tagLister: fakeTags{ + "docker.io/fystacklabs/apex:1.0.54": {"1.0.54"}, + }, + versionFile: "stack.versions.yaml", + composeEnvFile: ".fystack.compose.env", + }) + cmd.SetArgs([]string{"--env", "dev", "update", "apex"}) + + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "no semver updates available") { + t.Fatalf("missing no updates output:\n%s", out.String()) + } +} + +func TestDoctorChecksComposeVersionWithoutComposeEnvFile(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, "stack.versions.yaml"), "services: {}\n") + mustWrite(t, filepath.Join(root, "dev", "docker-compose.yaml"), "services: {}\n") + mustWrite(t, filepath.Join(root, "dev", "config.yaml"), "name: test\n") + mustWrite(t, filepath.Join(root, "dev", "config.rescanner.yaml"), "name: test\n") + mustWrite(t, filepath.Join(root, "dev", "config.indexer.yaml"), "name: test\n") + + var out bytes.Buffer + runner := &fakeRunner{} + cmd := newRootCommand(dependencies{ + workDir: root, + out: &out, + errOut: &out, + runner: runner, + tagLister: fakeTags{}, + versionFile: "stack.versions.yaml", + composeEnvFile: ".fystack.compose.env", + }) + cmd.SetArgs([]string{"doctor", "--env", "dev"}) + + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + if len(runner.calls) != 1 { + t.Fatalf("expected one runner call, got %d", len(runner.calls)) + } + wantArgs := []string{"compose", "version"} + if !reflect.DeepEqual(runner.calls[0].args, wantArgs) { + t.Fatalf("args mismatch\nwant: %#v\n got: %#v", wantArgs, runner.calls[0].args) + } + if _, err := os.Stat(filepath.Join(root, ".fystack.compose.env")); !os.IsNotExist(err) { + t.Fatalf("doctor should not create compose env file, stat err: %v", err) + } +} + +func TestSetupCopiesDevConfigsWithBinanceAndSkipsActions(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, "stack.versions.yaml"), "services: {}\n") + mustWrite(t, filepath.Join(root, "dev", "docker-compose.yaml"), "services: {}\n") + mustWrite(t, filepath.Join(root, "dev", "config.yaml.template"), `price_providers: + coinmarketcap: + api_key: "" + binance: + endpoint: "https://api.binance.com/api/v3/ticker/price" +`) + mustWrite(t, filepath.Join(root, "dev", "config.rescanner.yaml.template"), "rescanner: true\n") + mustWrite(t, filepath.Join(root, "dev", "config.indexer.yaml.template"), "indexer: true\n") + + var out bytes.Buffer + runner := &fakeRunner{} + cmd := newRootCommand(dependencies{ + workDir: root, + in: strings.NewReader("\n\nno\nno\n"), + out: &out, + errOut: &out, + runner: runner, + tagLister: fakeTags{}, + versionFile: "stack.versions.yaml", + composeEnvFile: ".fystack.compose.env", + }) + cmd.SetArgs([]string{"setup"}) + + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + config, err := os.ReadFile(filepath.Join(root, "dev", "config.yaml")) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(config), `cmc-test-key`) { + t.Fatalf("config should not contain a prompted API key for Binance:\n%s", config) + } + for _, path := range []string{"config.rescanner.yaml", "config.indexer.yaml"} { + if _, err := os.Stat(filepath.Join(root, "dev", path)); err != nil { + t.Fatalf("expected %s to be created: %v", path, err) + } + } + if len(runner.calls) != 0 { + t.Fatalf("expected setup to skip runner calls, got %#v", runner.calls) + } + got := out.String() + if !strings.Contains(got, "setup complete") { + t.Fatalf("missing setup completion output:\n%s", got) + } + if !strings.Contains(got, "using Binance price provider; no API key needed") { + t.Fatalf("missing Binance no-key output:\n%s", got) + } +} + +func TestSetupCanOverwriteConfigsAndRequireCoinMarketCapKey(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, "stack.versions.yaml"), "services: {}\n") + mustWrite(t, filepath.Join(root, "dev", "docker-compose.yaml"), "services: {}\n") + mustWrite(t, filepath.Join(root, "dev", "config.yaml.template"), `price_providers: + coinmarketcap: + api_key: "" + binance: + endpoint: "https://api.binance.com/api/v3/ticker/price" +`) + mustWrite(t, filepath.Join(root, "dev", "config.rescanner.yaml.template"), "rescanner: true\n") + mustWrite(t, filepath.Join(root, "dev", "config.indexer.yaml.template"), "indexer: true\n") + mustWrite(t, filepath.Join(root, "dev", "config.yaml"), `price_providers: + coinmarketcap: + api_key: "old-key" +`) + mustWrite(t, filepath.Join(root, "dev", "config.rescanner.yaml"), "old: rescanner\n") + mustWrite(t, filepath.Join(root, "dev", "config.indexer.yaml"), "old: indexer\n") + + var out bytes.Buffer + runner := &fakeRunner{} + cmd := newRootCommand(dependencies{ + workDir: root, + in: strings.NewReader("\nyes\n2\ncmc-test-key\nno\nno\n"), + out: &out, + errOut: &out, + runner: runner, + tagLister: fakeTags{}, + versionFile: "stack.versions.yaml", + composeEnvFile: ".fystack.compose.env", + }) + cmd.SetArgs([]string{"setup"}) + + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + config, err := os.ReadFile(filepath.Join(root, "dev", "config.yaml")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(config), `api_key: "cmc-test-key"`) { + t.Fatalf("config did not contain prompted API key:\n%s", config) + } + if len(runner.calls) != 0 { + t.Fatalf("expected setup to skip runner calls, got %#v", runner.calls) + } + got := out.String() + if !strings.Contains(got, "overwrote dev/config.yaml") { + t.Fatalf("missing overwrite output:\n%s", got) + } +} + +func TestInitForceRunsNodeSetupWhenNodeConfigsExist(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, "stack.versions.yaml"), "services: {}\n") + mustWrite(t, filepath.Join(root, "dev", "docker-compose.yaml"), "services: {}\n") + mustWrite(t, filepath.Join(root, "dev", "config.yaml"), `mpc: + signer: + local: + pk_raw: "" +integrity: + signer: + ed25519: + private_key: "" +`) + mustWrite(t, filepath.Join(root, "dev", "config.rescanner.yaml"), "name: test\n") + mustWrite(t, filepath.Join(root, "dev", "config.indexer.yaml"), "name: test\n") + mustWrite(t, filepath.Join(root, "dev", "node-configs", "node0", "config.yaml"), "name: node0\n") + + var out bytes.Buffer + runner := &mpciumFakeRunner{} + cmd := newRootCommand(dependencies{ + workDir: root, + out: &out, + errOut: &out, + runner: runner, + tagLister: fakeTags{}, + versionFile: "stack.versions.yaml", + composeEnvFile: ".fystack.compose.env", + }) + cmd.SetArgs([]string{"init", "--env", "dev", "--force"}) + + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + if len(runner.calls) == 0 { + t.Fatal("expected runner calls for docker pull and mpcium-cli, got none") + } + if runner.calls[0].name != "docker" { + t.Fatalf("expected first call to be docker, got %q", runner.calls[0].name) + } + if len(runner.calls[0].args) < 1 || runner.calls[0].args[0] != "pull" { + t.Fatalf("expected docker pull as first call, got args %#v", runner.calls[0].args) + } + for _, call := range runner.calls { + if strings.HasSuffix(call.name, "setup-nodes.sh") { + t.Fatal("init should use Go-based node setup, not setup-nodes.sh") + } + } + nodeConfigsDir := filepath.Join(root, "dev", "node-configs") + for _, node := range []string{"node0", "node1", "node2"} { + identDir := filepath.Join(nodeConfigsDir, node, "identity") + if _, err := os.Stat(identDir); err != nil { + t.Fatalf("expected %s identity dir to exist: %v", node, err) + } + } +} + +// mpciumFakeRunner simulates docker by writing the files that mpcium-cli would produce. +type mpciumFakeRunner struct { + calls []runnerCall +} + +func (r *mpciumFakeRunner) LookPath(string) error { return nil } + +func (r *mpciumFakeRunner) Run(_ context.Context, callDir, name string, args ...string) ([]byte, error) { + r.calls = append(r.calls, runnerCall{dir: callDir, name: name, args: append([]string(nil), args...)}) + if name != "docker" || len(args) == 0 || args[0] != "run" { + return nil, nil + } + + var mountSrc string + for i := 0; i < len(args)-1; i++ { + if args[i] == "-v" { + mountSrc = strings.SplitN(args[i+1], ":", 2)[0] + break + } + } + + for i, arg := range args { + if !strings.Contains(arg, "mpcium-cli") || i+1 >= len(args) { + continue + } + mpcArgs := args[i+1:] + switch mpcArgs[0] { + case "generate-peers": + peers := `{"node0":"peer-id-0","node1":"peer-id-1","node2":"peer-id-2"}` + _ = os.WriteFile(filepath.Join(mountSrc, "peers.json"), []byte(peers), 0644) + case "generate-initiator": + _ = os.WriteFile(filepath.Join(mountSrc, "event_initiator.key"), []byte("testprivkey"), 0644) + _ = os.WriteFile(filepath.Join(mountSrc, "event_initiator.identity.json"), []byte(`{"public_key":"testpubkey"}`), 0644) + case "generate-identity": + for j, a := range mpcArgs { + if a == "--node" && j+1 < len(mpcArgs) { + nodeName := mpcArgs[j+1] + identDir := filepath.Join(mountSrc, "identity") + _ = os.MkdirAll(identDir, 0755) + _ = os.WriteFile(filepath.Join(identDir, nodeName+"_identity.json"), []byte(`{"peer_id":"pid","public_key":"pubkey"}`), 0644) + _ = os.WriteFile(filepath.Join(identDir, nodeName+"_private.key"), []byte("privkey"), 0644) + break + } + } + } + break + } + return nil, nil +} + +func TestResetRemovesGeneratedFiles(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, "stack.versions.yaml"), "services: {}\n") + mustWrite(t, filepath.Join(root, "dev", "docker-compose.yaml"), "services: {}\n") + mustWrite(t, filepath.Join(root, "dev", "config.yaml"), "name: test\n") + mustWrite(t, filepath.Join(root, "dev", "config.rescanner.yaml"), "name: test\n") + mustWrite(t, filepath.Join(root, "dev", "config.indexer.yaml"), "name: test\n") + mustWrite(t, filepath.Join(root, "dev", "node-configs", "node0", "config.yaml"), "name: node0\n") + mustWrite(t, filepath.Join(root, ".fystack.compose.env"), "FYSTACK_APEX_IMAGE=x\n") + + var out bytes.Buffer + cmd := newRootCommand(dependencies{ + workDir: root, + in: strings.NewReader("yes\n"), + out: &out, + errOut: &out, + runner: &fakeRunner{}, + tagLister: fakeTags{}, + versionFile: "stack.versions.yaml", + composeEnvFile: ".fystack.compose.env", + }) + cmd.SetArgs([]string{"reset"}) + + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + for _, path := range []string{ + filepath.Join(root, "dev", "config.yaml"), + filepath.Join(root, "dev", "config.rescanner.yaml"), + filepath.Join(root, "dev", "config.indexer.yaml"), + filepath.Join(root, "dev", "node-configs"), + filepath.Join(root, ".fystack.compose.env"), + } { + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("expected %s to be removed, stat err: %v", path, err) + } + } + if !strings.Contains(out.String(), "reset complete") { + t.Fatalf("missing reset complete output:\n%s", out.String()) + } +} + +func TestResetForceSkipsConfirmation(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, "stack.versions.yaml"), "services: {}\n") + mustWrite(t, filepath.Join(root, "dev", "docker-compose.yaml"), "services: {}\n") + mustWrite(t, filepath.Join(root, "dev", "config.yaml"), "name: test\n") + + var out bytes.Buffer + cmd := newRootCommand(dependencies{ + workDir: root, + out: &out, + errOut: &out, + runner: &fakeRunner{}, + tagLister: fakeTags{}, + versionFile: "stack.versions.yaml", + composeEnvFile: ".fystack.compose.env", + }) + cmd.SetArgs([]string{"reset", "--force"}) + + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(root, "dev", "config.yaml")); !os.IsNotExist(err) { + t.Fatalf("expected config.yaml to be removed") + } +} + +func TestConfirmDefaultsNoFirstForDestructivePrompts(t *testing.T) { + var out bytes.Buffer + prompt := newPrompter(dependencies{ + in: strings.NewReader("\n"), + out: &out, + }) + + ok, err := prompt.confirm("Overwrite existing files?", false) + if err != nil { + t.Fatal(err) + } + if ok { + t.Fatal("expected empty answer to choose no") + } + got := out.String() + noIndex := strings.Index(got, "1. no") + yesIndex := strings.Index(got, "2. yes") + if noIndex == -1 || yesIndex == -1 || noIndex > yesIndex { + t.Fatalf("destructive prompt should render no before yes:\n%s", got) + } +} + +func TestDiscoverMPCServicesSortsByNodeIndex(t *testing.T) { + root := t.TempDir() + for _, dir := range []string{"node10", "node2", "node0"} { + if err := os.MkdirAll(filepath.Join(root, dir), 0755); err != nil { + t.Fatal(err) + } + } + got := discoverMPCServices(root) + want := []string{"mpcium0", "mpcium2", "mpcium10"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("want %#v, got %#v", want, got) + } +} + +func TestDestroyRunsComposeDownAndResetsFiles(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, "stack.versions.yaml"), `services: + apex: + env: FYSTACK_APEX_IMAGE + image: docker.io/fystacklabs/apex:1.0.54 + environments: [dev] +`) + mustWrite(t, filepath.Join(root, "dev", "docker-compose.yaml"), "services: {}\n") + mustWrite(t, filepath.Join(root, "dev", "config.yaml"), "name: test\n") + mustWrite(t, filepath.Join(root, "dev", "config.rescanner.yaml"), "name: test\n") + mustWrite(t, filepath.Join(root, "dev", "config.indexer.yaml"), "name: test\n") + mustWrite(t, filepath.Join(root, "dev", "node-configs", "node0", "config.yaml"), "name: node0\n") + mustWrite(t, filepath.Join(root, ".fystack.compose.env"), "FYSTACK_APEX_IMAGE=x\n") + + var out bytes.Buffer + runner := &fakeRunner{} + cmd := newRootCommand(dependencies{ + workDir: root, + in: strings.NewReader("yes\n"), + out: &out, + errOut: &out, + runner: runner, + tagLister: fakeTags{}, + versionFile: "stack.versions.yaml", + composeEnvFile: ".fystack.compose.env", + }) + cmd.SetArgs([]string{"--env", "dev", "destroy"}) + + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + + if len(runner.calls) != 1 { + t.Fatalf("expected one compose down call, got %d: %#v", len(runner.calls), runner.calls) + } + wantArgs := []string{"compose", "--env-file", filepath.Join(root, ".fystack.compose.env"), "-f", "docker-compose.yaml", "down", "--volumes", "--remove-orphans"} + if !reflect.DeepEqual(runner.calls[0].args, wantArgs) { + t.Fatalf("args mismatch\nwant: %#v\n got: %#v", wantArgs, runner.calls[0].args) + } + + for _, path := range []string{ + filepath.Join(root, "dev", "config.yaml"), + filepath.Join(root, "dev", "node-configs"), + filepath.Join(root, ".fystack.compose.env"), + } { + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("expected %s to be removed after destroy", path) + } + } + + got := out.String() + if !strings.Contains(got, "Destroy plan:") { + t.Fatalf("missing destroy plan output:\n%s", got) + } + if !strings.Contains(got, "Destroy complete.") { + t.Fatalf("missing completion message:\n%s", got) + } +} + +func TestDestroyAbortedOnNo(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, "stack.versions.yaml"), `services: + apex: + env: FYSTACK_APEX_IMAGE + image: docker.io/fystacklabs/apex:1.0.54 + environments: [dev] +`) + mustWrite(t, filepath.Join(root, "dev", "docker-compose.yaml"), "services: {}\n") + mustWrite(t, filepath.Join(root, "dev", "config.yaml"), "name: test\n") + + var out bytes.Buffer + runner := &fakeRunner{} + cmd := newRootCommand(dependencies{ + workDir: root, + in: strings.NewReader("no\n"), + out: &out, + errOut: &out, + runner: runner, + tagLister: fakeTags{}, + versionFile: "stack.versions.yaml", + composeEnvFile: ".fystack.compose.env", + }) + cmd.SetArgs([]string{"--env", "dev", "destroy"}) + + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + if len(runner.calls) != 0 { + t.Fatalf("expected no runner calls when destroy is canceled, got %#v", runner.calls) + } + if _, err := os.Stat(filepath.Join(root, "dev", "config.yaml")); err != nil { + t.Fatal("config.yaml should still exist after canceled destroy") + } + if !strings.Contains(out.String(), "destroy canceled") { + t.Fatalf("missing canceled message:\n%s", out.String()) + } +} + +func mustWrite(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } +} diff --git a/internal/compose/runner.go b/internal/compose/runner.go new file mode 100644 index 0000000..68929fe --- /dev/null +++ b/internal/compose/runner.go @@ -0,0 +1,24 @@ +package compose + +import ( + "context" + "os/exec" +) + +type Runner interface { + Run(ctx context.Context, dir, name string, args ...string) ([]byte, error) + LookPath(name string) error +} + +type OSRunner struct{} + +func (OSRunner) Run(ctx context.Context, dir, name string, args ...string) ([]byte, error) { + cmd := exec.CommandContext(ctx, name, args...) + cmd.Dir = dir + return cmd.CombinedOutput() +} + +func (OSRunner) LookPath(name string) error { + _, err := exec.LookPath(name) + return err +} diff --git a/internal/mask/mask.go b/internal/mask/mask.go new file mode 100644 index 0000000..6f63a8c --- /dev/null +++ b/internal/mask/mask.go @@ -0,0 +1,34 @@ +package mask + +import "regexp" + +var patterns = []struct { + re *regexp.Regexp + repl string +}{ + {regexp.MustCompile(`badger_password:\s*"[^"]*"`), `badger_password: "***MASKED***"`}, + {regexp.MustCompile(`Generated BadgerDB password:\s*\S+`), `Generated BadgerDB password: ***MASKED***`}, + {regexp.MustCompile(`Password:\s*\S+`), `Password: ***MASKED***`}, + {regexp.MustCompile(`encryption_key:\s*[a-f0-9]{32}`), `encryption_key: ***MASKED***`}, + {regexp.MustCompile(`Found encryption key:\s*[a-f0-9]{32}`), `Found encryption key: ***MASKED***`}, + {regexp.MustCompile(`ENCRYPTION_KEY=[a-f0-9]{32}`), `ENCRYPTION_KEY=***MASKED***`}, + {regexp.MustCompile(`event_initiator_pubkey:\s*"[^"]*"`), `event_initiator_pubkey: "***MASKED***"`}, + {regexp.MustCompile(`Event initiator public key:\s*\S+`), `Event initiator public key: ***MASKED***`}, + {regexp.MustCompile(`event_initiator_pk_raw:\s*"[^"]*"`), `event_initiator_pk_raw: "***MASKED***"`}, + {regexp.MustCompile(`Event initiator private key length:\s*\d+\s*characters`), `Event initiator private key length: ***MASKED*** characters`}, + {regexp.MustCompile(`jwt_secret:\s*[a-f0-9]{32}`), `jwt_secret: ***MASKED***`}, + {regexp.MustCompile(`api_key:\s*"[^"]*"`), `api_key: "***MASKED***"`}, + {regexp.MustCompile(`api_secret:\s*"[^"]*"`), `api_secret: "***MASKED***"`}, + {regexp.MustCompile(`client_secret:\s*"[^"]*"`), `client_secret: "***MASKED***"`}, + {regexp.MustCompile(`password:\s*"[^"]*"`), `password: "***MASKED***"`}, + {regexp.MustCompile(`redis_password:\s*"[^"]*"`), `redis_password: "***MASKED***"`}, + {regexp.MustCompile(`private_key:\s*"[a-f0-9]{64}"`), `private_key: "***MASKED***"`}, + {regexp.MustCompile(`peer_id:\s*"[^"]*"`), `peer_id: "***MASKED***"`}, +} + +func Sensitive(text string) string { + for _, pattern := range patterns { + text = pattern.re.ReplaceAllString(text, pattern.repl) + } + return text +} diff --git a/internal/mask/mask_test.go b/internal/mask/mask_test.go new file mode 100644 index 0000000..4e8c8c9 --- /dev/null +++ b/internal/mask/mask_test.go @@ -0,0 +1,18 @@ +package mask + +import ( + "strings" + "testing" +) + +func TestSensitiveMasksSecrets(t *testing.T) { + input := `password: "plain" +ENCRYPTION_KEY=82441c6785f53e02dbf97db9db2107ad +private_key: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"` + got := Sensitive(input) + for _, leaked := range []string{"plain", "82441c6785f53e02dbf97db9db2107ad", strings.Repeat("a", 64)} { + if strings.Contains(got, leaked) { + t.Fatalf("secret leaked in output:\n%s", got) + } + } +} diff --git a/internal/registry/registry.go b/internal/registry/registry.go new file mode 100644 index 0000000..f02793a --- /dev/null +++ b/internal/registry/registry.go @@ -0,0 +1,35 @@ +package registry + +import ( + "context" + "strings" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/v1/remote" +) + +type TagLister interface { + Tags(ctx context.Context, image string) ([]string, error) +} + +type RemoteTagLister struct{} + +func (RemoteTagLister) Tags(ctx context.Context, image string) ([]string, error) { + ref, err := name.ParseReference(image) + if err != nil { + return nil, err + } + return remote.List(ref.Context(), remote.WithContext(ctx), remote.WithAuthFromKeychain(authn.DefaultKeychain)) +} + +func ImageTag(image string) (string, bool) { + idx := strings.LastIndex(image, ":") + if idx < 0 || idx == len(image)-1 { + return "", false + } + if slash := strings.LastIndex(image, "/"); slash > idx { + return "", false + } + return image[idx+1:], true +} diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go new file mode 100644 index 0000000..9e68e5a --- /dev/null +++ b/internal/registry/registry_test.go @@ -0,0 +1,22 @@ +package registry + +import "testing" + +func TestImageTag(t *testing.T) { + tests := []struct { + image string + tag string + ok bool + }{ + {"docker.io/fystacklabs/apex:1.0.54", "1.0.54", true}, + {"mongo:7.0", "7.0", true}, + {"postgres", "", false}, + {"localhost:5000/repo/image:1.2.3", "1.2.3", true}, + } + for _, tt := range tests { + tag, ok := ImageTag(tt.image) + if tag != tt.tag || ok != tt.ok { + t.Fatalf("ImageTag(%q) = %q, %v; want %q, %v", tt.image, tag, ok, tt.tag, tt.ok) + } + } +} diff --git a/internal/semver/semver.go b/internal/semver/semver.go new file mode 100644 index 0000000..6d84121 --- /dev/null +++ b/internal/semver/semver.go @@ -0,0 +1,75 @@ +package semver + +import ( + "strconv" + "strings" +) + +type version struct { + parts [3]int +} + +func Valid(tag string) bool { + _, ok := parse(tag) + return ok +} + +func Compare(a, b string) int { + va, oka := parse(a) + vb, okb := parse(b) + if !oka || !okb { + return strings.Compare(a, b) + } + for i := range va.parts { + if va.parts[i] > vb.parts[i] { + return 1 + } + if va.parts[i] < vb.parts[i] { + return -1 + } + } + return 0 +} + +func Latest(tags []string) string { + latest := "" + for _, tag := range tags { + if !Valid(tag) { + continue + } + if latest == "" || Compare(tag, latest) > 0 { + latest = tag + } + } + return latest +} + +func NaturalLess(a, b string) bool { + ai, aerr := strconv.Atoi(a) + bi, berr := strconv.Atoi(b) + if aerr == nil && berr == nil { + return ai < bi + } + return a < b +} + +func parse(tag string) (version, bool) { + tag = strings.TrimPrefix(tag, "v") + core := strings.SplitN(tag, "-", 2)[0] + fields := strings.Split(core, ".") + if len(fields) < 2 || len(fields) > 3 { + return version{}, false + } + var out version + for i, field := range fields { + if field == "" { + return version{}, false + } + n, err := strconv.Atoi(field) + if err != nil || n < 0 { + return version{}, false + } + out.parts[i] = n + } + return out, true +} diff --git a/internal/semver/semver_test.go b/internal/semver/semver_test.go new file mode 100644 index 0000000..4edd01b --- /dev/null +++ b/internal/semver/semver_test.go @@ -0,0 +1,19 @@ +package semver + +import "testing" + +func TestLatest(t *testing.T) { + got := Latest([]string{"latest", "1.0.9", "1.0.10", "bad", "v2.0.0"}) + if got != "v2.0.0" { + t.Fatalf("want v2.0.0, got %q", got) + } +} + +func TestCompare(t *testing.T) { + if Compare("1.0.10", "1.0.9") <= 0 { + t.Fatal("expected 1.0.10 > 1.0.9") + } + if Compare("0.3.5", "0.3.5") != 0 { + t.Fatal("expected equal versions") + } +} diff --git a/internal/stack/stack.go b/internal/stack/stack.go new file mode 100644 index 0000000..7316042 --- /dev/null +++ b/internal/stack/stack.go @@ -0,0 +1,47 @@ +package stack + +import "fmt" + +type Environment struct { + Name string + WorkDir string + ComposeFile string + RequiredConfigFiles []string + DevInfrastructureServices []string +} + +func Resolve(name string) (Environment, error) { + switch name { + case "", "dev": + return Environment{ + Name: "dev", + WorkDir: "dev", + ComposeFile: "dev/docker-compose.yaml", + RequiredConfigFiles: []string{ + "dev/config.yaml", + "dev/config.rescanner.yaml", + "dev/config.indexer.yaml", + }, + DevInfrastructureServices: []string{ + "migrate", + "apex", + "rescanner", + "postgres", + "redis", + "mongo", + "nats-server", + "consul", + "multichain-indexer", + "fystack-ui-community", + }, + }, nil + case "prod": + return Environment{ + Name: "prod", + WorkDir: "prod", + ComposeFile: "prod/docker-compose.yaml", + }, nil + default: + return Environment{}, fmt.Errorf("unknown environment %q; use dev or prod", name) + } +} diff --git a/internal/versions/versions.go b/internal/versions/versions.go new file mode 100644 index 0000000..00a54d2 --- /dev/null +++ b/internal/versions/versions.go @@ -0,0 +1,140 @@ +package versions + +import ( + "bytes" + "fmt" + "os" + "sort" + "strings" + + "gopkg.in/yaml.v3" +) + +type Definitions struct { + Services map[string]Service `yaml:"services"` +} + +type Service struct { + Name string `yaml:"-"` + Env string `yaml:"env"` + Image string `yaml:"image"` + Environments []string `yaml:"environments"` +} + +func Load(path string) (Definitions, error) { + data, err := os.ReadFile(path) + if err != nil { + return Definitions{}, err + } + var defs Definitions + if err := yaml.Unmarshal(data, &defs); err != nil { + return Definitions{}, err + } + for name, svc := range defs.Services { + svc.Name = name + defs.Services[name] = svc + if svc.Env == "" { + return Definitions{}, fmt.Errorf("service %s missing env", name) + } + if svc.Image == "" { + return Definitions{}, fmt.Errorf("service %s missing image", name) + } + } + return defs, nil +} + +func (d Definitions) ForEnvironment(env string) []Service { + var services []Service + for name, svc := range d.Services { + if includes(svc.Environments, env) { + svc.Name = name + services = append(services, svc) + } + } + sort.Slice(services, func(i, j int) bool { + return services[i].Name < services[j].Name + }) + return services +} + +func (d Definitions) EnvFile(env string) string { + services := d.ForEnvironment(env) + var buf bytes.Buffer + buf.WriteString("# Generated by fystack. Do not store secrets here.\n") + for _, svc := range services { + fmt.Fprintf(&buf, "%s=%s\n", svc.Env, svc.Image) + } + return buf.String() +} + +func UpdateImages(path string, updates map[string]string) error { + if len(updates) == 0 { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + var doc yaml.Node + if err := yaml.Unmarshal(data, &doc); err != nil { + return err + } + root := documentRoot(&doc) + services := mappingValue(root, "services") + if services == nil { + return fmt.Errorf("versions file missing services") + } + for service, image := range updates { + serviceNode := mappingValue(services, service) + if serviceNode == nil { + return fmt.Errorf("service %s not found", service) + } + imageNode := mappingValue(serviceNode, "image") + if imageNode == nil { + return fmt.Errorf("service %s missing image", service) + } + imageNode.Kind = yaml.ScalarNode + imageNode.Tag = "!!str" + imageNode.Value = image + } + + var out bytes.Buffer + encoder := yaml.NewEncoder(&out) + encoder.SetIndent(2) + if err := encoder.Encode(&doc); err != nil { + _ = encoder.Close() + return err + } + if err := encoder.Close(); err != nil { + return err + } + return os.WriteFile(path, out.Bytes(), 0644) +} + +func documentRoot(node *yaml.Node) *yaml.Node { + if node.Kind == yaml.DocumentNode && len(node.Content) > 0 { + return node.Content[0] + } + return node +} + +func mappingValue(node *yaml.Node, key string) *yaml.Node { + if node == nil || node.Kind != yaml.MappingNode { + return nil + } + for i := 0; i+1 < len(node.Content); i += 2 { + if node.Content[i].Value == key { + return node.Content[i+1] + } + } + return nil +} + +func includes(values []string, target string) bool { + for _, value := range values { + if strings.EqualFold(value, target) || value == "*" { + return true + } + } + return false +} diff --git a/internal/versions/versions_test.go b/internal/versions/versions_test.go new file mode 100644 index 0000000..a47ba10 --- /dev/null +++ b/internal/versions/versions_test.go @@ -0,0 +1,69 @@ +package versions + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLoadAndEnvFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "stack.versions.yaml") + if err := os.WriteFile(path, []byte(`services: + apex: + env: FYSTACK_APEX_IMAGE + image: docker.io/fystacklabs/apex:1.0.54 + environments: [dev] + mongo-prod: + env: FYSTACK_MONGO_PROD_IMAGE + image: mongo:7.0 + environments: [prod] +`), 0644); err != nil { + t.Fatal(err) + } + defs, err := Load(path) + if err != nil { + t.Fatal(err) + } + env := defs.EnvFile("dev") + if !strings.Contains(env, "FYSTACK_APEX_IMAGE=docker.io/fystacklabs/apex:1.0.54") { + t.Fatalf("missing apex env:\n%s", env) + } + if strings.Contains(env, "FYSTACK_MONGO_PROD_IMAGE") { + t.Fatalf("prod env leaked into dev env:\n%s", env) + } +} + +func TestUpdateImagesPreservesOtherServices(t *testing.T) { + path := filepath.Join(t.TempDir(), "stack.versions.yaml") + if err := os.WriteFile(path, []byte(`services: + apex: + env: FYSTACK_APEX_IMAGE + image: docker.io/fystacklabs/apex:1.0.54 + environments: [dev] + redis: + env: FYSTACK_REDIS_IMAGE + image: redis:latest + environments: [dev] +`), 0644); err != nil { + t.Fatal(err) + } + + if err := UpdateImages(path, map[string]string{ + "apex": "docker.io/fystacklabs/apex:1.0.66", + }); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + got := string(data) + if !strings.Contains(got, "image: docker.io/fystacklabs/apex:1.0.66") { + t.Fatalf("missing updated apex image:\n%s", got) + } + if !strings.Contains(got, "image: redis:latest") { + t.Fatalf("redis image should be unchanged:\n%s", got) + } +} diff --git a/prod/docker-compose.yaml b/prod/docker-compose.yaml index f987692..de9bb9d 100644 --- a/prod/docker-compose.yaml +++ b/prod/docker-compose.yaml @@ -2,7 +2,7 @@ version: "3.8" services: nats-prod: - image: nats:alpine + image: ${FYSTACK_NATS_PROD_IMAGE:-nats:alpine} container_name: nats-apex-production security_opt: - no-new-privileges:true @@ -21,7 +21,7 @@ services: tty: true mongo-prod: - image: mongo:7.0 + image: ${FYSTACK_MONGO_PROD_IMAGE:-mongo:7.0} container_name: mongo-apex-production security_opt: - no-new-privileges:true diff --git a/stack.versions.yaml b/stack.versions.yaml new file mode 100644 index 0000000..06d22c9 --- /dev/null +++ b/stack.versions.yaml @@ -0,0 +1,53 @@ +services: + apex: + env: FYSTACK_APEX_IMAGE + image: docker.io/fystacklabs/apex:1.0.66 + environments: [dev] + apex-migrate: + env: FYSTACK_APEX_MIGRATE_IMAGE + image: docker.io/fystacklabs/apex-migrate:1.0.26 + environments: [dev] + rescanner: + env: FYSTACK_RESCANNER_IMAGE + image: docker.io/fystacklabs/rescanner:1.0.2 + environments: [dev] + multichain-indexer: + env: FYSTACK_MULTICHAIN_INDEXER_IMAGE + image: docker.io/fystacklabs/multichain-indexer:1.0.16 + environments: [dev] + fystack-ui-community: + env: FYSTACK_UI_IMAGE + image: docker.io/fystacklabs/fystack-ui-ee:1.0.20 + environments: [dev] + mpcium: + env: FYSTACK_MPCIUM_IMAGE + image: docker.io/fystacklabs/mpcium:v1.0.0 + environments: [dev] + postgres: + env: FYSTACK_POSTGRES_IMAGE + image: postgres + environments: [dev] + redis: + env: FYSTACK_REDIS_IMAGE + image: redis/redis-stack-server:latest + environments: [dev] + mongo-dev: + env: FYSTACK_MONGO_IMAGE + image: mongo:7.0 + environments: [dev] + nats-dev: + env: FYSTACK_NATS_IMAGE + image: nats:latest + environments: [dev] + consul: + env: FYSTACK_CONSUL_IMAGE + image: consul:1.15.4 + environments: [dev] + nats-prod: + env: FYSTACK_NATS_PROD_IMAGE + image: nats:alpine + environments: [prod] + mongo-prod: + env: FYSTACK_MONGO_PROD_IMAGE + image: mongo:7.0 + environments: [prod]