diff --git a/packages/backend/src/database.ts b/packages/backend/src/database.ts index 82bed68d..7df7a7fa 100644 --- a/packages/backend/src/database.ts +++ b/packages/backend/src/database.ts @@ -613,26 +613,27 @@ export class Database { id: projectId }, ...filter - } + }, + order: { + createdAt: 'DESC', + }, }); return domains; } - async getOldestDomainByProjectId( - projectId: string, - ): Promise { + async getOldestDomainByProjectId(projectId: string): Promise { const domainRepository = this.dataSource.getRepository(Domain); const domain = await domainRepository.findOne({ where: { project: { - id: projectId + id: projectId, }, }, order: { - createdAt: 'ASC' - } + createdAt: 'ASC', + }, }); return domain; diff --git a/packages/backend/src/entity/Deployer.ts b/packages/backend/src/entity/Deployer.ts index 72f386ca..452337d1 100644 --- a/packages/backend/src/entity/Deployer.ts +++ b/packages/backend/src/entity/Deployer.ts @@ -24,6 +24,9 @@ export class Deployer { @Column('varchar', { nullable: true }) paymentAddress!: string | null; + @Column('varchar', { nullable: true }) + version!: string | null; + @ManyToMany(() => Project, (project) => project.deployers) projects!: Project[]; } diff --git a/packages/backend/src/schema.gql b/packages/backend/src/schema.gql index 1404adef..575b67d7 100644 --- a/packages/backend/src/schema.gql +++ b/packages/backend/src/schema.gql @@ -272,8 +272,8 @@ type AppDeploymentRecordAttributes { } input AuctionParams { - maxPrice: String, - numProviders: Int, + maxPrice: String + numProviders: Int } type Query { diff --git a/packages/backend/src/service.ts b/packages/backend/src/service.ts index 4ae6a5b8..75da9159 100644 --- a/packages/backend/src/service.ts +++ b/packages/backend/src/service.ts @@ -7,7 +7,12 @@ import { DateTime } from 'luxon'; import { OAuthApp } from '@octokit/oauth-app'; import { Database } from './database'; -import { ApplicationRecord, Deployment, DeploymentStatus, Environment } from './entity/Deployment'; +import { + ApplicationRecord, + Deployment, + DeploymentStatus, + Environment, +} from './entity/Deployment'; import { Domain } from './entity/Domain'; import { EnvironmentVariable } from './entity/EnvironmentVariable'; import { Organization } from './entity/Organization'; @@ -119,7 +124,8 @@ export class Service { } // Fetch ApplicationDeploymentRecord for deployments - const records = await this.laconicRegistry.getDeploymentRecords(deployments); + const records = + await this.laconicRegistry.getDeploymentRecords(deployments); log(`Found ${records.length} ApplicationDeploymentRecords`); // Update deployments for which ApplicationDeploymentRecords were returned @@ -204,7 +210,9 @@ export class Service { return; } - const registryRecord = await this.laconicRegistry.getRecordById(record.attributes.dns); + const registryRecord = await this.laconicRegistry.getRecordById( + record.attributes.dns, + ); if (!registryRecord) { log(`DNS record not found for deployment ${deployment.id}`); @@ -219,7 +227,7 @@ export class Service { resourceType: dnsRecord.attributes.resource_type, value: dnsRecord.attributes.value, version: dnsRecord.attributes.version, - } + }; deployment.applicationDeploymentRecordId = record.id; deployment.applicationDeploymentRecordData = record.attributes; @@ -239,18 +247,38 @@ export class Service { relations: { project: true, deployer: true, - } + }, }); if (previousCanonicalDeployment) { + // If all the DNS in the previous canonical deployment request are different from the new deployment request // Send removal request for the previous canonical deployment and delete DB entry - if (previousCanonicalDeployment.url !== deployment.url) { - await this.laconicRegistry.createApplicationDeploymentRemovalRequest({ - deploymentId: previousCanonicalDeployment.applicationDeploymentRecordId!, - deployerLrn: previousCanonicalDeployment.deployer.deployerLrn, - auctionId: previousCanonicalDeployment.project.auctionId, - payment: previousCanonicalDeployment.project.txHash - }); + const previousDnsList = + previousCanonicalDeployment.applicationDeploymentRequestData?.dns || + ''; + const newDnsList = + deployment.applicationDeploymentRequestData?.dns || ''; + + const previousDnsSet = new Set( + previousDnsList.split(',').map((item) => item.trim()), + ); + + const newDnsSet = new Set( + newDnsList.split(',').map((item) => item.trim()), + ); + + const isMatch = [...previousDnsSet].some((item) => newDnsSet.has(item)); + + if (!isMatch) { + await this.laconicRegistry.createApplicationDeploymentRemovalRequest( + { + deploymentId: + previousCanonicalDeployment.applicationDeploymentRecordId!, + deployerLrn: previousCanonicalDeployment.deployer.deployerLrn, + auctionId: previousCanonicalDeployment.project.auctionId, + payment: previousCanonicalDeployment.project.txHash, + }, + ); } await this.db.deleteDeploymentById(previousCanonicalDeployment.id); @@ -261,7 +289,9 @@ export class Service { // Release deployer funds on successful deployment if (!deployment.project.fundsReleased) { - const fundsReleased = await this.releaseDeployerFundsByProjectId(deployment.projectId); + const fundsReleased = await this.releaseDeployerFundsByProjectId( + deployment.projectId, + ); // Return remaining amount to owner await this.returnUserFundsByProjectId(deployment.projectId, true); @@ -485,12 +515,17 @@ export class Service { return dbProjects; } - async getNonCanonicalDeploymentsByProjectId(projectId: string): Promise { - const nonCanonicalDeployments = await this.db.getNonCanonicalDeploymentsByProjectId(projectId); + async getNonCanonicalDeploymentsByProjectId( + projectId: string, + ): Promise { + const nonCanonicalDeployments = + await this.db.getNonCanonicalDeploymentsByProjectId(projectId); return nonCanonicalDeployments; } - async getLatestDNSRecordByProjectId(projectId: string): Promise { + async getLatestDNSRecordByProjectId( + projectId: string, + ): Promise { const dnsRecord = await this.db.getLatestDNSRecordByProjectId(projectId); return dnsRecord; } @@ -647,7 +682,10 @@ export class Service { let newDeployment: Deployment; if (oldDeployment.project.auctionId) { - newDeployment = await this.createDeploymentFromAuction(oldDeployment.project, oldDeployment.deployer); + newDeployment = await this.createDeploymentFromAuction( + oldDeployment.project, + oldDeployment.deployer, + ); } else { newDeployment = await this.createDeployment(user.id, octokit, { project: oldDeployment.project, @@ -655,9 +693,10 @@ export class Service { environment: Environment.Production, commitHash: oldDeployment.commitHash, commitMessage: oldDeployment.commitMessage, - deployer: oldDeployment.deployer + deployer: oldDeployment.deployer, }); } + return newDeployment; } @@ -665,7 +704,7 @@ export class Service { userId: string, octokit: Octokit, data: DeepPartial, - deployerLrn?: string + deployerLrn?: string, ): Promise { assert(data.project?.repository, 'Project repository not found'); log( @@ -688,34 +727,56 @@ export class Service { deployer = data.deployer; } - const deployment = await this.createDeploymentFromData(userId, data, deployer!.deployerLrn!, applicationRecordId, applicationRecordData, false); + const deployment = await this.createDeploymentFromData( + userId, + data, + deployer!.deployerLrn!, + applicationRecordId, + applicationRecordData, + false, + ); const address = await this.getAddress(); - const { repo, repoUrl } = await getRepoDetails(octokit, data.project.repository, data.commitHash); - const environmentVariablesObj = await this.getEnvVariables(data.project!.id!); + const { repo, repoUrl } = await getRepoDetails( + octokit, + data.project.repository, + data.commitHash, + ); + const environmentVariablesObj = await this.getEnvVariables( + data.project!.id!, + ); // To set project DNS if (data.environment === Environment.Production) { - const canonicalDeployment = await this.createDeploymentFromData(userId, data, deployer!.deployerLrn!, applicationRecordId, applicationRecordData, true); - // If a custom domain is present then use that as the DNS in the deployment request - const customDomain = await this.db.getOldestDomainByProjectId(data.project!.id!); + const canonicalDeployment = await this.createDeploymentFromData( + userId, + data, + deployer!.deployerLrn!, + applicationRecordId, + applicationRecordData, + true, + ); + + const dns = await this.getDnsForDeployerByProjectId(data.project.id!, deployer!.version, canonicalDeployment.project.name); // On deleting deployment later, project canonical deployment is also deleted // So publish project canonical deployment first so that ApplicationDeploymentRecord for the same is available when deleting deployment later - const { applicationDeploymentRequestData, applicationDeploymentRequestId } = - await this.laconicRegistry.createApplicationDeploymentRequest({ - deployment: canonicalDeployment, - appName: repo, - repository: repoUrl, - environmentVariables: environmentVariablesObj, - dns: customDomain?.name ?? `${canonicalDeployment.project.name}`, - lrn: deployer!.deployerLrn!, - apiUrl: deployer!.deployerApiUrl!, - payment: data.project.txHash, - auctionId: data.project.auctionId, - requesterAddress: address, - publicKey: deployer!.publicKey! - }); + const { + applicationDeploymentRequestData, + applicationDeploymentRequestId, + } = await this.laconicRegistry.createApplicationDeploymentRequest({ + deployment: canonicalDeployment, + appName: repo, + repository: repoUrl, + environmentVariables: environmentVariablesObj, + dns, + lrn: deployer!.deployerLrn!, + apiUrl: deployer!.deployerApiUrl!, + payment: data.project.txHash, + auctionId: data.project.auctionId, + requesterAddress: address, + publicKey: deployer!.publicKey!, + }); await this.db.updateDeploymentById(canonicalDeployment.id, { applicationDeploymentRequestId, @@ -735,7 +796,7 @@ export class Service { payment: data.project.txHash, auctionId: data.project.auctionId, requesterAddress: address, - publicKey: deployer!.publicKey! + publicKey: deployer!.publicKey!, }); await this.db.updateDeploymentById(deployment.id, { @@ -748,7 +809,7 @@ export class Service { async createDeploymentFromAuction( project: DeepPartial, - deployer: Deployer + deployer: Deployer, ): Promise { const octokit = await this.getOctokit(project.ownerId!); const [owner, repo] = project.repository!.split('/'); @@ -774,7 +835,7 @@ export class Service { const applicationRecordId = record.id; const applicationRecordData = record.attributes; - const deployerLrn = deployer!.deployerLrn + const deployerLrn = deployer!.deployerLrn; // Create deployment with prod branch and latest commit const deploymentData = { @@ -786,31 +847,49 @@ export class Service { commitMessage: latestCommit.commit.message, }; - const deployment = await this.createDeploymentFromData(project.ownerId!, deploymentData, deployerLrn, applicationRecordId, applicationRecordData, false); + const deployment = await this.createDeploymentFromData( + project.ownerId!, + deploymentData, + deployerLrn, + applicationRecordId, + applicationRecordData, + false, + ); + const address = await this.getAddress(); const environmentVariablesObj = await this.getEnvVariables(project!.id!); // To set project DNS if (deploymentData.environment === Environment.Production) { - const canonicalDeployment = await this.createDeploymentFromData(project.ownerId!, deploymentData, deployerLrn, applicationRecordId, applicationRecordData, true); + const canonicalDeployment = await this.createDeploymentFromData( + project.ownerId!, + deploymentData, + deployerLrn, + applicationRecordId, + applicationRecordData, + true, + ); + // If a custom domain is present then use that as the DNS in the deployment request - const customDomain = await this.db.getOldestDomainByProjectId(project!.id!); + const dns = await this.getDnsForDeployerByProjectId(project.id!, deployer.version, project.name!); // On deleting deployment later, project canonical deployment is also deleted // So publish project canonical deployment first so that ApplicationDeploymentRecord for the same is available when deleting deployment later - const { applicationDeploymentRequestId, applicationDeploymentRequestData } = - await this.laconicRegistry.createApplicationDeploymentRequest({ - deployment: canonicalDeployment, - appName: repo, - repository: repoUrl, - environmentVariables: environmentVariablesObj, - dns: customDomain?.name ?? `${canonicalDeployment.project.name}`, - auctionId: project.auctionId!, - lrn: deployerLrn, - apiUrl: deployer!.deployerApiUrl!, - requesterAddress: address, - publicKey: deployer!.publicKey! - }); + const { + applicationDeploymentRequestId, + applicationDeploymentRequestData, + } = await this.laconicRegistry.createApplicationDeploymentRequest({ + deployment: canonicalDeployment, + appName: repo, + repository: repoUrl, + environmentVariables: environmentVariablesObj, + dns, + auctionId: project.auctionId!, + lrn: deployerLrn, + apiUrl: deployer!.deployerApiUrl!, + requesterAddress: address, + publicKey: deployer!.publicKey!, + }); await this.db.updateDeploymentById(canonicalDeployment.id, { applicationDeploymentRequestId, @@ -830,7 +909,7 @@ export class Service { environmentVariables: environmentVariablesObj, dns: `${deployment.project.name}-${deployment.id}`, requesterAddress: address, - publicKey: deployer!.publicKey! + publicKey: deployer!.publicKey!, }); await this.db.updateDeploymentById(deployment.id, { @@ -864,7 +943,7 @@ export class Service { deployer: Object.assign(new Deployer(), { deployerLrn, }), - isCanonical + isCanonical, }); log(`Created deployment ${newDeployment.id}`); @@ -874,11 +953,11 @@ export class Service { async updateProjectWithDeployer( projectId: string, - deployer: Deployer + deployer: Deployer, ): Promise { const deploymentProject = await this.db.getProjects({ where: { id: projectId }, - relations: ['deployers'] + relations: ['deployers'], }); if (!deploymentProject[0].deployers) { @@ -923,15 +1002,22 @@ export class Service { const prodBranch = createdTemplateRepo.data.default_branch ?? 'main'; - const project = await this.addProject(user, organizationSlug, { - name: `${gitRepo.data.owner!.login}-${gitRepo.data.name}`, - prodBranch, - repository: gitRepo.data.full_name, - // TODO: Set selected template - template: 'webapp', - paymentAddress: data.paymentAddress, - txHash: data.txHash - }, lrn, auctionParams, environmentVariables); + const project = await this.addProject( + user, + organizationSlug, + { + name: `${gitRepo.data.owner!.login}-${gitRepo.data.name}`, + prodBranch, + repository: gitRepo.data.full_name, + // TODO: Set selected template + template: 'webapp', + paymentAddress: data.paymentAddress, + txHash: data.txHash, + }, + lrn, + auctionParams, + environmentVariables, + ); if (!project || !project.id) { throw new Error('Failed to create project from template'); @@ -990,7 +1076,9 @@ export class Service { commitHash: latestCommit.sha, commitMessage: latestCommit.commit.message, }; + const { applicationDeploymentAuctionId } = await this.laconicRegistry.createApplicationDeploymentAuction(repo, octokit, auctionParams!, deploymentData); + await this.updateProject(project.id, { auctionId: applicationDeploymentAuctionId }); } else { const deployer = await this.db.getDeployerByLRN(lrn!); @@ -1172,19 +1260,20 @@ export class Service { let newDeployment: Deployment; if (oldDeployment.project.auctionId) { - newDeployment = await this.createDeploymentFromAuction(oldDeployment.project, oldDeployment.deployer); - } else { - newDeployment = await this.createDeployment(user.id, octokit, - { - project: oldDeployment.project, - // TODO: Put isCurrent field in project - branch: oldDeployment.branch, - environment: Environment.Production, - commitHash: oldDeployment.commitHash, - commitMessage: oldDeployment.commitMessage, - deployer: oldDeployment.deployer - } + newDeployment = await this.createDeploymentFromAuction( + oldDeployment.project, + oldDeployment.deployer, ); + } else { + newDeployment = await this.createDeployment(user.id, octokit, { + project: oldDeployment.project, + // TODO: Put isCurrent field in project + branch: oldDeployment.branch, + environment: Environment.Production, + commitHash: oldDeployment.commitHash, + commitMessage: oldDeployment.commitMessage, + deployer: oldDeployment.deployer, + }); } return newDeployment; @@ -1227,23 +1316,25 @@ export class Service { { isCurrent: true }, ); - if (!newCurrentDeploymentUpdate || !oldCurrentDeploymentUpdate){ + if (!newCurrentDeploymentUpdate || !oldCurrentDeploymentUpdate) { return false; } - const newCurrentDeployment = await this.db.getDeployment({ where: { id: deploymentId }, relations: { project: true, deployer: true } }); + const newCurrentDeployment = await this.db.getDeployment({ + where: { id: deploymentId }, + relations: { project: true, deployer: true }, + }); if (!newCurrentDeployment) { throw new Error(`Deployment with Id ${deploymentId} not found`); } - const applicationDeploymentRequestData = newCurrentDeployment.applicationDeploymentRequestData; + const applicationDeploymentRequestData = + newCurrentDeployment.applicationDeploymentRequestData; - const customDomain = await this.db.getOldestDomainByProjectId(projectId); + const dns = await this.getDnsForDeployerByProjectId(projectId, newCurrentDeployment.deployer!.version, newCurrentDeployment.project.name) - if (customDomain && applicationDeploymentRequestData) { - applicationDeploymentRequestData.dns = customDomain.name - } + applicationDeploymentRequestData!.dns = dns // Create a canonical deployment for the new current deployment const canonicalDeployment = await this.createDeploymentFromData( @@ -1258,20 +1349,23 @@ export class Service { applicationDeploymentRequestData!.meta = JSON.stringify({ ...JSON.parse(applicationDeploymentRequestData!.meta), note: `Updated by Snowball @ ${DateTime.utc().toFormat( - "EEE LLL dd HH:mm:ss 'UTC' yyyy" - )}` + "EEE LLL dd HH:mm:ss 'UTC' yyyy", + )}`, }); const result = await this.laconicRegistry.publishRecord( applicationDeploymentRequestData, ); - log(`Application deployment request record published: ${result.id}`) + log(`Application deployment request record published: ${result.id}`); - const updateResult = await this.db.updateDeploymentById(canonicalDeployment.id, { - applicationDeploymentRequestId: result.id, - applicationDeploymentRequestData, - }); + const updateResult = await this.db.updateDeploymentById( + canonicalDeployment.id, + { + applicationDeploymentRequestId: result.id, + applicationDeploymentRequestData, + }, + ); return updateResult; } @@ -1598,6 +1692,8 @@ export class Service { const minimumPayment = record.attributes.minimumPayment; const paymentAddress = record.attributes.paymentAddress; const publicKey = record.attributes.publicKey; + const version = record.attributes.deployerVersion; + const baseDomain = deployerApiUrl.substring(deployerApiUrl.indexOf('.') + 1); const deployerData = { @@ -1607,7 +1703,8 @@ export class Service { baseDomain, minimumPayment, paymentAddress, - publicKey + publicKey, + version, }; // TODO: Update deployers table in a separate job @@ -1646,4 +1743,28 @@ export class Service { return amount === amountSent && sender === senderAddress && recipient === recipientAddress; } + + async getDnsForDeployerByProjectId(projectId: string, deployerVersion: string | undefined | null, projectName: string): Promise { + let dns; + // If a custom domain is present then use that as the DNS in the deployment request + // Only deployers with version > 1 support multiple custom domains + // TODO: Check version number + if (deployerVersion) { + const customDomains = await this.db.getDomainsByProjectId( + projectId, + ); + + dns = + customDomains.length > 0 + ? customDomains.map((d) => d.name).join(',') + : `${projectName}`; + } else { + const domain = await this.db.getOldestDomainByProjectId( + projectId + ); + dns = domain?.name ?? `${projectName}`; + } + + return dns; + } } diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index f02998ff..63ff4581 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -120,5 +120,6 @@ export interface DeployerRecord { publicKey: string; type: string; version: string; + deployerVersion: string; }; }