atom-deploy/src/app/api/registry/route.ts
2025-05-03 09:42:05 -04:00

385 lines
12 KiB
TypeScript

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<unknown>,
maxRetries = 3,
delay = 1000
): Promise<unknown> => {
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 });
}
}