diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b3496da0..382612a0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -272,27 +272,16 @@ jobs: # On failure, collect and print Docker container logs for debugging - name: "EXEC: {Collect Docker logs on failure}, independent" run: | - echo "+++++++++++ foc-devnet version run" - cat ~/.foc-devnet/state/latest/version.txt 2>/dev/null || echo "No version file found" - - echo "+++++++++++ Listing runs in foc-devnet..." - ls -1 ~/.foc-devnet/run/ 2>/dev/null || echo "No runs directory found" - - echo "+++++++++++ Get latest run ID" - LATEST_RUN=$(ls -t ~/.foc-devnet/run/ 2>/dev/null | head -1) - if [ -n "$LATEST_RUN" ]; then - echo "Latest run: $LATEST_RUN" - RUN_DIR="$HOME/.foc-devnet/run/$LATEST_RUN" - else - RUN_DIR="$HOME/.foc-devnet/state/latest" - fi + RUN_DIR="$HOME/.foc-devnet/state/latest" + + echo "+++++++++++ foc-devnet version" + cat "$RUN_DIR/version.txt" 2>/dev/null || echo "No version file found" - echo "+++++++++++ Disk space..." + echo "+++++++++++ Disk space" sudo df -h 2>/dev/null || echo "df command failed" - echo "+++++++++++ Latest Run Directory" - ls -lath "$RUN_DIR" 2>/dev/null || echo "No run directory found at $RUN_DIR" - ls -lath "$RUN_DIR/logs" 2>/dev/null || echo "No logs directory found at $RUN_DIR/logs" + echo "+++++++++++ Run Directory Contents" + ls -lath "$RUN_DIR" 2>/dev/null || echo "No run directory found" echo "+++++++++++ Contract Addresses" cat "$RUN_DIR/contract_addresses.json" 2>/dev/null || echo "No contract addresses file found" @@ -300,7 +289,7 @@ jobs: echo "+++++++++++ Step Context" cat "$RUN_DIR/step_context.json" 2>/dev/null || echo "No step context file found" - echo "+++++++++++ FOC metadata" + echo "+++++++++++ FOC Metadata" cat "$RUN_DIR/foc_metadata.json" 2>/dev/null || echo "No foc metadata file found" echo "+++++++++++ Container Logs" @@ -308,13 +297,12 @@ jobs: for logfile in "$RUN_DIR/logs"/*; do if [ -f "$logfile" ]; then echo "" - echo "šŸ“°šŸ“°šŸ“°šŸ“°šŸ“°šŸ“°šŸ“°šŸ“°šŸ“°šŸ“°šŸ“° Logs from $(basename "$logfile") šŸ“°šŸ“°šŸ“°šŸ“°šŸ“°šŸ“°šŸ“°šŸ“°šŸ“°šŸ“°šŸ“°šŸ“°šŸ“°" + echo "šŸ“° Logs from $(basename "$logfile") šŸ“°" cat "$logfile" 2>/dev/null || echo "Failed to read $logfile" - echo "šŸ“°šŸ“°šŸ“°šŸ“°šŸ“°šŸ“°šŸ“°šŸ“°šŸ“°šŸ“°šŸ“°šŸ“°šŸ“°šŸ“°šŸ“°šŸ“°šŸ“°šŸ“°šŸ“°šŸ“°šŸ“°šŸ“°šŸ“°šŸ“°šŸ“°šŸ“°šŸ“°šŸ“°šŸ“°šŸ“°šŸ“°šŸ“°šŸ“°šŸ“°šŸ“°šŸ“°šŸ“°" fi done else - echo "No container logs directory found at $RUN_DIR/logs" + echo "No container logs directory found" fi # Verify cluster is running correctly @@ -326,6 +314,34 @@ jobs: echo "Containers using foc-* images (running or exited):" docker ps -a --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}' + # Verify devnet-info.json was exported successfully + - name: "CHECK: {Verify devnet-info.json exists}" + if: steps.start_cluster.outcome == 'success' + run: | + DEVNET_INFO="$HOME/.foc-devnet/state/latest/devnet-info.json" + test -f "$DEVNET_INFO" || exit 1 + echo "āœ“ devnet-info.json created" + jq '.version' "$DEVNET_INFO" + + # Setup Node.js for JavaScript examples + - name: "EXEC: {Setup Node.js}, independent" + if: steps.start_cluster.outcome == 'success' + uses: actions/setup-node@v4 + with: + node-version: '20' + + # Validate schema using zod + - name: "CHECK: {Validate devnet-info.json schema}" + if: steps.start_cluster.outcome == 'success' + run: | + DEVNET_INFO="$HOME/.foc-devnet/state/latest/devnet-info.json" + cd examples + npm install + node validate-schema.js "$DEVNET_INFO" + node read-devnet-info.js "$DEVNET_INFO" + node check-balances.js "$DEVNET_INFO" + echo "āœ“ All examples ran well" + # Clean shutdown - name: "EXEC: {Stop cluster}, independent" run: ./foc-devnet stop diff --git a/.gitignore b/.gitignore index c1654137..8a22f1cf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ target/ contracts/MockUSDFC/lib/ contracts/MockUSDFC/broadcast/ -artifacts/ \ No newline at end of file +artifacts/ +.vscode/ \ No newline at end of file diff --git a/README.md b/README.md index ff98b663..23ecc9c3 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,10 @@ This will: **That's it!** Your local Filecoin network is running. +### Step 4: Use the Network + +See [examples/README.md](examples/README.md) for how you can easily consume network addresses, parameters, etc. and hook them into Synapse, etc. + --- ## ✨ Key Features @@ -161,6 +165,12 @@ See **[README_ADVANCED.md](README_ADVANCED.md)** for comprehensive documentation --- +## 🚶 Examples + +See [examples/README.md](examples/README.md). + +--- + ## šŸ“ License MIT License - see [LICENSE](LICENSE) file for details. diff --git a/examples/.eslintrc.json b/examples/.eslintrc.json new file mode 100644 index 00000000..ff2986c3 --- /dev/null +++ b/examples/.eslintrc.json @@ -0,0 +1,20 @@ +{ + "env": { + "node": true, + "es2022": true + }, + "parserOptions": { + "ecmaVersion": 2022, + "sourceType": "module" + }, + "extends": "eslint:recommended", + "rules": { + "no-console": "off", + "no-unused-vars": [ + "error", + { + "argsIgnorePattern": "^_" + } + ] + } +} diff --git a/examples/.gitignore b/examples/.gitignore new file mode 100644 index 00000000..504afef8 --- /dev/null +++ b/examples/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +package-lock.json diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 00000000..81796bc7 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,44 @@ +# FOC DevNet Examples + +This directory contains examples demonstrating how to interact with a running FOC DevNet instance using the exported `devnet-info.json` file. + +## Files + +- [read-devnet-info.js](read-devnet-info.js) - JavaScript example showing how to read and use the DevNet info +- [check-balances.js](check-balances.js) - JavaScript example demonstrating how to check user balances +- [devnet-schema.js](devnet-schema.js) - Zod schema for validating DevNet info exports +- [validate-schema.js](validate-schema.js) - CLI tool for validating devnet-info.json against the schema + +## Prerequisites + +- Node.js 20+ installed +- A running FOC DevNet instance (`foc-devnet start`) +- npm packages: `viem`, `zod` + +## Usage + +1. Start the DevNet: + ```bash + foc-devnet start + ``` + +2. Find the devnet-info.json file: + ```bash + # The file is located in the run directory + cat ~/.foc-devnet/state/latest/devnet-info.json + ``` + +3. Install dependencies and run examples: + ```bash + cd examples + npm install + + # Validate the schema + npm run validate-schema ~/.foc-devnet/state/latest/devnet-info.json + + # Read DevNet info + npm run read-info ~/.foc-devnet/state/latest/devnet-info.json + + # Check balances + npm run check-balances ~/.foc-devnet/state/latest/devnet-info.json + ``` diff --git a/examples/check-balances.js b/examples/check-balances.js new file mode 100644 index 00000000..ac3a3e91 --- /dev/null +++ b/examples/check-balances.js @@ -0,0 +1,159 @@ +/** + * FOC DevNet Balance Checker + * + * This example demonstrates how to use ethers.js to check balances + * of user accounts on the DevNet using the exported devnet-info.json. + * + * Usage: + * node check-balances.js [path-to-devnet-info.json] + * + * If no path is provided, defaults to ~/.foc-devnet/state/latest/devnet-info.json + */ + +import { readFileSync, existsSync } from "fs"; +import { homedir } from "os"; +import { join } from "path"; +import { createPublicClient, http, parseAbi } from "viem"; +import { formatUnits } from "viem"; + +// ERC20 ABI (minimal for balanceOf) +const ERC20_ABI = parseAbi([ + "function balanceOf(address owner) view returns (uint256)", + "function decimals() view returns (uint8)", + "function symbol() view returns (string)", +]); + +/** + * Load the devnet-info.json file. + * @param {string} filePath - Path to the devnet-info.json file + * @returns {object} The parsed DevNet info + */ +function loadDevnetInfo(filePath) { + if (!existsSync(filePath)) { + throw new Error(`DevNet info file not found: ${filePath}`); + } + const content = readFileSync(filePath, "utf8"); + return JSON.parse(content); +} + +/** + * Format wei to ether with specified decimals. + * @param {bigint} wei - Amount in wei + * @param {number} decimals - Decimal places + * @returns {string} Formatted amount + */ +function formatBalance(wei, decimals = 18) { + return formatUnits(wei, decimals); +} + +/** + * Check native FIL balance for an address. + * @param {object} client - Viem public client + * @param {string} address - Address to check + * @returns {Promise} Balance in FIL + */ +async function checkNativeBalance(client, address) { + const balance = await client.getBalance({ address }); + return formatBalance(balance); +} + +/** + * Check ERC20 token balance for an address. + * @param {object} client - Viem public client + * @param {string} tokenAddress - Token contract address + * @param {string} userAddress - User address to check + * @returns {Promise<{balance: string, symbol: string}>} Balance and symbol + */ +async function checkTokenBalance(client, tokenAddress, userAddress) { + const [balance, decimals, symbol] = await Promise.all([ + client.readContract({ + address: tokenAddress, + abi: ERC20_ABI, + functionName: "balanceOf", + args: [userAddress], + }), + client.readContract({ + address: tokenAddress, + abi: ERC20_ABI, + functionName: "decimals", + }), + client.readContract({ + address: tokenAddress, + abi: ERC20_ABI, + functionName: "symbol", + }), + ]); + return { + balance: formatBalance(balance, decimals), + symbol, + }; +} + +// Main execution +async function main() { + // Determine file path + const defaultPath = join( + homedir(), + ".foc-devnet", + "state", + "latest", + "devnet-info.json" + ); + const filePath = process.argv[2] || defaultPath; + + console.log(`Loading DevNet info from: ${filePath}\n`); + + try { + const { info } = loadDevnetInfo(filePath); + + // Connect to the Lotus RPC + const client = createPublicClient({ + transport: http(info.lotus.host_rpc_url), + }); + console.log(`Connected to: ${info.lotus.host_rpc_url}`); + + // Check if network is accessible + const blockNumber = await client.getBlockNumber(); + console.log(`Current block: ${blockNumber}\n`); + + console.log("═══════════════════════════════════════════════════════════"); + console.log(" Account Balances"); + console.log("═══════════════════════════════════════════════════════════\n"); + + // Check balances for all users + for (const user of info.users) { + console.log(`${user.name} (${user.evm_addr}):`); + + // Check native FIL balance + const filBalance = await checkNativeBalance(client, user.evm_addr); + console.log(` Native FIL: ${filBalance} tFIL`); + + // Check MockUSDFC balance + if (info.contracts.mockusdfc_addr) { + try { + const { balance, symbol } = await checkTokenBalance( + client, + info.contracts.mockusdfc_addr, + user.evm_addr + ); + console.log(` ${symbol}: ${balance}`); + } catch (e) { + console.log(` MockUSDFC: Error - ${e.message}`); + } + } + console.log(); + } + + console.log("═══════════════════════════════════════════════════════════\n"); + } catch (error) { + console.error(`Error: ${error.message}`); + if (error.code === "ECONNREFUSED") { + console.error( + "Could not connect to the DevNet. Make sure foc-devnet is running." + ); + } + process.exit(1); + } +} + +main(); diff --git a/examples/devnet-schema.js b/examples/devnet-schema.js new file mode 100644 index 00000000..93844a6f --- /dev/null +++ b/examples/devnet-schema.js @@ -0,0 +1,91 @@ +/** + * Zod schema for validating DevNet info exports. + * + * This schema ensures strict type checking and validates that all required + * fields are present with correct types. It's used by CI and examples to + * validate the exported devnet-info.json file. + */ + +import { z } from "zod"; + +const YugabyteInfo = z.object({ + web_ui_url: z.string().url(), + master_rpc_port: z.number().int().positive(), + ysql_port: z.number().int().positive(), +}); + +const CurioInfo = z.object({ + provider_id: z.number().int().positive(), + eth_addr: z.string().startsWith("0x"), + native_addr: z.string().min(1), + pdp_service_url: z.string().url(), + container_id: z.string().min(1), + container_name: z.string().min(1), + is_approved: z.boolean(), + yugabyte: YugabyteInfo, +}); + +const ContractsInfo = z.object({ + multicall3_addr: z.string().startsWith("0x"), + mockusdfc_addr: z.string().startsWith("0x"), + fwss_service_proxy_addr: z.string().startsWith("0x"), + fwss_state_view_addr: z.string().startsWith("0x"), + fwss_impl_addr: z.string().startsWith("0x"), + pdp_verifier_proxy_addr: z.string().startsWith("0x"), + pdp_verifier_impl_addr: z.string().startsWith("0x"), + service_provider_registry_proxy_addr: z.string().startsWith("0x"), + service_provider_registry_impl_addr: z.string().startsWith("0x"), + filecoin_pay_v1_addr: z.string().startsWith("0x"), + endorsements_addr: z.string().startsWith("0x"), +}); + +const UserInfo = z.object({ + name: z.string().regex(/^USER_\d+$/), + evm_addr: z.string().startsWith("0x"), + native_addr: z.string().min(1), + private_key_hex: z.string().startsWith("0x"), +}); + +const LotusInfo = z.object({ + host_rpc_url: z.string().url(), + container_id: z.string().min(1), + container_name: z.string().min(1), +}); + +const LotusMinerInfo = z.object({ + container_id: z.string().min(1), + container_name: z.string().min(1), + api_port: z.number().int().positive(), +}); + +const DevnetInfoV1 = z.object({ + run_id: z.string().min(1), + start_time: z.string(), + startup_duration: z.string().min(1), + users: z.array(UserInfo).min(1), + contracts: ContractsInfo, + lotus: LotusInfo, + lotus_miner: LotusMinerInfo, + pdp_sps: z.array(CurioInfo).min(1), +}); + +export const VersionedDevnetInfo = z.object({ + version: z.literal(1), + info: DevnetInfoV1, +}); + +/** + * Validate DevNet info against schema. + * @param {object} data - The parsed JSON data to validate + * @returns {object} The validated data if successful + * @throws {Error} If validation fails + */ +export function validateDevnetInfo(data) { + try { + return VersionedDevnetInfo.parse(data); + } catch (error) { + throw new Error( + `DevNet info schema validation failed: ${error.message}` + ); + } +} diff --git a/examples/package.json b/examples/package.json new file mode 100644 index 00000000..b2b9b2eb --- /dev/null +++ b/examples/package.json @@ -0,0 +1,17 @@ +{ + "name": "foc-devnet-examples", + "version": "1.0.0", + "description": "Examples for interacting with FOC DevNet", + "type": "module", + "scripts": { + "read-info": "node read-devnet-info.js", + "check-balances": "node check-balances.js" + }, + "dependencies": { + "viem": "^2.0.0", + "zod": "^3.22.0" + }, + "engines": { + "node": ">=24.0.0" + } +} diff --git a/examples/read-devnet-info.js b/examples/read-devnet-info.js new file mode 100644 index 00000000..73c9bcf6 --- /dev/null +++ b/examples/read-devnet-info.js @@ -0,0 +1,142 @@ +/** + * FOC DevNet Info Reader + * + * This example demonstrates how to read and use the devnet-info.json file + * exported by foc-devnet after a successful start. + * + * Usage: + * node read-devnet-info.js [path-to-devnet-info.json] + * + * If no path is provided, defaults to ~/.foc-devnet/state/latest/devnet-info.json + */ + +import { readFileSync, existsSync } from "fs"; +import { homedir } from "os"; +import { join } from "path"; +import { validateDevnetInfo } from "./devnet-schema.js"; + +/** + * Load and validate the devnet-info.json file. + * @param {string} filePath - Path to the devnet-info.json file + * @returns {object} The parsed and validated DevNet info + */ +function loadDevnetInfo(filePath) { + if (!existsSync(filePath)) { + throw new Error(`DevNet info file not found: ${filePath}`); + } + + const content = readFileSync(filePath, "utf8"); + const data = JSON.parse(content); + + // Validate against schema - this will throw if invalid + return validateDevnetInfo(data); +} + +/** + * Print summary of the DevNet state. + * @param {object} info - The DevnetInfoV1 object + */ +function printSummary(info) { + console.log("\n═══════════════════════════════════════════════════════════"); + console.log(" FOC DevNet Summary"); + console.log("═══════════════════════════════════════════════════════════\n"); + + console.log(`Run ID: ${info.run_id}`); + console.log(`Start Time: ${info.start_time}`); + console.log(`Startup Duration: ${info.startup_duration}`); +} + +/** + * Print Lotus node information. + * @param {object} lotus - The LotusInfo object + */ +function printLotusInfo(lotus) { + console.log("\n─── Lotus Node ───────────────────────────────────────────\n"); + console.log(`RPC URL: ${lotus.host_rpc_url}`); + console.log(`Container: ${lotus.container_name}`); + console.log(`Container ID: ${lotus.container_id.substring(0, 12)}...`); +} + +/** + * Print deployed contract addresses. + * @param {object} contracts - The ContractsInfo object + */ +function printContracts(contracts) { + console.log("\n─── Deployed Contracts ───────────────────────────────────\n"); + console.log(`MockUSDFC: ${contracts.mockusdfc_addr}`); + console.log(`Multicall3: ${contracts.multicall3_addr}`); + console.log(`FWSS Proxy: ${contracts.fwss_service_proxy_addr}`); + console.log(`PDP Verifier Proxy: ${contracts.pdp_verifier_proxy_addr}`); + console.log(`Service Provider Registry: ${contracts.service_provider_registry_proxy_addr}`); + console.log(`FilecoinPay V1: ${contracts.filecoin_pay_v1_addr}`); + console.log(`Endorsements: ${contracts.endorsements_addr}`); +} + +/** + * Print user account information. + * @param {Array} users - Array of UserInfo objects + */ +function printUsers(users) { + console.log("\n─── User Accounts ────────────────────────────────────────\n"); + + for (const user of users) { + console.log(`${user.name}:`); + console.log(` EVM Address: ${user.evm_addr}`); + console.log(` Native Address: ${user.native_addr}`); + console.log(` Private Key: ${user.private_key_hex.substring(0, 10)}...`); + console.log(); + } +} + +/** + * Print PDP service provider information. + * @param {Array} providers - Array of CurioInfo objects + */ +function printCurioProviders(providers) { + console.log("\n─── PDP Service Providers ────────────────────────────────\n"); + + for (const provider of providers) { + console.log(`Provider ${provider.provider_id}:`); + console.log(` ETH Address: ${provider.eth_addr}`); + console.log(` PDP Service URL: ${provider.pdp_service_url}`); + console.log(` Container: ${provider.container_name}`); + console.log(` Container ID: ${provider.container_id.substring(0, 12)}...`); + console.log(` YugabyteDB:`); + console.log(` Web UI: ${provider.yugabyte.web_ui_url}`); + console.log(` YSQL Port: ${provider.yugabyte.ysql_port}`); + console.log(); + } +} + +// Main execution +function main() { + // Determine file path + const defaultPath = join( + homedir(), + ".foc-devnet", + "state", + "latest", + "devnet-info.json" + ); + const filePath = process.argv[2] || defaultPath; + + console.log(`Loading DevNet info from: ${filePath}`); + + try { + const { version, info } = loadDevnetInfo(filePath); + console.log(`Schema version: ${version}`); + + printSummary(info); + printLotusInfo(info.lotus); + printContracts(info.contracts); + printUsers(info.users); + printCurioProviders(info.pdp_sps); + + console.log("═══════════════════════════════════════════════════════════\n"); + } catch (error) { + console.error(`Error: ${error.message}`); + process.exit(1); + } +} + +main(); diff --git a/examples/validate-schema.js b/examples/validate-schema.js new file mode 100644 index 00000000..6bd2ef40 --- /dev/null +++ b/examples/validate-schema.js @@ -0,0 +1,28 @@ +#!/usr/bin/env node +/** + * Schema validation script for DevNet info export. + * Used by CI to validate the exported devnet-info.json file. + */ + +import { readFileSync } from "fs"; +import { validateDevnetInfo } from "./devnet-schema.js"; + +const devnetInfoPath = process.argv[2]; + +if (!devnetInfoPath) { + console.error("Usage: node validate-schema.js "); + process.exit(1); +} + +try { + const content = readFileSync(devnetInfoPath, "utf8"); + const data = JSON.parse(content); + + validateDevnetInfo(data); + console.log("āœ“ Schema validation passed"); + process.exit(0); +} catch (error) { + console.error("āœ— Schema validation failed:"); + console.error(error.message); + process.exit(1); +} diff --git a/src/commands/start/curio/daemon.rs b/src/commands/start/curio/daemon.rs index 18ef72ab..ac6eb2ab 100644 --- a/src/commands/start/curio/daemon.rs +++ b/src/commands/start/curio/daemon.rs @@ -25,8 +25,7 @@ use tracing::info; /// 2. Build Docker run command with proper volumes and env vars /// 3. Start container with sleep infinity /// 4. Run curio daemon in background -/// 5. Wait for API to be ready -/// 6. Store allocated ports in context for later use +/// 5. Store container ID and name in context for later use pub fn start_curio_daemon( context: &SetupContext, _step: &CurioStep, @@ -46,9 +45,16 @@ pub fn start_curio_daemon( // Create necessary directories create_curio_directories(context, sp_index)?; - // Step 2: Create and start container + // Step 2: Create and start container, capturing container ID let docker_args = build_docker_create_args(context, sp_index, &container_name)?; - start_curio_container(context, &container_name, docker_args)?; + let container_id = start_curio_container(context, &container_name, docker_args)?; + + // Store container info in context for export + context.set(format!("pdp_sp_{}_container_id", sp_index), container_id); + context.set( + format!("pdp_sp_{}_container_name", sp_index), + container_name, + ); Ok(()) } @@ -79,11 +85,13 @@ fn create_curio_directories(context: &SetupContext, sp_index: usize) -> Result<( /// 1. Container is created but not started /// 2. Networks are connected while container is stopped /// 3. Container is started with Curio as PID 1 (logs work properly) +/// +/// Returns the container ID from docker create stdout fn start_curio_container( context: &SetupContext, container_name: &str, mut docker_args: Vec, -) -> Result<(), Box> { +) -> Result> { info!("Creating container {}...", container_name); // Add image and command - Curio as main process @@ -106,6 +114,13 @@ fn start_curio_container( .into()); } + // Extract container ID from stdout + let container_id = String::from_utf8_lossy(&output.stdout).trim().to_string(); + + if container_id.is_empty() { + return Err("Docker create did not return container ID".into()); + } + // Connect to filecoin network before starting let lotus_network = lotus_network_name(context.run_id()); let network_args = vec![ @@ -132,7 +147,7 @@ fn start_curio_container( info!("Container created and started"); - Ok(()) + Ok(container_id) } /// Build docker create arguments for Curio @@ -157,19 +172,19 @@ fn build_docker_create_args( // Port mappings - get dynamically allocated ports from context let api_port: u16 = context - .get(&format!("curio_sp_{}_api_port", sp_index)) + .get(&format!("pdp_sp_{}_api_port", sp_index)) .ok_or("Curio API port not found in context")? .parse()?; let api_port_alt: u16 = context - .get(&format!("curio_sp_{}_api_port_alt", sp_index)) + .get(&format!("pdp_sp_{}_api_port_alt", sp_index)) .ok_or("Curio API alt port not found in context")? .parse()?; let gui_port: u16 = context - .get(&format!("curio_sp_{}_gui_port", sp_index)) + .get(&format!("pdp_sp_{}_gui_port", sp_index)) .ok_or("Curio GUI port not found in context")? .parse()?; let pdp_port: u16 = context - .get(&format!("curio_sp_{}_pdp_port", sp_index)) + .get(&format!("pdp_sp_{}_pdp_port", sp_index)) .ok_or("Curio PDP port not found in context")? .parse()?; diff --git a/src/commands/start/curio/pre_execute.rs b/src/commands/start/curio/pre_execute.rs index 65d4bdcf..544b3ae5 100644 --- a/src/commands/start/curio/pre_execute.rs +++ b/src/commands/start/curio/pre_execute.rs @@ -48,19 +48,19 @@ pub fn verify_prerequisites(context: &SetupContext, sp_count: usize) -> Result<( let pdp_port = context.allocate_port()?; context.set( - format!("curio_sp_{}_api_port", sp_index), + format!("pdp_sp_{}_api_port", sp_index), api_port.to_string(), ); context.set( - format!("curio_sp_{}_api_port_alt", sp_index), + format!("pdp_sp_{}_api_port_alt", sp_index), api_port_alt.to_string(), ); context.set( - format!("curio_sp_{}_gui_port", sp_index), + format!("pdp_sp_{}_gui_port", sp_index), gui_port.to_string(), ); context.set( - format!("curio_sp_{}_pdp_port", sp_index), + format!("pdp_sp_{}_pdp_port", sp_index), pdp_port.to_string(), ); diff --git a/src/commands/start/curio/verification.rs b/src/commands/start/curio/verification.rs index 9e18a778..e0fb4d0a 100644 --- a/src/commands/start/curio/verification.rs +++ b/src/commands/start/curio/verification.rs @@ -43,7 +43,7 @@ fn verify_pdp_ping(context: &SetupContext, sp_index: usize) -> Result<(), Box Result> { // Get dynamically allocated PDP port from context (external port) let port: u16 = context - .get(&format!("curio_sp_{}_pdp_port", sp_index)) + .get(&format!("pdp_sp_{}_pdp_port", sp_index)) .ok_or("Curio PDP port not found in context")? .parse()?; @@ -199,7 +199,7 @@ fn download_piece( ) -> Result, Box> { // Get dynamically allocated PDP port from context let port: u16 = context - .get(&format!("curio_sp_{}_pdp_port", sp_index)) + .get(&format!("pdp_sp_{}_pdp_port", sp_index)) .ok_or("Curio PDP port not found in context")? .parse()?; diff --git a/src/commands/start/mod.rs b/src/commands/start/mod.rs index fb09d895..4d0afaee 100644 --- a/src/commands/start/mod.rs +++ b/src/commands/start/mod.rs @@ -331,7 +331,7 @@ fn execute_cluster_steps( parallel: bool, portainer_port: u16, notest: bool, -) -> Result<(), Box> { +) -> Result> { // Ensure genesis prerequisites are ready (one-time setup, needs config for sector count) ensure_genesis_prerequisites(config.active_pdp_sp_count, run_id)?; @@ -352,6 +352,16 @@ fn execute_cluster_steps( config.active_pdp_sp_count, config.approved_pdp_sp_count ); + let step_config = step::StepExecutionConfig { + run_id: run_id.to_string(), + run_dir: run_dir.to_path_buf(), + port_start: config.port_range_start, + port_count: config.port_range_count, + portainer_port: Some(portainer_port), + active_pdp_sp_count: config.active_pdp_sp_count, + approved_pdp_sp_count: config.approved_pdp_sp_count, + }; + if parallel { info!("Execution mode: PARALLEL (experimental)"); let step_epochs = create_step_epochs(volumes_dir, run_dir, config, notest); @@ -362,37 +372,18 @@ fn execute_cluster_steps( .map(|epoch| epoch.iter().map(|s| s.as_ref()).collect()) .collect(); - execute_steps_parallel( - epoch_refs, - step::StepExecutionConfig { - run_id: run_id.to_string(), - run_dir: run_dir.to_path_buf(), - port_start: config.port_range_start, - port_count: config.port_range_count, - portainer_port: Some(portainer_port), - active_pdp_sp_count: config.active_pdp_sp_count, - approved_pdp_sp_count: config.approved_pdp_sp_count, - }, - )?; + let context = execute_steps_parallel(epoch_refs, step_config)?; + Ok(context) } else { info!("Execution mode: SEQUENTIAL"); let steps = create_steps(volumes_dir, run_dir, config, notest); - execute_steps( + let context = execute_steps( steps.iter().map(|s| s.as_ref()).collect::>(), - step::StepExecutionConfig { - run_id: run_id.to_string(), - run_dir: run_dir.to_path_buf(), - port_start: config.port_range_start, - port_count: config.port_range_count, - portainer_port: Some(portainer_port), - active_pdp_sp_count: config.active_pdp_sp_count, - approved_pdp_sp_count: config.approved_pdp_sp_count, - }, + step_config, )?; + Ok(context) } - - Ok(()) } /// Start the local Filecoin network cluster. @@ -447,11 +438,26 @@ pub fn start_cluster( warn!("Post-start teardown encountered an error: {}", e); } - // Propagate original execution result - exec_result?; - - info!("Cluster started successfully!"); - Ok(()) + // Export devnet info if steps succeeded + match exec_result { + Ok(context) => { + // Export the devnet info JSON for external consumers + if let Err(e) = crate::external_api::export_devnet_info(&context) { + warn!("Failed to export devnet info: {}", e); + } else { + info!( + "āœ“ DevNet info exported to: {}", + crate::paths::devnet_info_file(context.run_id()).display() + ); + } + info!("Cluster started successfully!"); + Ok(()) + } + Err(e) => { + warn!("Cluster startup failed, external devnet information not exported"); + Err(e) + } + } } /// Finalize the start attempt by collecting logs, cleaning dead containers, and writing status. diff --git a/src/commands/start/pdp_service_provider/pdp_service_provider_step.rs b/src/commands/start/pdp_service_provider/pdp_service_provider_step.rs index beb86945..d6e6e3fc 100644 --- a/src/commands/start/pdp_service_provider/pdp_service_provider_step.rs +++ b/src/commands/start/pdp_service_provider/pdp_service_provider_step.rs @@ -93,7 +93,7 @@ impl Step for PdpSpRegistrationStep { for sp_index in 1..=self.active_sp_count { let pdp_key = format!("pdp_sp_{}_address", sp_index); let eth_key = format!("pdp_sp_{}_eth_address", sp_index); - let port_key = format!("curio_sp_{}_pdp_port", sp_index); + let port_key = format!("pdp_sp_{}_pdp_port", sp_index); let sp_address = context .get(&pdp_key) @@ -169,7 +169,7 @@ impl Step for PdpSpRegistrationStep { for sp_index in 1..=self.active_sp_count { let pdp_key = format!("pdp_sp_{}_address", sp_index); let eth_key = format!("pdp_sp_{}_eth_address", sp_index); - let port_key = format!("curio_sp_{}_pdp_port", sp_index); + let port_key = format!("pdp_sp_{}_pdp_port", sp_index); let sp_address = context .get(&pdp_key) @@ -217,6 +217,12 @@ impl Step for PdpSpRegistrationStep { context, ) { Ok(provider_id) => { + // Store is_approved status in context + context.set( + format!("pdp_sp_{}_is_approved", sp_index), + should_approve.to_string(), + ); + // Only approve if within approved count if should_approve { if let Err(e) = registration::add_to_approved_list( diff --git a/src/commands/start/step/mod.rs b/src/commands/start/step/mod.rs index cbf97c57..20a39618 100644 --- a/src/commands/start/step/mod.rs +++ b/src/commands/start/step/mod.rs @@ -323,7 +323,7 @@ pub struct StepExecutionConfig { pub fn execute_steps( steps: Vec<&dyn Step>, config: StepExecutionConfig, -) -> Result<(), Box> { +) -> Result> { // Create port allocator and verify all ports are available let mut port_allocator = PortAllocator::new(config.port_start, config.port_count)?; @@ -391,7 +391,7 @@ pub fn execute_steps( .collect(); context.set_multi(timing_items); - Ok(()) + Ok(context) } /// Execute steps organized in parallel epochs @@ -410,11 +410,11 @@ pub fn execute_steps( /// /// # Returns /// -/// Returns Ok(()) if all steps in all epochs complete successfully, or an error if any step fails. +/// Returns Ok(SetupContext) if all steps in all epochs complete successfully, or an error if any step fails. pub fn execute_steps_parallel( step_epochs: Vec>, config: StepExecutionConfig, -) -> Result<(), Box> { +) -> Result> { // Create port allocator and verify all ports are available let mut port_allocator = PortAllocator::new(config.port_start, config.port_count)?; @@ -487,7 +487,10 @@ pub fn execute_steps_parallel( warn!("Failed to save step context: {}", e); } - Ok(()) + // Unwrap Arc to return owned SetupContext + let context = Arc::try_unwrap(context).map_err(|_| "Failed to unwrap Arc")?; + + Ok(context) } /// Execute a single epoch of steps (either sequentially or in parallel) diff --git a/src/commands/start/usdfc_funding/usdfc_funding_step.rs b/src/commands/start/usdfc_funding/usdfc_funding_step.rs index d287b9a8..299e0f1c 100644 --- a/src/commands/start/usdfc_funding/usdfc_funding_step.rs +++ b/src/commands/start/usdfc_funding/usdfc_funding_step.rs @@ -8,6 +8,7 @@ use super::funding_operations::{self, check_mock_usdfc_balance, transfer_mock_us use super::key_operations::get_user_private_key; use crate::commands::start::step::{SetupContext, Step}; use crate::commands::start::usdfc_funding::key_operations::get_user_eth_address; +use crate::constants::USER_ACCOUNT_COUNT; use crate::docker::containers::lotus_container_name; use crate::docker::core::container_is_running; use std::error::Error; @@ -41,7 +42,7 @@ impl USDFCFundingStep { let mut accounts_to_check = Vec::new(); // Add user accounts (base-1 numbering) - for user_num in 1..=3 { + for user_num in 1..=USER_ACCOUNT_COUNT { let account_name = format!("USER_{}", user_num); accounts_to_check.push((account_name, 100_000u64)); } @@ -250,7 +251,7 @@ impl Step for USDFCFundingStep { let mut token_transfers = Vec::new(); // Add user accounts (base-1 numbering) - for user_num in 1..=3 { + for user_num in 1..=USER_ACCOUNT_COUNT { let account_name = format!("USER_{}", user_num); let eth_address = get_user_eth_address(&account_name)?; let amount_tokens = 100_000u64; @@ -291,7 +292,7 @@ impl Step for USDFCFundingStep { // Build list of accounts to verify let mut accounts_to_verify = Vec::new(); - for user_num in 1..=3 { + for user_num in 1..=USER_ACCOUNT_COUNT { let account_name = format!("USER_{}", user_num); accounts_to_verify.push((account_name, 100_000u64)); } diff --git a/src/constants.rs b/src/constants.rs index 64532323..b6f356ab 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -41,6 +41,9 @@ pub const PORT_CHECK_TIMEOUT_MS: u64 = 5000; /// PDP Service Provider configuration pub const MAX_PDP_SP_COUNT: usize = 5; +/// Number of user test accounts (USER_1, USER_2, USER_3) +pub const USER_ACCOUNT_COUNT: usize = 3; + /// Service configuration pub const SERVICE_NAME: &str = "FOC DevNet Warm Storage"; pub const SERVICE_DESCRIPTION: &str = "Warm storage service for FOC local development network"; diff --git a/src/external_api/devnet_info.rs b/src/external_api/devnet_info.rs new file mode 100644 index 00000000..1c146396 --- /dev/null +++ b/src/external_api/devnet_info.rs @@ -0,0 +1,134 @@ +//! DevNet information data structures. +//! +//! Contains the versioned schema for exporting DevNet state to external consumers. + +use serde::{Deserialize, Serialize}; + +/// Versioned wrapper for DevNet information. +/// +/// This struct provides a stable interface for external consumers. The `version` +/// field indicates the schema version, allowing consumers to handle migrations. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VersionedDevnetInfo { + /// Schema version number. Increment when breaking changes are made. + pub version: u32, + /// The actual DevNet information payload. + pub info: DevnetInfoV1, +} + +/// DevNet information schema version 1. +/// +/// Contains all relevant information about a running DevNet instance. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DevnetInfoV1 { + /// Unique identifier for this run (e.g., "26jan20-1622_GoofyNubs") + pub run_id: String, + /// ISO 8601 formatted start time + pub start_time: String, + /// Time taken to start the system (e.g., "539.04s") + pub startup_duration: String, + /// User accounts available for testing + pub users: Vec, + /// Deployed contract addresses + pub contracts: ContractsInfo, + /// Lotus node information + pub lotus: LotusInfo, + /// Lotus miner information + pub lotus_miner: LotusMinerInfo, + /// PDP service providers + pub pdp_sps: Vec, +} + +/// Information about a user account. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserInfo { + /// User identifier (e.g., "USER_1") + pub name: String, + /// Ethereum-compatible address (0x...) + pub evm_addr: String, + /// Native Filecoin address (t410f...) + pub native_addr: String, + /// Private key in hex format (without 0x prefix) + pub private_key_hex: String, +} + +/// Deployed contract addresses. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContractsInfo { + /// Multicall3 contract address + pub multicall3_addr: String, + /// MockUSDFC token contract address + pub mockusdfc_addr: String, + /// Filecoin Warm Storage Service proxy address + pub fwss_service_proxy_addr: String, + /// Filecoin Warm Storage Service state view address + pub fwss_state_view_addr: String, + /// Filecoin Warm Storage Service implementation address + pub fwss_impl_addr: String, + /// PDP Verifier proxy address + pub pdp_verifier_proxy_addr: String, + /// PDP Verifier implementation address + pub pdp_verifier_impl_addr: String, + /// Service Provider Registry proxy address + pub service_provider_registry_proxy_addr: String, + /// Service Provider Registry implementation address + pub service_provider_registry_impl_addr: String, + /// FilecoinPay V1 contract address + pub filecoin_pay_v1_addr: String, + /// Endorsements contract address + pub endorsements_addr: String, +} + +/// Lotus node information. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LotusInfo { + /// RPC URL exposed to the host + pub host_rpc_url: String, + /// Docker container ID + pub container_id: String, + /// Docker container name + pub container_name: String, +} + +/// Lotus miner information. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LotusMinerInfo { + /// Docker container ID + pub container_id: String, + /// Docker container name + pub container_name: String, + /// API port + pub api_port: u16, +} + +/// Curio service provider information. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CurioInfo { + /// Provider ID (1-indexed) + pub provider_id: u32, + /// Ethereum address of the provider + pub eth_addr: String, + /// Native Filecoin address + pub native_addr: String, + /// PDP service URL accessible from host + pub pdp_service_url: String, + /// Docker container ID + pub container_id: String, + /// Docker container name + pub container_name: String, + /// Whether this provider is approved in FWSS + pub is_approved: bool, + /// YugabyteDB information for this provider + pub yugabyte: YugabyteInfo, +} + +/// YugabyteDB connection information. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct YugabyteInfo { + /// Web UI URL exposed to host + pub web_ui_url: String, + /// Master RPC port + pub master_rpc_port: u16, + /// YSQL port for Postgres-compatible connections + pub ysql_port: u16, +} diff --git a/src/external_api/export.rs b/src/external_api/export.rs new file mode 100644 index 00000000..c22d8c56 --- /dev/null +++ b/src/external_api/export.rs @@ -0,0 +1,295 @@ +//! Export DevNet information from SetupContext. +//! +//! This module extracts data from the SetupContext and produces a +//! VersionedDevnetInfo structure that can be serialized to JSON. + +use std::path::Path; + +use chrono::Utc; + +use crate::commands::start::step::SetupContext; +use crate::constants::USER_ACCOUNT_COUNT; +use crate::crypto::derive_ethereum_key; +use crate::crypto::mnemonic::load_mnemonic; +use crate::external_api::{ + ContractsInfo, CurioInfo, DevnetInfoV1, LotusInfo, LotusMinerInfo, UserInfo, + VersionedDevnetInfo, YugabyteInfo, DEVNET_INFO_SCHEMA_VERSION, +}; +use crate::paths; + +/// Export DevNet information to a JSON file. +/// +/// Extracts all relevant information from the SetupContext and writes +/// it to `devnet-info.json` in the run directory. +pub fn export_devnet_info(context: &SetupContext) -> Result<(), Box> { + let info = build_devnet_info(context)?; + let versioned = VersionedDevnetInfo { + version: DEVNET_INFO_SCHEMA_VERSION, + info, + }; + + let output_path = paths::devnet_info_file(context.run_id()); + write_json_file(&output_path, &versioned)?; + + tracing::info!("Exported DevNet info to: {}", output_path.display()); + Ok(()) +} + +/// Build DevnetInfoV1 from SetupContext. +fn build_devnet_info(ctx: &SetupContext) -> Result> { + Ok(DevnetInfoV1 { + run_id: ctx.run_id().to_string(), + start_time: Utc::now().to_rfc3339(), + startup_duration: ctx + .get("step_timing_total_execution_time") + .expect("step_timing_total_execution_time not found in context"), + users: build_users(ctx)?, + contracts: build_contracts(ctx)?, + lotus: build_lotus_info(ctx)?, + lotus_miner: build_lotus_miner_info(ctx)?, + pdp_sps: build_pdp_service_providers(ctx)?, + }) +} + +/// Build user information from context and mnemonic. +fn build_users(ctx: &SetupContext) -> Result, Box> { + let mnemonic = load_mnemonic()?; + let seed = mnemonic.to_seed(""); + + let mut users = Vec::new(); + for i in 1..=USER_ACCOUNT_COUNT { + let name = format!("USER_{}", i); + let user = build_single_user(ctx, &name, &seed)?; + users.push(user); + } + Ok(users) +} + +/// Build a single user's info. +fn build_single_user( + ctx: &SetupContext, + name: &str, + seed: &[u8; 64], +) -> Result> { + let key_name = name.to_uppercase(); + let derived = derive_ethereum_key(seed, &key_name)?; + + let evm_addr = ctx + .get(&format!("{}_eth_address", name.to_lowercase())) + .or_else(|| derived.eth_address.clone()) + .ok_or(format!( + "{}_eth_address not found in context", + name.to_lowercase() + ))?; + + let native_addr = ctx + .get(&format!("{}_address", name.to_lowercase())) + .ok_or(format!( + "{}_address not found in context", + name.to_lowercase() + ))?; + + Ok(UserInfo { + name: name.to_string(), + evm_addr, + native_addr, + private_key_hex: format!("0x{}", derived.private_key), + }) +} + +/// Build contracts info from context. +fn build_contracts(ctx: &SetupContext) -> Result> { + Ok(ContractsInfo { + multicall3_addr: ctx + .get("multicall3_address") + .ok_or("Missing multicall3_address in context")?, + mockusdfc_addr: ctx + .get("mockusdfc_contract_address") + .ok_or("Missing mockusdfc_contract_address in context")?, + fwss_service_proxy_addr: ctx + .get("foc_contract_filecoin_warm_storage_service_proxy") + .ok_or("Missing foc_contract_filecoin_warm_storage_service_proxy in context")?, + fwss_state_view_addr: ctx + .get("foc_contract_filecoin_warm_storage_service_state_view") + .ok_or("Missing foc_contract_filecoin_warm_storage_service_state_view in context")?, + fwss_impl_addr: ctx + .get("foc_contract_filecoin_warm_storage_service_implementation") + .ok_or( + "Missing foc_contract_filecoin_warm_storage_service_implementation in context", + )?, + pdp_verifier_proxy_addr: ctx + .get("foc_contract_p_d_p_verifier_proxy") + .ok_or("Missing foc_contract_p_d_p_verifier_proxy in context")?, + pdp_verifier_impl_addr: ctx + .get("foc_contract_p_d_p_verifier_implementation") + .ok_or("Missing foc_contract_p_d_p_verifier_implementation in context")?, + service_provider_registry_proxy_addr: ctx + .get("foc_contract_service_provider_registry_proxy") + .ok_or("Missing foc_contract_service_provider_registry_proxy in context")?, + service_provider_registry_impl_addr: ctx + .get("foc_contract_service_provider_registry_implementation") + .ok_or("Missing foc_contract_service_provider_registry_implementation in context")?, + filecoin_pay_v1_addr: ctx + .get("foc_contract_filecoin_pay_v1_contract") + .ok_or("Missing foc_contract_filecoin_pay_v1_contract in context")?, + endorsements_addr: ctx + .get("foc_contract_endorsements") + .ok_or("Missing foc_contract_endorsements in context")?, + }) +} + +/// Build Lotus node info from context. +fn build_lotus_info(ctx: &SetupContext) -> Result> { + let api_port = ctx + .get("lotus_api_port") + .expect("lotus_api_port not found in context"); + Ok(LotusInfo { + host_rpc_url: format!("http://localhost:{}/rpc/v1", api_port), + container_id: ctx + .get("lotus_container_id") + .ok_or("Missing lotus_container_id in context")?, + container_name: ctx + .get("lotus_container_name") + .ok_or("Missing lotus_container_name in context")?, + }) +} + +/// Build Lotus miner info from context. +fn build_lotus_miner_info( + ctx: &SetupContext, +) -> Result> { + let api_port: u16 = ctx + .get("lotus_miner_api_port") + .and_then(|p| p.parse().ok()) + .expect("lotus_miner_api_port not found or invalid in context"); + + Ok(LotusMinerInfo { + container_id: ctx + .get("lotus_miner_container_id") + .ok_or("Missing lotus_miner_container_id in context")?, + container_name: ctx + .get("lotus_miner_container_name") + .ok_or("Missing lotus_miner_container_name in context")?, + api_port, + }) +} + +/// Build PDP service providers info from context. +fn build_pdp_service_providers( + ctx: &SetupContext, +) -> Result, Box> { + let active_count: usize = ctx + .get("active_pdp_sp_count") + .and_then(|s| s.parse().ok()) + .ok_or("active_pdp_sp_count not found or invalid in context")?; + + (1..=active_count) + .map(|id| build_single_pdp_service_provider(ctx, id as u32)) + .collect() +} + +/// Build a single PDP service provider's info. +fn build_single_pdp_service_provider( + ctx: &SetupContext, + provider_id: u32, +) -> Result> { + let eth_addr = ctx + .get(&format!("pdp_sp_{}_eth_address", provider_id)) + .ok_or(format!( + "pdp_sp_{}_eth_address not found in context", + provider_id + ))?; + let native_addr = ctx + .get(&format!("pdp_sp_{}_address", provider_id)) + .ok_or(format!( + "pdp_sp_{}_address not found in context", + provider_id + ))?; + let pdp_port: u16 = ctx + .get(&format!("pdp_sp_{}_pdp_port", provider_id)) + .and_then(|p| p.parse().ok()) + .ok_or(format!( + "pdp_sp_{}_pdp_port not found or invalid in context", + provider_id + ))?; + + let container_id = ctx + .get(&format!("pdp_sp_{}_container_id", provider_id)) + .ok_or(format!( + "pdp_sp_{}_container_id not found in context", + provider_id + ))?; + let container_name = ctx + .get(&format!("pdp_sp_{}_container_name", provider_id)) + .ok_or(format!( + "pdp_sp_{}_container_name not found in context", + provider_id + ))?; + + let is_approved = ctx + .get(&format!("pdp_sp_{}_is_approved", provider_id)) + .and_then(|v| v.parse::().ok()) + .ok_or(format!( + "pdp_sp_{}_is_approved not found or invalid in context", + provider_id + ))?; + + let yugabyte = build_yugabyte_info(ctx, provider_id)?; + + Ok(CurioInfo { + provider_id, + eth_addr, + native_addr, + pdp_service_url: format!("http://localhost:{}", pdp_port), + container_id, + container_name, + is_approved, + yugabyte, + }) +} + +/// Build YugabyteDB info for a provider. +fn build_yugabyte_info( + ctx: &SetupContext, + provider_id: u32, +) -> Result> { + let web_ui_port: u16 = ctx + .get(&format!("yugabyte_{}_web_ui_port", provider_id)) + .and_then(|p| p.parse().ok()) + .ok_or(format!( + "yugabyte_{}_web_ui_port not found or invalid in context", + provider_id + ))?; + + let master_rpc_port: u16 = ctx + .get(&format!("yugabyte_{}_master_rpc_port", provider_id)) + .and_then(|p| p.parse().ok()) + .ok_or(format!( + "yugabyte_{}_master_rpc_port not found or invalid in context", + provider_id + ))?; + + let ysql_port: u16 = ctx + .get(&format!("yugabyte_{}_ysql_port", provider_id)) + .and_then(|p| p.parse().ok()) + .ok_or(format!( + "yugabyte_{}_ysql_port not found or invalid in context", + provider_id + ))?; + + Ok(YugabyteInfo { + web_ui_url: format!("http://localhost:{}", web_ui_port), + master_rpc_port, + ysql_port, + }) +} + +/// Write a serializable struct to a JSON file. +fn write_json_file( + path: &Path, + data: &T, +) -> Result<(), Box> { + let json = serde_json::to_string_pretty(data)?; + std::fs::write(path, json)?; + Ok(()) +} diff --git a/src/external_api/mod.rs b/src/external_api/mod.rs new file mode 100644 index 00000000..90b6e788 --- /dev/null +++ b/src/external_api/mod.rs @@ -0,0 +1,17 @@ +//! External API module for DevNet information export. +//! +//! This module provides versioned, schema-based JSON output that can be consumed +//! by external tools and scripts (e.g., JavaScript, Python) to interact with +//! the running DevNet. + +mod devnet_info; +mod export; + +pub use devnet_info::{ + ContractsInfo, CurioInfo, DevnetInfoV1, LotusInfo, LotusMinerInfo, UserInfo, + VersionedDevnetInfo, YugabyteInfo, +}; +pub use export::export_devnet_info; + +/// Current schema version for DevNet info export. +pub const DEVNET_INFO_SCHEMA_VERSION: u32 = 1; diff --git a/src/lib.rs b/src/lib.rs index 1a603c4b..781ab041 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,6 +11,7 @@ pub mod constants; pub mod crypto; pub mod docker; pub mod embedded_assets; +pub mod external_api; pub mod logger; pub mod paths; pub mod poison; diff --git a/src/logger.rs b/src/logger.rs index 095e99ec..aac845d7 100644 --- a/src/logger.rs +++ b/src/logger.rs @@ -1,7 +1,5 @@ -use crate::paths::{foc_devnet_run_dir, foc_devnet_run_log_file, foc_devnet_state_latest}; +use crate::paths::foc_devnet_run_log_file; use std::fs; -use std::os::unix::fs::symlink; -use std::path::Path; use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; /// Initializes the logging system. @@ -10,9 +8,10 @@ use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, Env /// 1. A stdout layer with ANSI colors for terminal output. /// 2. A file layer without ANSI colors for the execution log. /// -/// It also updates the `state/latest` symlink to point to the current run directory. +/// Note: The `state/latest` symlink is managed by the start command's +/// `setup_directories_and_run_id()` function to ensure proper sequencing. pub fn init_logging(run_id: &str) -> Result<(), Box> { - let run_dir = foc_devnet_run_dir(run_id); + let run_dir = crate::paths::foc_devnet_run_dir(run_id); fs::create_dir_all(&run_dir)?; let log_file_path = foc_devnet_run_log_file(run_id); @@ -34,27 +33,5 @@ pub fn init_logging(run_id: &str) -> Result<(), Box> { .with(stdout_layer) .init(); - update_latest_symlink(&run_dir)?; - Ok(()) -} - -fn update_latest_symlink(run_dir: &Path) -> Result<(), Box> { - let latest = foc_devnet_state_latest(); - - // Remove existing symlink or directory if it exists - if latest.exists() || latest.is_symlink() { - if latest.is_symlink() || latest.is_file() { - fs::remove_file(&latest)?; - } else if latest.is_dir() { - fs::remove_dir_all(&latest)?; - } - } - - // Ensure parent directory exists - if let Some(parent) = latest.parent() { - fs::create_dir_all(parent)?; - } - - symlink(run_dir, latest)?; Ok(()) } diff --git a/src/paths.rs b/src/paths.rs index 46f6a542..c3526532 100644 --- a/src/paths.rs +++ b/src/paths.rs @@ -78,6 +78,12 @@ pub fn step_context_file(run_id: &str) -> PathBuf { foc_devnet_run_dir(run_id).join("step_context.json") } +/// Returns the path to the devnet info file for a specific run +/// This is the versioned, stable schema JSON file for external consumers. +pub fn devnet_info_file(run_id: &str) -> PathBuf { + foc_devnet_run_dir(run_id).join("devnet-info.json") +} + /// Returns the path to the PDP_SP_X provider ID file for a specific run pub fn pdp_sp_provider_id_file(run_id: &str, sp_idx: usize) -> PathBuf { foc_devnet_run_dir(run_id) diff --git a/src/run_id/mod.rs b/src/run_id/mod.rs index 0648ca9a..6a65fa39 100644 --- a/src/run_id/mod.rs +++ b/src/run_id/mod.rs @@ -57,23 +57,50 @@ pub fn generate_run_id() -> String { } /// Create a symlink to the latest run directory. +/// +/// This function is responsible for maintaining `~/.foc-devnet/state/latest`, +/// a symlink that always points to the most recent run directory. +/// +/// It handles: +/// - Creating the parent directory if needed +/// - Removing any existing symlink (including broken ones) +/// - Creating the new symlink +/// +/// # Arguments +/// * `run_id` - The run ID of the current execution +/// +/// # Failures +/// This is a critical operation. If it fails, the state directory will be +/// inconsistent and subsequent runs may have issues. pub fn create_latest_symlink(run_id: &str) -> Result<(), Box> { let latest_link = crate::paths::foc_devnet_state_latest(); let run_dir = crate::paths::foc_devnet_run_dir(run_id); - // Remove existing symlink if it exists - if latest_link.exists() || latest_link.is_symlink() { + // Ensure parent directory exists (state/) before trying to create symlink + if let Some(parent) = latest_link.parent() { + std::fs::create_dir_all(parent)?; + } + + // Remove existing symlink if it exists (including broken symlinks) + // We need to handle broken symlinks carefully: + // - exists() returns false for broken symlinks (target doesn't exist) + // - is_symlink() returns true even if target is broken + // So we check is_symlink() first, which is true for both valid and broken symlinks + if latest_link.is_symlink() { #[cfg(unix)] std::fs::remove_file(&latest_link)?; #[cfg(windows)] + std::fs::remove_file(&latest_link)?; + } else if latest_link.exists() { + // It's a real directory or file (shouldn't happen, but handle it) if latest_link.is_dir() { - std::fs::remove_dir(&latest_link)?; + std::fs::remove_dir_all(&latest_link)?; } else { std::fs::remove_file(&latest_link)?; } } - // Create new symlink + // Create new symlink pointing to the run directory #[cfg(unix)] std::os::unix::fs::symlink(&run_dir, &latest_link)?; #[cfg(windows)] @@ -110,4 +137,66 @@ mod tests { // At least the random name part should differ assert_ne!(id1, id2, "Run IDs should be different"); } + + #[test] + fn test_create_latest_symlink_handles_broken_symlinks() { + // This test verifies that create_latest_symlink properly handles + // removing broken symlinks and creating new ones + use std::os::unix::fs::symlink; + use tempfile::TempDir; + + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let _run_id = generate_run_id(); + + // Mock the paths by using temp directory structure + let runs_dir = temp_dir.path().join("run"); + let state_dir = temp_dir.path().join("state"); + let latest_link = state_dir.join("latest"); + + std::fs::create_dir_all(&runs_dir).expect("Failed to create runs dir"); + std::fs::create_dir_all(&state_dir).expect("Failed to create state dir"); + + // Create first run directory + let run_dir1 = runs_dir.join("run1"); + std::fs::create_dir_all(&run_dir1).expect("Failed to create run1 dir"); + + // Create initial symlink + symlink(&run_dir1, &latest_link).expect("Failed to create initial symlink"); + assert!(latest_link.is_symlink(), "Initial symlink should exist"); + assert_eq!( + std::fs::read_link(&latest_link).unwrap(), + run_dir1, + "Symlink should point to run1" + ); + + // Delete the target to create a broken symlink + std::fs::remove_dir_all(&run_dir1).expect("Failed to remove run1 dir"); + assert!( + latest_link.is_symlink(), + "Broken symlink should still be detected as symlink" + ); + assert!( + !latest_link.exists(), + "Broken symlink target should not exist" + ); + + // Create a new run directory + let run_dir2 = runs_dir.join("run2"); + std::fs::create_dir_all(&run_dir2).expect("Failed to create run2 dir"); + + // Remove the broken symlink and create new one + if latest_link.is_symlink() { + std::fs::remove_file(&latest_link).expect("Failed to remove broken symlink"); + } + assert!(!latest_link.exists(), "Symlink should be removed"); + + symlink(&run_dir2, &latest_link).expect("Failed to create new symlink"); + assert!(latest_link.is_symlink(), "New symlink should exist"); + assert_eq!( + std::fs::read_link(&latest_link).unwrap(), + run_dir2, + "Symlink should point to run2" + ); + assert!(latest_link.exists(), "New symlink target should exist"); + } }