1784 lines
51 KiB
TypeScript
1784 lines
51 KiB
TypeScript
import assert from 'node:assert'
|
|
import debug from 'debug'
|
|
import { DateTime } from 'luxon'
|
|
|
|
import { Octokit, RequestError } from 'octokit'
|
|
import type { DeepPartial, FindOptionsWhere } from 'typeorm'
|
|
|
|
import type { OAuthApp } from '@octokit/oauth-app'
|
|
|
|
import type { GitHubConfig, RegistryConfig } from './config'
|
|
import type { Database } from './database'
|
|
import { Deployer } from './entity/Deployer'
|
|
import {
|
|
type ApplicationRecord,
|
|
type Deployment,
|
|
DeploymentStatus,
|
|
Environment
|
|
} from './entity/Deployment'
|
|
import type { Domain } from './entity/Domain'
|
|
import type { EnvironmentVariable } from './entity/EnvironmentVariable'
|
|
import type { Organization } from './entity/Organization'
|
|
import { Project } from './entity/Project'
|
|
import type { Permission, ProjectMember } from './entity/ProjectMember'
|
|
import { User } from './entity/User'
|
|
import { Role } from './entity/UserOrganization'
|
|
import type { Registry } from './registry'
|
|
import type {
|
|
AddProjectFromTemplateInput,
|
|
AppDeploymentRecord,
|
|
AppDeploymentRemovalRecord,
|
|
AuctionParams,
|
|
DNSRecord,
|
|
DNSRecordAttributes,
|
|
DeployerRecord,
|
|
EnvironmentVariables,
|
|
GitPushEventPayload
|
|
} from './types'
|
|
import { getRepoDetails } from './utils'
|
|
|
|
const log = debug('snowball:service')
|
|
|
|
const GITHUB_UNIQUE_WEBHOOK_ERROR = 'Hook already exists on this repository'
|
|
|
|
// Define a constant for an hour in milliseconds
|
|
const HOUR = 1000 * 60 * 60
|
|
|
|
interface Config {
|
|
gitHubConfig: GitHubConfig
|
|
registryConfig: RegistryConfig
|
|
}
|
|
|
|
export class Service {
|
|
private db: Database
|
|
private oauthApp: OAuthApp
|
|
private laconicRegistry: Registry
|
|
private config: Config
|
|
|
|
private deployRecordCheckTimeout?: NodeJS.Timeout
|
|
private auctionStatusCheckTimeout?: NodeJS.Timeout
|
|
|
|
constructor(config: Config, db: Database, app: OAuthApp, registry: Registry) {
|
|
this.db = db
|
|
this.oauthApp = app
|
|
this.laconicRegistry = registry
|
|
this.config = config
|
|
this.init()
|
|
}
|
|
|
|
/**
|
|
* Initialize services
|
|
*/
|
|
init(): void {
|
|
// Start check for ApplicationDeploymentRecords asynchronously
|
|
this.checkDeployRecordsAndUpdate()
|
|
// Start check for ApplicationDeploymentRemovalRecords asynchronously
|
|
this.checkDeploymentRemovalRecordsAndUpdate()
|
|
// Start check for Deployment Auctions asynchronously
|
|
this.checkAuctionStatus()
|
|
}
|
|
|
|
/**
|
|
* Destroy services
|
|
*/
|
|
destroy(): void {
|
|
clearTimeout(this.deployRecordCheckTimeout)
|
|
clearTimeout(this.auctionStatusCheckTimeout)
|
|
}
|
|
|
|
/**
|
|
* Checks for ApplicationDeploymentRecord and update corresponding deployments
|
|
* Continues check in loop after a delay of registryConfig.fetchDeploymentRecordDelay
|
|
*/
|
|
async checkDeployRecordsAndUpdate(): Promise<void> {
|
|
// Fetch deployments in building state
|
|
const deployments = await this.db.getDeployments({
|
|
where: {
|
|
status: DeploymentStatus.Building
|
|
}
|
|
})
|
|
|
|
if (deployments.length) {
|
|
log(
|
|
`Found ${deployments.length} deployments in ${DeploymentStatus.Building} state`
|
|
)
|
|
|
|
// Calculate a timestamp for one hour ago
|
|
const anHourAgo = Date.now() - HOUR
|
|
|
|
// Filter out deployments started more than an hour ago and mark them as Error
|
|
const oldDeploymentsToUpdate = deployments
|
|
.filter((deployment) => Number(deployment.updatedAt) < anHourAgo)
|
|
.map((deployment) => {
|
|
return this.db.updateDeploymentById(deployment.id, {
|
|
status: DeploymentStatus.Error,
|
|
isCurrent: false
|
|
})
|
|
})
|
|
|
|
// If there are old deployments to update, log and perform the updates
|
|
if (oldDeploymentsToUpdate.length > 0) {
|
|
log(
|
|
`Cleaning up ${oldDeploymentsToUpdate.length} deployments stuck in ${DeploymentStatus.Building} state for over an hour`
|
|
)
|
|
await Promise.all(oldDeploymentsToUpdate)
|
|
}
|
|
|
|
// Fetch ApplicationDeploymentRecord for deployments
|
|
const records =
|
|
await this.laconicRegistry.getDeploymentRecords(deployments)
|
|
log(`Found ${records.length} ApplicationDeploymentRecords`)
|
|
|
|
// Update deployments for which ApplicationDeploymentRecords were returned
|
|
if (records.length) {
|
|
await this.updateDeploymentsWithRecordData(records)
|
|
}
|
|
}
|
|
|
|
this.deployRecordCheckTimeout = setTimeout(() => {
|
|
this.checkDeployRecordsAndUpdate()
|
|
}, this.config.registryConfig.fetchDeploymentRecordDelay)
|
|
}
|
|
|
|
/**
|
|
* Checks for ApplicationDeploymentRemovalRecord and remove corresponding deployments
|
|
* Continues check in loop after a delay of registryConfig.fetchDeploymentRecordDelay
|
|
*/
|
|
async checkDeploymentRemovalRecordsAndUpdate(): Promise<void> {
|
|
// Fetch deployments in deleting state
|
|
const deployments = await this.db.getDeployments({
|
|
where: {
|
|
status: DeploymentStatus.Deleting
|
|
}
|
|
})
|
|
|
|
if (deployments.length) {
|
|
log(
|
|
`Found ${deployments.length} deployments in ${DeploymentStatus.Deleting} state`
|
|
)
|
|
|
|
// Fetch ApplicationDeploymentRemovalRecords for deployments
|
|
const records =
|
|
await this.laconicRegistry.getDeploymentRemovalRecords(deployments)
|
|
log(`Found ${records.length} ApplicationDeploymentRemovalRecords`)
|
|
|
|
// Update deployments for which ApplicationDeploymentRemovalRecords were returned
|
|
if (records.length) {
|
|
await this.deleteDeploymentsWithRecordData(records, deployments)
|
|
}
|
|
}
|
|
|
|
this.deployRecordCheckTimeout = setTimeout(() => {
|
|
this.checkDeploymentRemovalRecordsAndUpdate()
|
|
}, this.config.registryConfig.fetchDeploymentRecordDelay)
|
|
}
|
|
|
|
/**
|
|
* Update deployments with ApplicationDeploymentRecord data
|
|
* Deployments that are completed but not updated in DB
|
|
*/
|
|
async updateDeploymentsWithRecordData(
|
|
records: AppDeploymentRecord[]
|
|
): Promise<void> {
|
|
// Fetch the deployments to be updated using deployment requestId
|
|
const deployments = await this.db.getDeployments({
|
|
where: records.map((record) => ({
|
|
applicationDeploymentRequestId: record.attributes.request
|
|
})),
|
|
relations: {
|
|
deployer: true,
|
|
project: true
|
|
},
|
|
order: {
|
|
createdAt: 'DESC'
|
|
}
|
|
})
|
|
|
|
const recordToDeploymentsMap = deployments.reduce(
|
|
(acc: { [key: string]: Deployment }, deployment) => {
|
|
if (deployment.applicationDeploymentRequestId) {
|
|
acc[deployment.applicationDeploymentRequestId] = deployment
|
|
}
|
|
return acc
|
|
},
|
|
{}
|
|
)
|
|
|
|
// Update deployment data for ApplicationDeploymentRecords
|
|
const deploymentUpdatePromises = records.map(async (record) => {
|
|
const deployment = recordToDeploymentsMap[record.attributes.request]
|
|
|
|
if (!deployment.project) {
|
|
log(`Project ${deployment.projectId} not found`)
|
|
return
|
|
}
|
|
|
|
const registryRecord = await this.laconicRegistry.getRecordById(
|
|
record.attributes.dns
|
|
)
|
|
|
|
if (!registryRecord) {
|
|
log(`DNS record not found for deployment ${deployment.id}`)
|
|
return
|
|
}
|
|
|
|
const dnsRecord = registryRecord as DNSRecord
|
|
|
|
const dnsRecordData: DNSRecordAttributes = {
|
|
name: dnsRecord.attributes.name,
|
|
request: dnsRecord.attributes.request,
|
|
resourceType: dnsRecord.attributes.resource_type,
|
|
value: dnsRecord.attributes.value,
|
|
version: dnsRecord.attributes.version
|
|
}
|
|
|
|
deployment.applicationDeploymentRecordId = record.id
|
|
deployment.applicationDeploymentRecordData = record.attributes
|
|
deployment.url = record.attributes.url
|
|
deployment.status = DeploymentStatus.Ready
|
|
deployment.isCurrent = deployment.environment === Environment.Production
|
|
deployment.dnsRecordData = dnsRecordData
|
|
|
|
if (deployment.isCanonical) {
|
|
const previousCanonicalDeployment = await this.db.getDeployment({
|
|
where: {
|
|
projectId: deployment.project.id,
|
|
deployer: deployment.deployer,
|
|
isCanonical: true,
|
|
isCurrent: true
|
|
},
|
|
relations: {
|
|
project: true,
|
|
deployer: true
|
|
}
|
|
})
|
|
|
|
if (previousCanonicalDeployment) {
|
|
// 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
|
|
}
|
|
)
|
|
}
|
|
|
|
await this.db.deleteDeploymentById(previousCanonicalDeployment.id)
|
|
}
|
|
}
|
|
|
|
await this.db.updateDeploymentById(deployment.id, deployment)
|
|
|
|
// Release deployer funds on successful deployment
|
|
if (!deployment.project.fundsReleased) {
|
|
const fundsReleased = await this.releaseDeployerFundsByProjectId(
|
|
deployment.projectId
|
|
)
|
|
|
|
// Return remaining amount to owner
|
|
await this.returnUserFundsByProjectId(deployment.projectId, true)
|
|
|
|
await this.db.updateProjectById(deployment.projectId, {
|
|
fundsReleased
|
|
})
|
|
}
|
|
|
|
log(
|
|
`Updated deployment ${deployment.id} with URL ${record.attributes.url}`
|
|
)
|
|
})
|
|
|
|
await Promise.all(deploymentUpdatePromises)
|
|
|
|
// Get deployments that are in production environment
|
|
const prodDeployments = Object.values(recordToDeploymentsMap).filter(
|
|
(deployment) => deployment.isCurrent
|
|
)
|
|
// Set the isCurrent state to false for the old deployments
|
|
for (const deployment of prodDeployments) {
|
|
const projectDeployments = await this.db.getDeploymentsByProjectId(
|
|
deployment.projectId
|
|
)
|
|
|
|
const oldDeployments = projectDeployments.filter(
|
|
(projectDeployment) =>
|
|
projectDeployment.deployer.deployerLrn ===
|
|
deployment.deployer.deployerLrn &&
|
|
projectDeployment.id !== deployment.id &&
|
|
projectDeployment.isCanonical === deployment.isCanonical
|
|
)
|
|
for (const oldDeployment of oldDeployments) {
|
|
await this.db.updateDeployment(
|
|
{ id: oldDeployment.id },
|
|
{ isCurrent: false }
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete deployments with ApplicationDeploymentRemovalRecord data
|
|
*/
|
|
async deleteDeploymentsWithRecordData(
|
|
records: AppDeploymentRemovalRecord[],
|
|
deployments: Deployment[]
|
|
): Promise<void> {
|
|
const removedApplicationDeploymentRecordIds = records.map(
|
|
(record) => record.attributes.deployment
|
|
)
|
|
|
|
// Get removed deployments for ApplicationDeploymentRecords
|
|
const removedDeployments = deployments.filter((deployment) =>
|
|
removedApplicationDeploymentRecordIds.includes(
|
|
deployment.applicationDeploymentRecordId!
|
|
)
|
|
)
|
|
|
|
const recordToDeploymentsMap = removedDeployments.reduce(
|
|
(acc: { [key: string]: Deployment }, deployment) => {
|
|
if (deployment.applicationDeploymentRecordId) {
|
|
acc[deployment.applicationDeploymentRecordId] = deployment
|
|
}
|
|
return acc
|
|
},
|
|
{}
|
|
)
|
|
|
|
// Update deployment data for ApplicationDeploymentRecords and delete
|
|
const deploymentUpdatePromises = records.map(async (record) => {
|
|
const deployment = recordToDeploymentsMap[record.attributes.deployment]
|
|
|
|
await this.db.updateDeploymentById(deployment.id, {
|
|
applicationDeploymentRemovalRecordId: record.id,
|
|
applicationDeploymentRemovalRecordData: record.attributes
|
|
})
|
|
|
|
log(
|
|
`Updated deployment ${deployment.id} with ApplicationDeploymentRemovalRecord ${record.id}`
|
|
)
|
|
|
|
await this.db.deleteDeploymentById(deployment.id)
|
|
})
|
|
|
|
await Promise.all(deploymentUpdatePromises)
|
|
}
|
|
|
|
/**
|
|
* Checks the status for all ongoing auctions
|
|
* Calls the createDeploymentFromAuction method for deployments with completed auctions
|
|
*/
|
|
async checkAuctionStatus(): Promise<void> {
|
|
const projects = await this.db.allProjectsWithoutDeployments()
|
|
|
|
const validAuctionIds = projects
|
|
.map((project) => project.auctionId)
|
|
.filter((id): id is string => Boolean(id))
|
|
const completedAuctionIds =
|
|
await this.laconicRegistry.getCompletedAuctionIds(validAuctionIds)
|
|
|
|
const projectsToBedeployed = projects.filter(
|
|
(project) =>
|
|
project.auctionId && completedAuctionIds.includes(project.auctionId)
|
|
)
|
|
|
|
for (const project of projectsToBedeployed) {
|
|
const deployerRecords =
|
|
await this.laconicRegistry.getAuctionWinningDeployerRecords(
|
|
project!.auctionId!
|
|
)
|
|
|
|
if (!deployerRecords) {
|
|
log(`No winning deployer for auction ${project!.auctionId}`)
|
|
|
|
// Return all funds to the owner
|
|
await this.returnUserFundsByProjectId(project.id, false)
|
|
} else {
|
|
const deployers =
|
|
await this.saveDeployersByDeployerRecords(deployerRecords)
|
|
for (const deployer of deployers) {
|
|
log(`Creating deployment for deployer ${deployer.deployerLrn}`)
|
|
await this.createDeploymentFromAuction(project, deployer)
|
|
// Update project with deployer
|
|
await this.updateProjectWithDeployer(project.id, deployer)
|
|
}
|
|
}
|
|
}
|
|
|
|
this.auctionStatusCheckTimeout = setTimeout(() => {
|
|
this.checkAuctionStatus()
|
|
}, this.config.registryConfig.checkAuctionStatusDelay)
|
|
}
|
|
|
|
async getUser(userId: string): Promise<User | null> {
|
|
return this.db.getUser({
|
|
where: {
|
|
id: userId
|
|
}
|
|
})
|
|
}
|
|
|
|
async getUserByEmail(email: string): Promise<User | null> {
|
|
return await this.db.getUser({
|
|
where: {
|
|
email
|
|
}
|
|
})
|
|
}
|
|
|
|
async getUserBySubOrgId(subOrgId: string): Promise<User | null> {
|
|
return await this.db.getUser({
|
|
where: {
|
|
subOrgId
|
|
}
|
|
})
|
|
}
|
|
|
|
async getUserByEthAddress(ethAddress: string): Promise<User | null> {
|
|
return await this.db.getUser({
|
|
where: {
|
|
ethAddress
|
|
}
|
|
})
|
|
}
|
|
|
|
async createUser(params: {
|
|
name?: string
|
|
email: string
|
|
subOrgId: string
|
|
ethAddress: string
|
|
turnkeyWalletId: string
|
|
}): Promise<User> {
|
|
const [org] = await this.db.getOrganizations({})
|
|
assert(org, 'No organizations exists in database')
|
|
|
|
// Create user with new address
|
|
const user = await this.db.addUser({
|
|
email: params.email,
|
|
name: params.name,
|
|
subOrgId: params.subOrgId,
|
|
ethAddress: params.ethAddress,
|
|
isVerified: true,
|
|
turnkeyWalletId: params.turnkeyWalletId
|
|
})
|
|
|
|
await this.db.addUserOrganization({
|
|
member: user,
|
|
organization: org,
|
|
role: Role.Owner
|
|
})
|
|
|
|
return user
|
|
}
|
|
|
|
async getOctokit(userId: string): Promise<Octokit> {
|
|
const user = await this.db.getUser({ where: { id: userId } })
|
|
assert(
|
|
user?.gitHubToken,
|
|
'User needs to be authenticated with GitHub token'
|
|
)
|
|
|
|
return new Octokit({ auth: user.gitHubToken })
|
|
}
|
|
|
|
async getOrganizationsByUserId(user: User): Promise<Organization[]> {
|
|
const dbOrganizations = await this.db.getOrganizationsByUserId(user.id)
|
|
return dbOrganizations
|
|
}
|
|
|
|
async getProjectById(user: User, projectId: string): Promise<Project | null> {
|
|
const dbProject = await this.db.getProjectById(projectId)
|
|
|
|
if (dbProject && dbProject.owner.id !== user.id) {
|
|
return null
|
|
}
|
|
|
|
return dbProject
|
|
}
|
|
|
|
async getProjectsInOrganization(
|
|
user: User,
|
|
organizationSlug: string
|
|
): Promise<Project[]> {
|
|
const dbProjects = await this.db.getProjectsInOrganization(
|
|
user.id,
|
|
organizationSlug
|
|
)
|
|
return dbProjects
|
|
}
|
|
|
|
async getNonCanonicalDeploymentsByProjectId(
|
|
projectId: string
|
|
): Promise<Deployment[]> {
|
|
const nonCanonicalDeployments =
|
|
await this.db.getNonCanonicalDeploymentsByProjectId(projectId)
|
|
return nonCanonicalDeployments
|
|
}
|
|
|
|
async getLatestDNSRecordByProjectId(
|
|
projectId: string
|
|
): Promise<DNSRecordAttributes | null> {
|
|
const dnsRecord = await this.db.getLatestDNSRecordByProjectId(projectId)
|
|
return dnsRecord
|
|
}
|
|
|
|
async getEnvironmentVariablesByProjectId(
|
|
projectId: string
|
|
): Promise<EnvironmentVariable[]> {
|
|
const dbEnvironmentVariables =
|
|
await this.db.getEnvironmentVariablesByProjectId(projectId)
|
|
return dbEnvironmentVariables
|
|
}
|
|
|
|
async getProjectMembersByProjectId(
|
|
projectId: string
|
|
): Promise<ProjectMember[]> {
|
|
const dbProjectMembers =
|
|
await this.db.getProjectMembersByProjectId(projectId)
|
|
return dbProjectMembers
|
|
}
|
|
|
|
async searchProjects(user: User, searchText: string): Promise<Project[]> {
|
|
const dbProjects = await this.db.getProjectsBySearchText(
|
|
user.id,
|
|
searchText
|
|
)
|
|
return dbProjects
|
|
}
|
|
|
|
async getDomainsByProjectId(
|
|
projectId: string,
|
|
filter?: FindOptionsWhere<Domain>
|
|
): Promise<Domain[]> {
|
|
const dbDomains = await this.db.getDomainsByProjectId(projectId, filter)
|
|
return dbDomains
|
|
}
|
|
|
|
async updateProjectMember(
|
|
projectMemberId: string,
|
|
data: { permissions: Permission[] }
|
|
): Promise<boolean> {
|
|
return this.db.updateProjectMemberById(projectMemberId, data)
|
|
}
|
|
|
|
async addProjectMember(
|
|
projectId: string,
|
|
data: {
|
|
email: string
|
|
permissions: Permission[]
|
|
}
|
|
): Promise<ProjectMember> {
|
|
// TODO: Send invitation
|
|
let user = await this.db.getUser({
|
|
where: {
|
|
email: data.email
|
|
}
|
|
})
|
|
|
|
if (!user) {
|
|
user = await this.db.addUser({
|
|
email: data.email
|
|
})
|
|
}
|
|
|
|
const newProjectMember = await this.db.addProjectMember({
|
|
project: {
|
|
id: projectId
|
|
},
|
|
permissions: data.permissions,
|
|
isPending: true,
|
|
member: {
|
|
id: user.id
|
|
}
|
|
})
|
|
|
|
return newProjectMember
|
|
}
|
|
|
|
async removeProjectMember(
|
|
user: User,
|
|
projectMemberId: string
|
|
): Promise<boolean> {
|
|
const member = await this.db.getProjectMemberById(projectMemberId)
|
|
|
|
if (String(member.member.id) === user.id) {
|
|
throw new Error('Invalid operation: cannot remove self')
|
|
}
|
|
|
|
const memberProject = member.project
|
|
assert(memberProject)
|
|
|
|
if (String(user.id) === String(memberProject.owner.id)) {
|
|
return this.db.removeProjectMemberById(projectMemberId)
|
|
}
|
|
|
|
throw new Error('Invalid operation: not authorized')
|
|
}
|
|
|
|
async addEnvironmentVariables(
|
|
projectId: string,
|
|
data: { environments: string[]; key: string; value: string }[]
|
|
): Promise<EnvironmentVariable[]> {
|
|
const formattedEnvironmentVariables = data.flatMap(
|
|
(environmentVariable) => {
|
|
return environmentVariable.environments.map((environment) => {
|
|
return {
|
|
key: environmentVariable.key,
|
|
value: environmentVariable.value,
|
|
environment: environment as Environment,
|
|
project: Object.assign(new Project(), {
|
|
id: projectId
|
|
})
|
|
}
|
|
})
|
|
}
|
|
)
|
|
|
|
const savedEnvironmentVariables = await this.db.addEnvironmentVariables(
|
|
formattedEnvironmentVariables
|
|
)
|
|
return savedEnvironmentVariables
|
|
}
|
|
|
|
async updateEnvironmentVariable(
|
|
environmentVariableId: string,
|
|
data: DeepPartial<EnvironmentVariable>
|
|
): Promise<boolean> {
|
|
return this.db.updateEnvironmentVariable(environmentVariableId, data)
|
|
}
|
|
|
|
async removeEnvironmentVariable(
|
|
environmentVariableId: string
|
|
): Promise<boolean> {
|
|
return this.db.deleteEnvironmentVariable(environmentVariableId)
|
|
}
|
|
|
|
async updateDeploymentToProd(
|
|
user: User,
|
|
deploymentId: string
|
|
): Promise<Deployment> {
|
|
const oldDeployment = await this.db.getDeployment({
|
|
where: { id: deploymentId },
|
|
relations: {
|
|
project: true,
|
|
deployer: true
|
|
}
|
|
})
|
|
|
|
if (!oldDeployment) {
|
|
throw new Error('Deployment does not exist')
|
|
}
|
|
|
|
const octokit = await this.getOctokit(user.id)
|
|
|
|
const newDeployment = await this.createDeployment(user.id, octokit, {
|
|
project: oldDeployment.project,
|
|
branch: oldDeployment.branch,
|
|
environment: Environment.Production,
|
|
commitHash: oldDeployment.commitHash,
|
|
commitMessage: oldDeployment.commitMessage,
|
|
deployer: oldDeployment.deployer
|
|
})
|
|
|
|
return newDeployment
|
|
}
|
|
|
|
async createDeployment(
|
|
userId: string,
|
|
octokit: Octokit,
|
|
data: DeepPartial<Deployment>,
|
|
deployerLrn?: string
|
|
): Promise<Deployment> {
|
|
assert(data.project?.repository, 'Project repository not found')
|
|
log(
|
|
`Creating deployment in project ${data.project.name} from branch ${data.branch}`
|
|
)
|
|
|
|
// TODO: Set environment variables for each deployment (environment variables can`t be set in application record)
|
|
const { applicationRecordId, applicationRecordData } =
|
|
await this.laconicRegistry.createApplicationRecord({
|
|
octokit,
|
|
repository: data.project.repository,
|
|
appType: data.project!.template!,
|
|
commitHash: data.commitHash!
|
|
})
|
|
|
|
let deployer: Deployer | undefined
|
|
if (deployerLrn) {
|
|
const found = await this.db.getDeployerByLRN(deployerLrn)
|
|
deployer = found || undefined
|
|
} else {
|
|
deployer = data.deployer as Deployer
|
|
}
|
|
|
|
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!
|
|
)
|
|
|
|
// 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!
|
|
)
|
|
|
|
// 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!
|
|
})
|
|
|
|
await this.db.updateDeploymentById(canonicalDeployment.id, {
|
|
applicationDeploymentRequestId,
|
|
applicationDeploymentRequestData
|
|
})
|
|
}
|
|
|
|
const { applicationDeploymentRequestId, applicationDeploymentRequestData } =
|
|
await this.laconicRegistry.createApplicationDeploymentRequest({
|
|
deployment: deployment,
|
|
appName: repo,
|
|
repository: repoUrl,
|
|
lrn: deployer!.deployerLrn!,
|
|
apiUrl: deployer!.deployerApiUrl!,
|
|
environmentVariables: environmentVariablesObj,
|
|
dns: `${deployment.project.name}-${deployment.id}`,
|
|
payment: data.project.txHash,
|
|
auctionId: data.project.auctionId,
|
|
requesterAddress: address,
|
|
publicKey: deployer!.publicKey!
|
|
})
|
|
|
|
await this.db.updateDeploymentById(deployment.id, {
|
|
applicationDeploymentRequestId,
|
|
applicationDeploymentRequestData
|
|
})
|
|
|
|
return deployment
|
|
}
|
|
|
|
async createDeploymentFromAuction(
|
|
project: DeepPartial<Project>,
|
|
deployer: Deployer
|
|
): Promise<Deployment> {
|
|
const octokit = await this.getOctokit(project.ownerId!)
|
|
const [owner, repo] = project.repository!.split('/')
|
|
|
|
const repoUrl = (
|
|
await octokit.rest.repos.get({
|
|
owner,
|
|
repo
|
|
})
|
|
).data.html_url
|
|
|
|
const {
|
|
data: [latestCommit]
|
|
} = await octokit.rest.repos.listCommits({
|
|
owner,
|
|
repo,
|
|
sha: project.prodBranch,
|
|
per_page: 1
|
|
})
|
|
|
|
const lrn = this.laconicRegistry.getLrn(repo)
|
|
const [record] = await this.laconicRegistry.getRecordsByName(lrn)
|
|
const applicationRecordId = record.id
|
|
const applicationRecordData = record.attributes
|
|
|
|
const deployerLrn = deployer!.deployerLrn
|
|
|
|
// Create deployment with prod branch and latest commit
|
|
const deploymentData = {
|
|
project,
|
|
branch: project.prodBranch,
|
|
environment: Environment.Production,
|
|
domain: null,
|
|
commitHash: latestCommit.sha,
|
|
commitMessage: latestCommit.commit.message
|
|
}
|
|
|
|
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
|
|
)
|
|
// If a custom domain is present then use that as the DNS in the deployment request
|
|
const customDomain = await this.db.getOldestDomainByProjectId(
|
|
project!.id!
|
|
)
|
|
|
|
// 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!
|
|
})
|
|
|
|
await this.db.updateDeploymentById(canonicalDeployment.id, {
|
|
applicationDeploymentRequestId,
|
|
applicationDeploymentRequestData
|
|
})
|
|
}
|
|
|
|
const { applicationDeploymentRequestId, applicationDeploymentRequestData } =
|
|
// Create requests for all the deployers
|
|
await this.laconicRegistry.createApplicationDeploymentRequest({
|
|
deployment: deployment,
|
|
appName: repo,
|
|
repository: repoUrl,
|
|
auctionId: project.auctionId!,
|
|
lrn: deployerLrn,
|
|
apiUrl: deployer!.deployerApiUrl!,
|
|
environmentVariables: environmentVariablesObj,
|
|
dns: `${deployment.project.name}-${deployment.id}`,
|
|
requesterAddress: address,
|
|
publicKey: deployer!.publicKey!
|
|
})
|
|
|
|
await this.db.updateDeploymentById(deployment.id, {
|
|
applicationDeploymentRequestId,
|
|
applicationDeploymentRequestData
|
|
})
|
|
|
|
return deployment
|
|
}
|
|
|
|
async createDeploymentFromData(
|
|
userId: string,
|
|
data: DeepPartial<Deployment>,
|
|
deployerLrn: string,
|
|
applicationRecordId: string,
|
|
applicationRecordData: ApplicationRecord,
|
|
isCanonical: boolean
|
|
): Promise<Deployment> {
|
|
const newDeployment = await this.db.addDeployment({
|
|
project: data.project,
|
|
branch: data.branch,
|
|
commitHash: data.commitHash,
|
|
commitMessage: data.commitMessage,
|
|
environment: data.environment,
|
|
status: DeploymentStatus.Building,
|
|
applicationRecordId,
|
|
applicationRecordData,
|
|
createdBy: Object.assign(new User(), {
|
|
id: userId
|
|
}),
|
|
deployer: Object.assign(new Deployer(), {
|
|
deployerLrn
|
|
}),
|
|
isCanonical
|
|
})
|
|
|
|
log(`Created deployment ${newDeployment.id}`)
|
|
|
|
return newDeployment
|
|
}
|
|
|
|
async updateProjectWithDeployer(
|
|
projectId: string,
|
|
deployer: Deployer
|
|
): Promise<Deployer> {
|
|
const deploymentProject = await this.db.getProjects({
|
|
where: { id: projectId },
|
|
relations: ['deployers']
|
|
})
|
|
|
|
if (!deploymentProject[0].deployers) {
|
|
deploymentProject[0].deployers = []
|
|
}
|
|
|
|
deploymentProject[0].deployers.push(deployer)
|
|
|
|
await this.db.saveProject(deploymentProject[0])
|
|
|
|
return deployer
|
|
}
|
|
|
|
async addProjectFromTemplate(
|
|
user: User,
|
|
organizationSlug: string,
|
|
data: AddProjectFromTemplateInput,
|
|
lrn?: string,
|
|
auctionParams?: AuctionParams,
|
|
environmentVariables?: EnvironmentVariables[]
|
|
): Promise<Project | undefined> {
|
|
try {
|
|
const octokit = await this.getOctokit(user.id)
|
|
|
|
const gitRepo = await octokit?.rest.repos.createUsingTemplate({
|
|
template_owner: data.templateOwner,
|
|
template_repo: data.templateRepo,
|
|
owner: data.owner,
|
|
name: data.name,
|
|
include_all_branches: false,
|
|
private: data.isPrivate
|
|
})
|
|
|
|
if (!gitRepo) {
|
|
throw new Error('Failed to create repository from template')
|
|
}
|
|
|
|
const createdTemplateRepo = await octokit.rest.repos.get({
|
|
owner: data.owner,
|
|
repo: data.name
|
|
})
|
|
|
|
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
|
|
)
|
|
|
|
if (!project || !project.id) {
|
|
throw new Error('Failed to create project from template')
|
|
}
|
|
|
|
return project
|
|
} catch (error) {
|
|
console.error('Error creating project from template:', error)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
async addProject(
|
|
user: User,
|
|
organizationSlug: string,
|
|
data: DeepPartial<Project>,
|
|
lrn?: string,
|
|
auctionParams?: AuctionParams,
|
|
environmentVariables?: EnvironmentVariables[]
|
|
): Promise<Project | undefined> {
|
|
const organization = await this.db.getOrganization({
|
|
where: {
|
|
slug: organizationSlug
|
|
}
|
|
})
|
|
|
|
if (!organization) {
|
|
throw new Error('Organization does not exist')
|
|
}
|
|
|
|
const project = await this.db.addProject(user, organization.id, data)
|
|
|
|
if (environmentVariables) {
|
|
await this.addEnvironmentVariables(project.id, environmentVariables)
|
|
}
|
|
|
|
const octokit = await this.getOctokit(user.id)
|
|
const [owner, repo] = project.repository.split('/')
|
|
|
|
const {
|
|
data: [latestCommit]
|
|
} = await octokit.rest.repos.listCommits({
|
|
owner,
|
|
repo,
|
|
sha: project.prodBranch,
|
|
per_page: 1
|
|
})
|
|
|
|
if (auctionParams) {
|
|
// Create deployment with prod branch and latest commit
|
|
const deploymentData = {
|
|
project,
|
|
branch: project.prodBranch,
|
|
environment: Environment.Production,
|
|
domain: null,
|
|
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!)
|
|
|
|
if (!deployer) {
|
|
log('Invalid deployer LRN')
|
|
return
|
|
}
|
|
|
|
if (deployer.minimumPayment && project.txHash) {
|
|
const amountToBePaid = deployer?.minimumPayment
|
|
.replace(/\D/g, '')
|
|
.toString()
|
|
|
|
const txResponse = await this.laconicRegistry.sendTokensToAccount(
|
|
deployer?.paymentAddress!,
|
|
amountToBePaid
|
|
)
|
|
|
|
const txHash = txResponse.transactionHash
|
|
if (txHash) {
|
|
await this.updateProject(project.id, { txHash })
|
|
project.txHash = txHash
|
|
log('Funds transferrend to deployer')
|
|
}
|
|
}
|
|
|
|
const deploymentData = {
|
|
project,
|
|
branch: project.prodBranch,
|
|
environment: Environment.Production,
|
|
domain: null,
|
|
commitHash: latestCommit.sha,
|
|
commitMessage: latestCommit.commit.message,
|
|
deployer
|
|
}
|
|
|
|
const newDeployment = await this.createDeployment(
|
|
user.id,
|
|
octokit,
|
|
deploymentData
|
|
)
|
|
// Update project with deployer
|
|
await this.updateProjectWithDeployer(
|
|
newDeployment.projectId,
|
|
newDeployment.deployer
|
|
)
|
|
}
|
|
|
|
await this.createRepoHook(octokit, project)
|
|
|
|
return project
|
|
}
|
|
|
|
async createRepoHook(octokit: Octokit, project: Project): Promise<void> {
|
|
try {
|
|
const [owner, repo] = project.repository.split('/')
|
|
await octokit.rest.repos.createWebhook({
|
|
owner,
|
|
repo,
|
|
config: {
|
|
url: new URL(
|
|
'api/github/webhook',
|
|
this.config.gitHubConfig.webhookUrl
|
|
).href,
|
|
content_type: 'json'
|
|
},
|
|
events: ['push']
|
|
})
|
|
} catch (err) {
|
|
// https://docs.github.com/en/rest/repos/webhooks?apiVersion=2022-11-28#create-a-repository-webhook--status-codes
|
|
if (
|
|
!(
|
|
err instanceof RequestError &&
|
|
err.status === 422 &&
|
|
(err.response?.data as any).errors.some(
|
|
(err: any) => err.message === GITHUB_UNIQUE_WEBHOOK_ERROR
|
|
)
|
|
)
|
|
) {
|
|
throw err
|
|
}
|
|
|
|
log(GITHUB_UNIQUE_WEBHOOK_ERROR)
|
|
}
|
|
}
|
|
|
|
async handleGitHubPush(data: GitPushEventPayload): Promise<void> {
|
|
const { repository, ref, head_commit: headCommit, deleted } = data
|
|
|
|
if (deleted) {
|
|
log(`Branch ${ref} deleted for project ${repository.full_name}`)
|
|
return
|
|
}
|
|
|
|
log(
|
|
`Handling GitHub push event from repository: ${repository.full_name}, branch: ${ref}`
|
|
)
|
|
const projects = await this.db.getProjects({
|
|
where: { repository: repository.full_name },
|
|
relations: {
|
|
deployers: true
|
|
}
|
|
})
|
|
|
|
if (!projects.length) {
|
|
log(`No projects found for repository ${repository.full_name}`)
|
|
}
|
|
|
|
// The `ref` property contains the full reference, including the branch name
|
|
// For example, "refs/heads/main" or "refs/heads/feature-branch"
|
|
const branch = ref.split('/').pop()
|
|
|
|
for await (const project of projects) {
|
|
const octokit = await this.getOctokit(project.ownerId)
|
|
|
|
const deployers = project.deployers
|
|
if (!deployers) {
|
|
log(`No deployer present for project ${project.id}`)
|
|
return
|
|
}
|
|
|
|
for (const deployer of deployers) {
|
|
// Create deployment with branch and latest commit in GitHub data
|
|
await this.createDeployment(project.ownerId, octokit, {
|
|
project,
|
|
branch,
|
|
environment:
|
|
project.prodBranch === branch
|
|
? Environment.Production
|
|
: Environment.Preview,
|
|
commitHash: headCommit.id,
|
|
commitMessage: headCommit.message,
|
|
deployer: deployer
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
async updateProject(
|
|
projectId: string,
|
|
data: DeepPartial<Project>
|
|
): Promise<boolean> {
|
|
return this.db.updateProjectById(projectId, data)
|
|
}
|
|
|
|
async deleteProject(projectId: string): Promise<boolean> {
|
|
// TODO: Remove GitHub repo hook
|
|
return this.db.deleteProjectById(projectId)
|
|
}
|
|
|
|
async deleteDomain(domainId: string): Promise<boolean> {
|
|
const domainsRedirectedFrom = await this.db.getDomains({
|
|
where: {
|
|
redirectToId: domainId
|
|
}
|
|
})
|
|
|
|
if (domainsRedirectedFrom.length > 0) {
|
|
throw new Error(
|
|
'Cannot delete domain since it has redirects from other domains'
|
|
)
|
|
}
|
|
|
|
return this.db.deleteDomainById(domainId)
|
|
}
|
|
|
|
async redeployToProd(user: User, deploymentId: string): Promise<Deployment> {
|
|
const oldDeployment = await this.db.getDeployment({
|
|
relations: {
|
|
project: true,
|
|
deployer: true,
|
|
createdBy: true
|
|
},
|
|
where: {
|
|
id: deploymentId
|
|
}
|
|
})
|
|
|
|
if (oldDeployment === null) {
|
|
throw new Error('Deployment not found')
|
|
}
|
|
|
|
const octokit = await this.getOctokit(user.id)
|
|
|
|
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
|
|
})
|
|
}
|
|
|
|
return newDeployment
|
|
}
|
|
|
|
async rollbackDeployment(
|
|
projectId: string,
|
|
deploymentId: string
|
|
): Promise<boolean> {
|
|
// TODO: Implement transactions
|
|
const oldCurrentDeployment = await this.db.getDeployment({
|
|
relations: {
|
|
project: true,
|
|
deployer: true
|
|
},
|
|
where: {
|
|
project: {
|
|
id: projectId
|
|
},
|
|
isCurrent: true,
|
|
isCanonical: false
|
|
}
|
|
})
|
|
|
|
if (!oldCurrentDeployment) {
|
|
throw new Error('Current deployment does not exist')
|
|
}
|
|
|
|
const oldCurrentDeploymentUpdate = await this.db.updateDeploymentById(
|
|
oldCurrentDeployment.id,
|
|
{ isCurrent: false }
|
|
)
|
|
|
|
const newCurrentDeploymentUpdate = await this.db.updateDeploymentById(
|
|
deploymentId,
|
|
{ isCurrent: true }
|
|
)
|
|
|
|
if (!newCurrentDeploymentUpdate || !oldCurrentDeploymentUpdate) {
|
|
return false
|
|
}
|
|
|
|
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 customDomain = await this.db.getOldestDomainByProjectId(projectId)
|
|
|
|
if (customDomain && applicationDeploymentRequestData) {
|
|
applicationDeploymentRequestData.dns = customDomain.name
|
|
}
|
|
|
|
// Create a canonical deployment for the new current deployment
|
|
const canonicalDeployment = await this.createDeploymentFromData(
|
|
newCurrentDeployment.project.ownerId,
|
|
newCurrentDeployment,
|
|
newCurrentDeployment.deployer!.deployerLrn!,
|
|
newCurrentDeployment.applicationRecordId,
|
|
newCurrentDeployment.applicationRecordData,
|
|
true
|
|
)
|
|
|
|
applicationDeploymentRequestData!.meta = JSON.stringify({
|
|
...JSON.parse(applicationDeploymentRequestData!.meta),
|
|
note: `Updated by Snowball @ ${DateTime.utc().toFormat(
|
|
"EEE LLL dd HH:mm:ss 'UTC' yyyy"
|
|
)}`
|
|
})
|
|
|
|
const result = await this.laconicRegistry.publishRecord(
|
|
applicationDeploymentRequestData
|
|
)
|
|
|
|
log(`Application deployment request record published: ${result.id}`)
|
|
|
|
const updateResult = await this.db.updateDeploymentById(
|
|
canonicalDeployment.id,
|
|
{
|
|
applicationDeploymentRequestId: result.id,
|
|
applicationDeploymentRequestData
|
|
}
|
|
)
|
|
|
|
return updateResult
|
|
}
|
|
|
|
async deleteDeployment(deploymentId: string): Promise<boolean> {
|
|
const deployment = await this.db.getDeployment({
|
|
where: {
|
|
id: deploymentId
|
|
},
|
|
relations: {
|
|
project: true,
|
|
deployer: true
|
|
}
|
|
})
|
|
|
|
if (deployment?.applicationDeploymentRecordId) {
|
|
// If deployment is current, remove deployment for project subdomain as well
|
|
if (deployment.isCurrent) {
|
|
const canonicalDeployment = await this.db.getDeployment({
|
|
where: {
|
|
projectId: deployment.project.id,
|
|
deployer: deployment.deployer,
|
|
isCanonical: true
|
|
},
|
|
relations: {
|
|
project: true,
|
|
deployer: true
|
|
}
|
|
})
|
|
|
|
// If the canonical deployment is not present then query the chain for the deployment record for backward compatibility
|
|
if (!canonicalDeployment) {
|
|
log(
|
|
`Canonical deployment for deployment with id ${deployment.id} not found, querying the chain..`
|
|
)
|
|
const currentDeploymentURL = `https://${(deployment.project.name).toLowerCase()}.${deployment.deployer.baseDomain}`
|
|
|
|
const deploymentRecords =
|
|
await this.laconicRegistry.getDeploymentRecordsByFilter({
|
|
application: deployment.applicationRecordId,
|
|
url: currentDeploymentURL
|
|
})
|
|
|
|
if (!deploymentRecords.length) {
|
|
log(
|
|
`No ApplicationDeploymentRecord found for URL ${currentDeploymentURL} and ApplicationDeploymentRecord id ${deployment.applicationDeploymentRecordId}`
|
|
)
|
|
|
|
return false
|
|
}
|
|
|
|
// Multiple records are fetched, take the latest record
|
|
const latestRecord = deploymentRecords.sort(
|
|
(a, b) =>
|
|
new Date(b.createTime).getTime() -
|
|
new Date(a.createTime).getTime()
|
|
)[0]
|
|
|
|
await this.laconicRegistry.createApplicationDeploymentRemovalRequest({
|
|
deploymentId: latestRecord.id,
|
|
deployerLrn: deployment.deployer.deployerLrn,
|
|
auctionId: deployment.project.auctionId,
|
|
payment: deployment.project.txHash
|
|
})
|
|
} else {
|
|
// If canonical deployment is found in the DB, then send the removal request with that deployment record Id
|
|
const result =
|
|
await this.laconicRegistry.createApplicationDeploymentRemovalRequest(
|
|
{
|
|
deploymentId:
|
|
canonicalDeployment.applicationDeploymentRecordId!,
|
|
deployerLrn: canonicalDeployment.deployer.deployerLrn,
|
|
auctionId: canonicalDeployment.project.auctionId,
|
|
payment: canonicalDeployment.project.txHash
|
|
}
|
|
)
|
|
|
|
await this.db.updateDeploymentById(canonicalDeployment.id, {
|
|
status: DeploymentStatus.Deleting,
|
|
applicationDeploymentRemovalRequestId:
|
|
result.applicationDeploymentRemovalRequestId,
|
|
applicationDeploymentRemovalRequestData:
|
|
result.applicationDeploymentRemovalRequestData
|
|
})
|
|
}
|
|
}
|
|
|
|
const result =
|
|
await this.laconicRegistry.createApplicationDeploymentRemovalRequest({
|
|
deploymentId: deployment.applicationDeploymentRecordId,
|
|
deployerLrn: deployment.deployer.deployerLrn,
|
|
auctionId: deployment.project.auctionId,
|
|
payment: deployment.project.txHash
|
|
})
|
|
|
|
await this.db.updateDeploymentById(deployment.id, {
|
|
status: DeploymentStatus.Deleting,
|
|
applicationDeploymentRemovalRequestId:
|
|
result.applicationDeploymentRemovalRequestId,
|
|
applicationDeploymentRemovalRequestData:
|
|
result.applicationDeploymentRemovalRequestData
|
|
})
|
|
|
|
return result !== undefined || result !== null
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
async addDomain(
|
|
projectId: string,
|
|
data: { name: string }
|
|
): Promise<{
|
|
primaryDomain: Domain
|
|
// redirectedDomain: Domain;
|
|
}> {
|
|
const currentProject = await this.db.getProjectById(projectId)
|
|
|
|
if (currentProject === null) {
|
|
throw new Error(`Project with ${projectId} not found`)
|
|
}
|
|
|
|
const primaryDomainDetails = {
|
|
...data,
|
|
branch: currentProject.prodBranch,
|
|
project: currentProject
|
|
}
|
|
|
|
const savedPrimaryDomain = await this.db.addDomain(primaryDomainDetails)
|
|
|
|
// const domainArr = data.name.split('www.');
|
|
|
|
// const redirectedDomainDetails = {
|
|
// name: domainArr.length > 1 ? domainArr[1] : `www.${domainArr[0]}`,
|
|
// branch: currentProject.prodBranch,
|
|
// project: currentProject,
|
|
// redirectTo: savedPrimaryDomain,
|
|
// };
|
|
|
|
// const savedRedirectedDomain = await this.db.addDomain(
|
|
// redirectedDomainDetails,
|
|
// );
|
|
|
|
return {
|
|
primaryDomain: savedPrimaryDomain
|
|
// redirectedDomain: savedRedirectedDomain,
|
|
}
|
|
}
|
|
|
|
async updateDomain(
|
|
domainId: string,
|
|
data: DeepPartial<Domain>
|
|
): Promise<boolean> {
|
|
const domain = await this.db.getDomain({
|
|
where: {
|
|
id: domainId
|
|
}
|
|
})
|
|
|
|
if (domain === null) {
|
|
throw new Error(`Error finding domain with id ${domainId}`)
|
|
}
|
|
|
|
const newDomain = {
|
|
...data
|
|
}
|
|
|
|
const domainsRedirectedFrom = await this.db.getDomains({
|
|
where: {
|
|
project: {
|
|
id: domain.projectId
|
|
},
|
|
redirectToId: domain.id
|
|
}
|
|
})
|
|
|
|
// If there are domains redirecting to current domain, only branch of current domain can be updated
|
|
if (domainsRedirectedFrom.length > 0 && data.branch === domain.branch) {
|
|
throw new Error('Remove all redirects to this domain before updating')
|
|
}
|
|
|
|
if (data.redirectToId) {
|
|
const redirectedDomain = await this.db.getDomain({
|
|
where: {
|
|
id: data.redirectToId
|
|
}
|
|
})
|
|
|
|
if (redirectedDomain === null) {
|
|
throw new Error('Could not find Domain to redirect to')
|
|
}
|
|
|
|
if (redirectedDomain.redirectToId) {
|
|
throw new Error(
|
|
'Unable to redirect to the domain because it is already redirecting elsewhere. Redirects cannot be chained.'
|
|
)
|
|
}
|
|
|
|
newDomain.redirectTo = redirectedDomain
|
|
}
|
|
|
|
const updateResult = await this.db.updateDomainById(domainId, newDomain)
|
|
|
|
return updateResult
|
|
}
|
|
|
|
async authenticateGitHub(
|
|
code: string,
|
|
user: User
|
|
): Promise<{ token: string }> {
|
|
const {
|
|
authentication: { token }
|
|
} = await this.oauthApp.createToken({
|
|
code
|
|
})
|
|
|
|
await this.db.updateUser(user, { gitHubToken: token })
|
|
|
|
return { token }
|
|
}
|
|
|
|
async unauthenticateGitHub(
|
|
user: User,
|
|
data: DeepPartial<User>
|
|
): Promise<boolean> {
|
|
return this.db.updateUser(user, data)
|
|
}
|
|
|
|
async getEnvVariables(projectId: string): Promise<{ [key: string]: string }> {
|
|
const environmentVariables =
|
|
await this.db.getEnvironmentVariablesByProjectId(projectId, {
|
|
environment: Environment.Production
|
|
})
|
|
|
|
const environmentVariablesObj = environmentVariables.reduce(
|
|
(acc, env) => {
|
|
acc[env.key] = env.value
|
|
return acc
|
|
},
|
|
{} as { [key: string]: string }
|
|
)
|
|
|
|
return environmentVariablesObj
|
|
}
|
|
|
|
async getAuctionData(auctionId: string): Promise<any> {
|
|
const auctions = await this.laconicRegistry.getAuctionData(auctionId)
|
|
return auctions[0]
|
|
}
|
|
|
|
async releaseDeployerFundsByProjectId(projectId: string): Promise<boolean> {
|
|
const project = await this.db.getProjectById(projectId)
|
|
|
|
if (!project || !project.auctionId) {
|
|
log(
|
|
`Project ${projectId} ${!project ? 'not found' : 'does not have an auction'}`
|
|
)
|
|
|
|
return false
|
|
}
|
|
|
|
const auction = await this.laconicRegistry.releaseDeployerFunds(
|
|
project.auctionId
|
|
)
|
|
|
|
if (auction.auction.fundsReleased) {
|
|
log(`Funds released for auction ${project.auctionId}`)
|
|
await this.db.updateProjectById(projectId, { fundsReleased: true })
|
|
|
|
return true
|
|
}
|
|
|
|
log(`Error releasing funds for auction ${project.auctionId}`)
|
|
|
|
return false
|
|
}
|
|
|
|
async returnUserFundsByProjectId(
|
|
projectId: string,
|
|
winningDeployersPresent: boolean
|
|
) {
|
|
const project = await this.db.getProjectById(projectId)
|
|
|
|
if (!project || !project.auctionId) {
|
|
log(
|
|
`Project ${projectId} ${!project ? 'not found' : 'does not have an auction'}`
|
|
)
|
|
|
|
return false
|
|
}
|
|
|
|
const auction = await this.getAuctionData(project.auctionId)
|
|
const totalAuctionPrice =
|
|
Number(auction.maxPrice.quantity) * auction.numProviders
|
|
|
|
let amountToBeReturned: number
|
|
if (winningDeployersPresent) {
|
|
amountToBeReturned =
|
|
totalAuctionPrice -
|
|
auction.winnerAddresses.length * Number(auction.winnerPrice.quantity)
|
|
} else {
|
|
amountToBeReturned = totalAuctionPrice
|
|
}
|
|
|
|
if (amountToBeReturned !== 0) {
|
|
await this.laconicRegistry.sendTokensToAccount(
|
|
project.paymentAddress,
|
|
amountToBeReturned.toString()
|
|
)
|
|
}
|
|
}
|
|
|
|
async getDeployers(): Promise<Deployer[]> {
|
|
const dbDeployers = await this.db.getDeployers()
|
|
|
|
if (dbDeployers.length > 0) {
|
|
// Call asynchronously to fetch the records from the registry and update the DB
|
|
this.updateDeployersFromRegistry()
|
|
return dbDeployers
|
|
}
|
|
|
|
// Fetch from the registry and populate empty DB
|
|
return await this.updateDeployersFromRegistry()
|
|
}
|
|
|
|
async updateDeployersFromRegistry(): Promise<Deployer[]> {
|
|
const deployerRecords =
|
|
await this.laconicRegistry.getDeployerRecordsByFilter({})
|
|
await this.saveDeployersByDeployerRecords(deployerRecords)
|
|
|
|
return await this.db.getDeployers()
|
|
}
|
|
|
|
async saveDeployersByDeployerRecords(
|
|
deployerRecords: DeployerRecord[]
|
|
): Promise<Deployer[]> {
|
|
const deployers: Deployer[] = []
|
|
|
|
for (const record of deployerRecords) {
|
|
if (record.names && record.names.length > 0) {
|
|
const deployerId = record.id
|
|
const deployerLrn = record.names[0]
|
|
const deployerApiUrl = record.attributes.apiUrl
|
|
const minimumPayment = record.attributes.minimumPayment
|
|
const paymentAddress = record.attributes.paymentAddress
|
|
const publicKey = record.attributes.publicKey
|
|
const baseDomain = deployerApiUrl.substring(
|
|
deployerApiUrl.indexOf('.') + 1
|
|
)
|
|
|
|
const deployerData = {
|
|
deployerLrn,
|
|
deployerId,
|
|
deployerApiUrl,
|
|
baseDomain,
|
|
minimumPayment,
|
|
paymentAddress,
|
|
publicKey
|
|
}
|
|
|
|
// TODO: Update deployers table in a separate job
|
|
const deployer = await this.db.addDeployer(deployerData)
|
|
deployers.push(deployer)
|
|
}
|
|
}
|
|
|
|
return deployers
|
|
}
|
|
|
|
async getAddress(): Promise<any> {
|
|
const account = await this.laconicRegistry.getAccount()
|
|
|
|
return account.address
|
|
}
|
|
|
|
async verifyTx(
|
|
txHash: string,
|
|
amountSent: string,
|
|
senderAddress: string
|
|
): Promise<boolean> {
|
|
const txResponse = await this.laconicRegistry.getTxResponse(txHash)
|
|
if (!txResponse) {
|
|
log('Transaction response not found')
|
|
return false
|
|
}
|
|
|
|
const transfer = txResponse.events.find(
|
|
(e: any) =>
|
|
e.type === 'transfer' &&
|
|
e.attributes.some((a: any) => a.key === 'msg_index')
|
|
)
|
|
if (!transfer) {
|
|
log('No transfer event found')
|
|
return false
|
|
}
|
|
|
|
const sender = transfer.attributes.find(
|
|
(a: any) => a.key === 'sender'
|
|
)?.value
|
|
const recipient = transfer.attributes.find(
|
|
(a: any) => a.key === 'recipient'
|
|
)?.value
|
|
const amount = transfer.attributes.find(
|
|
(a: any) => a.key === 'amount'
|
|
)?.value
|
|
|
|
const recipientAddress = await this.getAddress()
|
|
|
|
return (
|
|
amount === amountSent &&
|
|
sender === senderAddress &&
|
|
recipient === recipientAddress
|
|
)
|
|
}
|
|
}
|