import assert from 'assert'; import debug from 'debug'; import { DeepPartial, FindOptionsWhere } from 'typeorm'; import { Octokit } from 'octokit'; import { OAuthApp } from '@octokit/oauth-app'; import { Database } from './database'; import { Deployment, DeploymentStatus, Environment } from './entity/Deployment'; import { Domain } from './entity/Domain'; import { EnvironmentVariable } from './entity/EnvironmentVariable'; import { Organization } from './entity/Organization'; import { Project } from './entity/Project'; import { Permission, ProjectMember } from './entity/ProjectMember'; import { User } from './entity/User'; import { Registry } from './registry'; const log = debug('snowball:service'); export class Service { private db: Database; private oauthApp: OAuthApp; private registry: Registry; constructor (db: Database, app: OAuthApp, registry: Registry) { this.db = db; this.oauthApp = app; this.registry = registry; } async getUser (userId: string): Promise { return this.db.getUser({ where: { id: userId } }); } async getOctokit (userId: string): Promise { const user = await this.db.getUser({ where: { id: userId } }); assert(user && user.gitHubToken, 'User needs to be authenticated with GitHub token'); return new Octokit({ auth: user.gitHubToken }); } async getOrganizationsByUserId (userId: string): Promise { const dbOrganizations = await this.db.getOrganizationsByUserId(userId); return dbOrganizations; } async getProjectById (projectId: string): Promise { const dbProject = await this.db.getProjectById(projectId); return dbProject; } async getProjectsInOrganization (userId:string, organizationSlug: string): Promise { const dbProjects = await this.db.getProjectsInOrganization(userId, organizationSlug); return dbProjects; } async getDeployementsByProjectId (projectId: string): Promise { const dbDeployments = await this.db.getDeploymentsByProjectId(projectId); return dbDeployments; } async getEnvironmentVariablesByProjectId (projectId: string): Promise { const dbEnvironmentVariables = await this.db.getEnvironmentVariablesByProjectId(projectId); return dbEnvironmentVariables; } async getProjectMembersByProjectId (projectId: string): Promise { const dbProjectMembers = await this.db.getProjectMembersByProjectId(projectId); return dbProjectMembers; } async searchProjects (userId: string, searchText: string): Promise { const dbProjects = await this.db.getProjectsBySearchText(userId, searchText); return dbProjects; } async getDomainsByProjectId (projectId: string, filter?: FindOptionsWhere): Promise { const dbDomains = await this.db.getDomainsByProjectId(projectId, filter); return dbDomains; } async updateProjectMember (projectMemberId: string, data: {permissions: Permission[]}): Promise { return this.db.updateProjectMemberById(projectMemberId, data); } async addProjectMember (projectId: string, data: { email: string, permissions: Permission[] }): Promise { // 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 (userId: string, projectMemberId: string): Promise { const member = await this.db.getProjectMemberById(projectMemberId); if (String(member.member.id) === userId) { throw new Error('Invalid operation: cannot remove self'); } const memberProject = member.project; assert(memberProject); if (String(userId) === String(memberProject.owner.id)) { return this.db.removeProjectMemberById(projectMemberId); } else { throw new Error('Invalid operation: not authorized'); } } async addEnvironmentVariables (projectId: string, data: { environments: string[], key: string, value: string}[]): Promise { const formattedEnvironmentVariables = data.map((environmentVariable) => { return environmentVariable.environments.map((environment) => { return ({ key: environmentVariable.key, value: environmentVariable.value, environment: environment as Environment, project: Object.assign(new Project(), { id: projectId }) }); }); }).flat(); const savedEnvironmentVariables = await this.db.addEnvironmentVariables(formattedEnvironmentVariables); return savedEnvironmentVariables; } async updateEnvironmentVariable (environmentVariableId: string, data : DeepPartial): Promise { return this.db.updateEnvironmentVariable(environmentVariableId, data); } async removeEnvironmentVariable (environmentVariableId: string): Promise { return this.db.deleteEnvironmentVariable(environmentVariableId); } async updateDeploymentToProd (userId: string, deploymentId: string): Promise { const oldDeployment = await this.db.getDeployment({ where: { id: deploymentId }, relations: { project: true } }); if (!oldDeployment) { throw new Error('Deployment does not exist'); } const prodBranchDomains = await this.db.getDomainsByProjectId(oldDeployment.project.id, { branch: oldDeployment.project.prodBranch }); // TODO: Fix unique constraint error for domain const deploymentWithProdBranchDomain = await this.db.getDeployment({ where: { domain: { id: prodBranchDomains[0].id } } }); if (deploymentWithProdBranchDomain) { await this.db.updateDeploymentById(deploymentWithProdBranchDomain.id, { domain: null, isCurrent: false }); } const oldCurrentDeployment = await this.db.getDeployment({ relations: { domain: true }, where: { project: { id: oldDeployment.project.id }, isCurrent: true } }); if (oldCurrentDeployment) { await this.db.updateDeploymentById(oldCurrentDeployment.id, { isCurrent: false, domain: null }); } const octokit = await this.getOctokit(userId); const newDeployement = await this.createDeployment(userId, octokit, { project: oldDeployment.project, isCurrent: true, branch: oldDeployment.branch, environment: Environment.Production, domain: prodBranchDomains[0], commitHash: oldDeployment.commitHash }); return newDeployement; } async createDeployment (userId: string, octokit: Octokit, data: DeepPartial): Promise { assert(data.project?.repository, 'Project repository not found'); const [owner, repo] = data.project.repository.split('/'); const { data: packageJSONData } = await octokit.rest.repos.getContent({ owner, repo, path: 'package.json', ref: data.commitHash }); if (!packageJSONData) { throw new Error('Package.json file not found'); } assert(!Array.isArray(packageJSONData) && packageJSONData.type === 'file'); const packageJSON = JSON.parse(atob(packageJSONData.content)); const { registryRecordId, registryRecordData } = await this.registry.createApplicationRecord({ packageJSON, appType: data.project!.template!, commitHash: data.commitHash! }); const newDeployement = await this.db.addDeployement({ project: data.project, branch: data.branch, commitHash: data.commitHash, environment: data.environment, isCurrent: data.isCurrent, status: DeploymentStatus.Building, registryRecordId, registryRecordData, domain: data.domain, createdBy: Object.assign(new User(), { id: userId }) }); log(`Application record ${registryRecordId} published for deployment ${newDeployement.id}`); return newDeployement; } async addProject (userId: string, organizationSlug: string, data: DeepPartial): Promise { const organization = await this.db.getOrganization({ where: { slug: organizationSlug } }); if (!organization) { throw new Error('Organization does not exist'); } const project = await this.db.addProject(userId, organization.id, data); const octokit = await this.getOctokit(userId); const [owner, repo] = project.repository.split('/'); const { data: [latestCommit] } = await octokit.rest.repos.listCommits({ owner, repo, sha: project.prodBranch, per_page: 1 }); const { data: repoDetails } = await octokit.rest.repos.get({ owner, repo }); // Create deployment with prod branch and latest commit const newDeployment = await this.createDeployment(userId, octokit, { project, isCurrent: true, branch: project.prodBranch, environment: Environment.Production, domain: null, commitHash: latestCommit.sha }); const { registryRecordId, registryRecordData } = await this.registry.createApplicationDeploymentRequest( { appName: newDeployment.registryRecordData.name!, commitHash: latestCommit.sha, repository: repoDetails.git_url }); await this.db.updateProjectById(project.id, { registryRecordId, registryRecordData }); // TODO: Setup repo webhook for push events return project; } async updateProject (projectId: string, data: DeepPartial): Promise { return this.db.updateProjectById(projectId, data); } async deleteProject (projectId: string): Promise { return this.db.deleteProjectById(projectId); } async deleteDomain (domainId: string): Promise { 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 (userId: string, deploymentId: string): Promise { const oldDeployment = await this.db.getDeployment({ relations: { project: true, domain: true, createdBy: true }, where: { id: deploymentId } }); if (oldDeployment === null) { throw new Error('Deployment not found'); } await this.db.updateDeploymentById(deploymentId, { domain: null, isCurrent: false }); const octokit = await this.getOctokit(userId); const newDeployement = await this.createDeployment(userId, octokit, { project: oldDeployment.project, // TODO: Put isCurrent field in project branch: oldDeployment.branch, isCurrent: true, environment: Environment.Production, domain: oldDeployment.domain, commitHash: oldDeployment.commitHash }); return newDeployement; } async rollbackDeployment (projectId: string, deploymentId: string): Promise { // TODO: Implement transactions const oldCurrentDeployment = await this.db.getDeployment({ relations: { domain: true }, where: { project: { id: projectId }, isCurrent: true } }); if (!oldCurrentDeployment) { throw new Error('Current deployement doesnot exist'); } const oldCurrentDeploymentUpdate = await this.db.updateDeploymentById(oldCurrentDeployment.id, { isCurrent: false, domain: null }); const newCurrentDeploymentUpdate = await this.db.updateDeploymentById(deploymentId, { isCurrent: true, domain: oldCurrentDeployment?.domain }); return newCurrentDeploymentUpdate && oldCurrentDeploymentUpdate; } async addDomain (projectId: string, domainDetails: { 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 = { ...domainDetails, branch: currentProject.prodBranch, project: currentProject }; const savedPrimaryDomain = await this.db.addDomain(primaryDomainDetails); const domainArr = domainDetails.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, domainDetails: DeepPartial): Promise { const domain = await this.db.getDomain({ where: { id: domainId } }); if (domain === null) { throw new Error(`Error finding domain with id ${domainId}`); } const newDomain = { ...domainDetails }; 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 && domainDetails.branch === domain.branch) { throw new Error('Remove all redirects to this domain before updating'); } if (domainDetails.redirectToId) { const redirectedDomain = await this.db.getDomain({ where: { id: domainDetails.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, userId: string): Promise<{token: string}> { const { authentication: { token } } = await this.oauthApp.createToken({ code }); await this.db.updateUser(userId, { gitHubToken: token }); return { token }; } async unauthenticateGitHub (userId: string, data: DeepPartial): Promise { return this.db.updateUser(userId, data); } }