import { NextRequest, NextResponse } from 'next/server'; import { Account, Registry, parseGasAndFees } from '@cerc-io/registry-sdk'; import { GasPrice } from '@cosmjs/stargate'; import axios from 'axios'; // Sleep helper function const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); // Extract repo name from URL const extractRepoInfo = (url: string): { repoName: string, repoUrl: string, provider: string } => { try { const parsedUrl = new URL(url); const pathParts = parsedUrl.pathname.split('/').filter(part => part); // GitHub repository URL pattern if (parsedUrl.hostname === 'github.com' && pathParts.length >= 2) { return { repoName: pathParts[1], repoUrl: `https://github.com/${pathParts[0]}/${pathParts[1]}`, provider: 'github' }; } // GitLab repository URL pattern if ((parsedUrl.hostname === 'gitlab.com' || parsedUrl.hostname.includes('gitlab')) && pathParts.length >= 2) { return { repoName: pathParts[pathParts.length - 1], repoUrl: url, provider: 'gitlab' }; } // Bitbucket repository URL pattern if (parsedUrl.hostname === 'bitbucket.org' && pathParts.length >= 2) { return { repoName: pathParts[1], repoUrl: `https://bitbucket.org/${pathParts[0]}/${pathParts[1]}`, provider: 'bitbucket' }; } // For other URLs, try to extract a meaningful name from the hostname const hostnameWithoutTLD = parsedUrl.hostname.split('.')[0]; return { repoName: hostnameWithoutTLD, repoUrl: url, provider: 'other' }; } catch (error) { console.warn('Failed to parse URL, using fallback name', error); return { repoName: 'webapp', repoUrl: url, provider: 'other' }; } }; // Fetch latest commit hash from GitHub repository const fetchLatestCommitHash = async (repoUrl: string, provider: string): Promise<{ fullHash: string, shortHash: string }> => { try { // Handle GitHub repositories if (provider === 'github') { // Extract owner and repo from GitHub URL const match = repoUrl.match(/github\.com\/([^\/]+)\/([^\/]+)/); if (match) { const [, owner, repo] = match; const apiUrl = `https://api.github.com/repos/${owner}/${repo}/commits/main`; const response = await axios.get(apiUrl); if (response.data && response.data.sha) { // Return both full hash and short hash (7 characters) return { fullHash: response.data.sha, shortHash: response.data.sha.substring(0, 7) }; } } } // For non-GitHub repositories or if fetching fails, return a default value return { fullHash: 'main', shortHash: 'main' }; } catch (error) { console.warn('Failed to fetch latest commit hash:', error); return { fullHash: 'main', shortHash: 'main' }; } }; // Registry transaction retry helper const registryTransactionWithRetry = async ( txFn: () => Promise, maxRetries = 3, delay = 1000 ): Promise => { let lastError; for (let attempt = 0; attempt < maxRetries; attempt++) { try { return await txFn(); } catch (error) { console.error(`Transaction attempt ${attempt + 1} failed:`, error); lastError = error; if (attempt < maxRetries - 1) { await sleep(delay); } } } throw lastError; }; export async function POST(request: NextRequest) { try { const { url, txHash } = await request.json(); // Validate required environment variables const requiredEnvVars = [ 'REGISTRY_CHAIN_ID', 'REGISTRY_GQL_ENDPOINT', 'REGISTRY_RPC_ENDPOINT', 'REGISTRY_BOND_ID', 'REGISTRY_AUTHORITY', 'REGISTRY_USER_KEY', 'DEPLOYER_LRN' ]; for (const envVar of requiredEnvVars) { if (!process.env[envVar]) { return NextResponse.json({ status: 'error', message: `Missing environment variable: ${envVar}` }, { status: 500 }); } } // Extract repository information from URL const { repoName, repoUrl, provider } = extractRepoInfo(url); console.log(`Extracted repo info - Name: ${repoName}, URL: ${repoUrl}, Provider: ${provider}`); // Fetch latest commit hash (or default to 'main' if unable to fetch) const { fullHash, shortHash } = await fetchLatestCommitHash(repoUrl, provider); console.log(`Using commit hash - Full: ${fullHash}, Short: ${shortHash}`); // Use the repository name as the app name const appName = repoName; console.log(`Using app name: ${appName}`); // Sanitize the app name to ensure it's DNS-compatible (only alphanumeric and dashes) const sanitizedAppName = appName.replace(/[^a-zA-Z0-9-]/g, '-').toLowerCase(); // Create DNS name in format: app_name-shortcommithash const dnsName = `${sanitizedAppName}-${shortHash}`; console.log(`DNS name: ${dnsName} (sanitized from: ${appName})`); // Ensure the DNS name doesn't have consecutive dashes or start/end with a dash let cleanDnsName = dnsName .replace(/--+/g, '-') // Replace consecutive dashes with a single dash .replace(/^-+|-+$/g, ''); // Remove leading and trailing dashes // Ensure DNS name is valid (63 chars max per label, all lowercase, starts with a letter) if (cleanDnsName.length > 63) { // If too long, truncate but keep the commit hash part const hashPart = `-${shortHash}`; const maxAppNameLength = 63 - hashPart.length; cleanDnsName = sanitizedAppName.substring(0, maxAppNameLength) + hashPart; } // If the DNS name ended up empty (unlikely) or doesn't start with a letter (possible), // add a prefix to make it valid if (!cleanDnsName || !/^[a-z]/.test(cleanDnsName)) { cleanDnsName = `app-${cleanDnsName}`; } console.log(`Final DNS name: ${cleanDnsName}`); // Set up Registry config const config = { chainId: process.env.REGISTRY_CHAIN_ID!, rpcEndpoint: process.env.REGISTRY_RPC_ENDPOINT!, gqlEndpoint: process.env.REGISTRY_GQL_ENDPOINT!, bondId: process.env.REGISTRY_BOND_ID!, authority: process.env.REGISTRY_AUTHORITY!, privateKey: process.env.REGISTRY_USER_KEY!, fee: { gas: process.env.REGISTRY_GAS || '900000', fees: process.env.REGISTRY_FEES || '900000alnt', gasPrice: '0.001alnt', // Hardcoded valid gas price string with denomination }, }; console.log('Registry config:', { ...config, privateKey: '[REDACTED]', // Don't log the private key }); const deployerLrn = process.env.DEPLOYER_LRN!; // Create Registry client instance const gasPrice = GasPrice.fromString('0.001alnt'); console.log('Using manual gas price:', gasPrice); const registry = new Registry( config.gqlEndpoint, config.rpcEndpoint, { chainId: config.chainId, gasPrice } ); // Create LRN for the application const lrn = `lrn://${config.authority}/applications/${appName}`; // Get current timestamp for the meta note const timestamp = new Date().toUTCString(); // Step 1: Create and publish ApplicationRecord first console.log('Step 1: Publishing ApplicationRecord...'); const applicationRecord = { type: 'ApplicationRecord', name: appName, version: '1.0.0', app_type: 'webapp', repository: [repoUrl], repository_ref: fullHash, app_version: '0.0.1' }; // Create fee for transaction directly const fee = { amount: [{ denom: 'alnt', amount: '900000' }], gas: '900000', }; console.log('Application record data:', applicationRecord); // Publish the application record let applicationRecordId; try { const appRecordResult = await registryTransactionWithRetry(() => registry.setRecord( { privateKey: config.privateKey, record: applicationRecord, bondId: config.bondId, }, config.privateKey, fee ) ) as { id?: string }; applicationRecordId = appRecordResult.id; console.log('Application record published with ID:', applicationRecordId); if (!applicationRecordId) { return NextResponse.json({ status: 'error', message: 'Failed to publish ApplicationRecord' }, { status: 500 }); } } catch (err) { console.error('Error publishing ApplicationRecord:', err); return NextResponse.json({ status: 'error', message: err instanceof Error ? err.message : 'Unknown error publishing ApplicationRecord' }, { status: 500 }); } // Step 2: Set name mappings console.log('Step 2: Setting name mappings...'); try { // Set the main LRN to point to the application record await registryTransactionWithRetry(() => registry.setName( { cid: applicationRecordId, lrn }, config.privateKey, fee ) ); console.log(`Set name mapping: ${lrn} -> ${applicationRecordId}`); // Set the versioned LRN (with repository_ref) await registryTransactionWithRetry(() => registry.setName( { cid: applicationRecordId, lrn: `${lrn}@${fullHash}` }, config.privateKey, fee ) ); console.log(`Set name mapping: ${lrn}@${fullHash} -> ${applicationRecordId}`); } catch (err) { console.error('Error setting name mappings:', err); return NextResponse.json({ status: 'error', message: err instanceof Error ? err.message : 'Unknown error setting name mappings' }, { status: 500 }); } // Step 3: Create ApplicationDeploymentRequest console.log('Step 3: Creating ApplicationDeploymentRequest...'); // Prepare record data for deployment request const deploymentRequestData = { type: 'ApplicationDeploymentRequest', version: '1.0.0', name: appName, application: lrn, deployer: deployerLrn, dns: cleanDnsName, config: { env: { LACONIC_HOSTED_CONFIG_laconicd_chain_id: process.env.REGISTRY_CHAIN_ID || 'laconic-testnet-2' } }, meta: { note: `Added via ATOM-Deploy @ ${timestamp}`, repository: repoUrl, repository_ref: fullHash, }, payment: txHash, }; console.log('Deployment request data:', deploymentRequestData); // Publish the deployment request let deploymentRequestId; try { const deployRequestResult = await registryTransactionWithRetry(() => registry.setRecord( { privateKey: config.privateKey, record: deploymentRequestData, bondId: config.bondId, }, config.privateKey, fee ) ) as { id?: string }; deploymentRequestId = deployRequestResult.id; console.log('Deployment request published with ID:', deploymentRequestId); if (!deploymentRequestId) { return NextResponse.json({ status: 'error', message: 'Failed to publish ApplicationDeploymentRequest' }, { status: 500 }); } } catch (err) { console.error('Error publishing deployment request:', err); return NextResponse.json({ status: 'error', message: err instanceof Error ? err.message : 'Unknown error publishing deployment request' }, { status: 500 }); } // Return combined results return NextResponse.json({ id: deploymentRequestId, applicationRecordId: applicationRecordId, status: 'success', lrn: lrn, dns: cleanDnsName, appName: appName, repoUrl: repoUrl, commitHash: fullHash, shortCommitHash: shortHash }); } catch (error) { console.error('Failed to create application deployment request:', error); return NextResponse.json({ status: 'error', message: error instanceof Error ? error.message : 'Unknown error', }, { status: 500 }); } }