385 lines
12 KiB
TypeScript
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 });
|
|
}
|
|
} |