2024-02-06 08:48:06 +00:00
|
|
|
import assert from 'assert';
|
2024-02-12 06:04:01 +00:00
|
|
|
import debug from 'debug';
|
2024-02-06 08:48:06 +00:00
|
|
|
import { DeepPartial, FindOptionsWhere } from 'typeorm';
|
2024-02-15 11:54:57 +00:00
|
|
|
import { Octokit, RequestError } from 'octokit';
|
2024-02-05 10:51:55 +00:00
|
|
|
|
2024-02-08 09:29:19 +00:00
|
|
|
import { OAuthApp } from '@octokit/oauth-app';
|
|
|
|
|
2024-02-02 09:32:12 +00:00
|
|
|
import { Database } from './database';
|
2024-02-12 06:04:01 +00:00
|
|
|
import { Deployment, DeploymentStatus, Environment } from './entity/Deployment';
|
2024-02-02 09:32:12 +00:00
|
|
|
import { Domain } from './entity/Domain';
|
|
|
|
import { EnvironmentVariable } from './entity/EnvironmentVariable';
|
|
|
|
import { Organization } from './entity/Organization';
|
|
|
|
import { Project } from './entity/Project';
|
2024-02-05 10:51:55 +00:00
|
|
|
import { Permission, ProjectMember } from './entity/ProjectMember';
|
2024-02-02 09:32:12 +00:00
|
|
|
import { User } from './entity/User';
|
2024-02-12 06:04:01 +00:00
|
|
|
import { Registry } from './registry';
|
2024-02-19 08:13:29 +00:00
|
|
|
import { GitHubConfig, RegistryConfig } from './config';
|
2024-02-22 04:34:33 +00:00
|
|
|
import { AppDeploymentRecord, GitPushEventPayload, PackageJSON } from './types';
|
2024-02-22 11:56:26 +00:00
|
|
|
import { Role } from './entity/UserOrganization';
|
2024-02-12 06:04:01 +00:00
|
|
|
|
|
|
|
const log = debug('snowball:service');
|
2024-02-19 08:13:29 +00:00
|
|
|
|
2024-02-15 11:54:57 +00:00
|
|
|
const GITHUB_UNIQUE_WEBHOOK_ERROR = 'Hook already exists on this repository';
|
|
|
|
|
|
|
|
interface Config {
|
2024-02-22 05:45:17 +00:00
|
|
|
gitHubConfig: GitHubConfig;
|
|
|
|
registryConfig: RegistryConfig;
|
2024-02-15 11:54:57 +00:00
|
|
|
}
|
2024-02-12 06:04:01 +00:00
|
|
|
|
2024-02-02 09:32:12 +00:00
|
|
|
export class Service {
|
|
|
|
private db: Database;
|
2024-02-14 05:33:22 +00:00
|
|
|
private oauthApp: OAuthApp;
|
2024-02-12 06:04:01 +00:00
|
|
|
private registry: Registry;
|
2024-02-15 11:54:57 +00:00
|
|
|
private config: Config;
|
2024-02-02 09:32:12 +00:00
|
|
|
|
2024-02-19 08:13:29 +00:00
|
|
|
private deployRecordCheckTimeout?: NodeJS.Timeout;
|
|
|
|
|
2024-02-15 11:54:57 +00:00
|
|
|
constructor (config: Config, db: Database, app: OAuthApp, registry: Registry) {
|
2024-02-02 09:32:12 +00:00
|
|
|
this.db = db;
|
2024-02-14 05:33:22 +00:00
|
|
|
this.oauthApp = app;
|
2024-02-12 06:04:01 +00:00
|
|
|
this.registry = registry;
|
2024-02-15 11:54:57 +00:00
|
|
|
this.config = config;
|
2024-02-19 08:13:29 +00:00
|
|
|
this.init();
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Initialize services
|
|
|
|
*/
|
|
|
|
init (): void {
|
|
|
|
// Start check for ApplicationDeploymentRecords asynchronously
|
|
|
|
this.checkDeployRecordsAndUpdate();
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Destroy services
|
|
|
|
*/
|
|
|
|
destroy (): void {
|
|
|
|
clearTimeout(this.deployRecordCheckTimeout);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Checks for ApplicationDeploymentRecord and update corresponding deployments
|
|
|
|
* Continues check in loop after a delay of DEPLOY_RECORD_CHECK_DELAY_MS
|
|
|
|
*/
|
|
|
|
async checkDeployRecordsAndUpdate (): Promise<void> {
|
|
|
|
// Fetch deployments in building state
|
|
|
|
const deployments = await this.db.getDeployments({
|
|
|
|
where: {
|
|
|
|
status: DeploymentStatus.Building
|
|
|
|
// TODO: Fetch and check records for recent deployments
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
if (deployments.length) {
|
2024-02-22 05:45:17 +00:00
|
|
|
log(
|
|
|
|
`Found ${deployments.length} deployments in ${DeploymentStatus.Building} state`
|
|
|
|
);
|
2024-02-19 08:13:29 +00:00
|
|
|
|
|
|
|
// Fetch ApplicationDeploymentRecord for deployments
|
|
|
|
const records = await this.registry.getDeploymentRecords(deployments);
|
|
|
|
log(`Found ${records.length} ApplicationDeploymentRecords`);
|
|
|
|
|
|
|
|
// Update deployments for which ApplicationDeploymentRecords were returned
|
2024-02-20 05:23:42 +00:00
|
|
|
if (records.length) {
|
|
|
|
await this.updateDeploymentsWithRecordData(records);
|
|
|
|
}
|
2024-02-19 08:13:29 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
this.deployRecordCheckTimeout = setTimeout(() => {
|
|
|
|
this.checkDeployRecordsAndUpdate();
|
|
|
|
}, this.config.registryConfig.fetchDeploymentRecordDelay);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Update deployments with ApplicationDeploymentRecord data
|
|
|
|
*/
|
2024-02-22 05:45:17 +00:00
|
|
|
async updateDeploymentsWithRecordData (
|
|
|
|
records: AppDeploymentRecord[]
|
|
|
|
): Promise<void> {
|
2024-02-19 08:13:29 +00:00
|
|
|
// Get deployments for ApplicationDeploymentRecords
|
|
|
|
const deployments = await this.db.getDeployments({
|
2024-02-22 05:45:17 +00:00
|
|
|
where: records.map((record) => ({
|
2024-02-19 08:13:29 +00:00
|
|
|
applicationRecordId: record.attributes.application
|
|
|
|
})),
|
|
|
|
order: {
|
|
|
|
createdAt: 'DESC'
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
// Get project IDs of deployments that are in production environment
|
2024-02-22 05:45:17 +00:00
|
|
|
const productionDeploymentProjectIds = deployments.reduce(
|
|
|
|
(acc, deployment): Set<string> => {
|
|
|
|
if (deployment.environment === Environment.Production) {
|
|
|
|
acc.add(deployment.projectId);
|
|
|
|
}
|
2024-02-19 08:13:29 +00:00
|
|
|
|
2024-02-22 05:45:17 +00:00
|
|
|
return acc;
|
|
|
|
},
|
|
|
|
new Set<string>()
|
|
|
|
);
|
2024-02-19 08:13:29 +00:00
|
|
|
|
|
|
|
// Set old deployments isCurrent to false
|
2024-02-22 05:45:17 +00:00
|
|
|
await this.db.updateDeploymentsByProjectIds(
|
|
|
|
Array.from(productionDeploymentProjectIds),
|
|
|
|
{ isCurrent: false }
|
|
|
|
);
|
2024-02-19 08:13:29 +00:00
|
|
|
|
2024-02-22 05:45:17 +00:00
|
|
|
const recordToDeploymentsMap = deployments.reduce(
|
|
|
|
(acc: { [key: string]: Deployment }, deployment) => {
|
|
|
|
acc[deployment.applicationRecordId] = deployment;
|
|
|
|
return acc;
|
|
|
|
},
|
|
|
|
{}
|
|
|
|
);
|
2024-02-19 08:13:29 +00:00
|
|
|
|
|
|
|
// Update deployment data for ApplicationDeploymentRecords
|
|
|
|
const deploymentUpdatePromises = records.map(async (record) => {
|
|
|
|
const deployment = recordToDeploymentsMap[record.attributes.application];
|
|
|
|
|
2024-02-22 05:45:17 +00:00
|
|
|
await this.db.updateDeploymentById(deployment.id, {
|
|
|
|
applicationDeploymentRecordId: record.id,
|
|
|
|
applicationDeploymentRecordData: record.attributes,
|
|
|
|
url: record.attributes.url,
|
|
|
|
status: DeploymentStatus.Ready,
|
|
|
|
isCurrent: deployment.environment === Environment.Production
|
|
|
|
});
|
2024-02-19 08:13:29 +00:00
|
|
|
|
2024-02-22 05:45:17 +00:00
|
|
|
log(
|
|
|
|
`Updated deployment ${deployment.id} with URL ${record.attributes.url}`
|
|
|
|
);
|
2024-02-19 08:13:29 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
await Promise.all(deploymentUpdatePromises);
|
2024-02-02 09:32:12 +00:00
|
|
|
}
|
|
|
|
|
2024-02-06 13:41:53 +00:00
|
|
|
async getUser (userId: string): Promise<User | null> {
|
2024-02-05 10:51:55 +00:00
|
|
|
return this.db.getUser({
|
|
|
|
where: {
|
|
|
|
id: userId
|
|
|
|
}
|
|
|
|
});
|
2024-02-02 09:32:12 +00:00
|
|
|
}
|
|
|
|
|
2024-02-22 11:56:26 +00:00
|
|
|
async loadOrCreateUser (ethAddress: string): Promise<User> {
|
|
|
|
// Get user by ETH address
|
|
|
|
let user = await this.db.getUser({
|
|
|
|
where: {
|
|
|
|
ethAddress
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
if (!user) {
|
|
|
|
const [org] = await this.db.getOrganizations({});
|
|
|
|
assert(org, 'No organizations exists in database');
|
|
|
|
|
|
|
|
// Create user with new address
|
|
|
|
user = await this.db.addUser({
|
|
|
|
email: `${ethAddress}@example.com`,
|
|
|
|
name: ethAddress,
|
|
|
|
isVerified: true,
|
|
|
|
ethAddress
|
|
|
|
});
|
|
|
|
|
|
|
|
await this.db.addUserOrganization({
|
|
|
|
member: user,
|
|
|
|
organization: org,
|
|
|
|
role: Role.Owner
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
return user;
|
|
|
|
}
|
|
|
|
|
2024-02-14 05:33:22 +00:00
|
|
|
async getOctokit (userId: string): Promise<Octokit> {
|
|
|
|
const user = await this.db.getUser({ where: { id: userId } });
|
2024-02-22 05:45:17 +00:00
|
|
|
assert(
|
|
|
|
user && user.gitHubToken,
|
|
|
|
'User needs to be authenticated with GitHub token'
|
|
|
|
);
|
2024-02-14 05:33:22 +00:00
|
|
|
|
|
|
|
return new Octokit({ auth: user.gitHubToken });
|
|
|
|
}
|
|
|
|
|
2024-02-22 11:56:26 +00:00
|
|
|
async getOrganizationsByUserId (user: User): Promise<Organization[]> {
|
|
|
|
const dbOrganizations = await this.db.getOrganizationsByUserId(user.id);
|
2024-02-02 09:32:12 +00:00
|
|
|
return dbOrganizations;
|
|
|
|
}
|
|
|
|
|
|
|
|
async getProjectById (projectId: string): Promise<Project | null> {
|
|
|
|
const dbProject = await this.db.getProjectById(projectId);
|
|
|
|
return dbProject;
|
|
|
|
}
|
|
|
|
|
2024-02-22 11:56:26 +00:00
|
|
|
async getProjectsInOrganization (user: User, organizationSlug: string): Promise<Project[]> {
|
|
|
|
const dbProjects = await this.db.getProjectsInOrganization(user.id, organizationSlug);
|
2024-02-02 09:32:12 +00:00
|
|
|
return dbProjects;
|
|
|
|
}
|
|
|
|
|
2024-02-19 08:13:29 +00:00
|
|
|
async getDeploymentsByProjectId (projectId: string): Promise<Deployment[]> {
|
2024-02-02 09:32:12 +00:00
|
|
|
const dbDeployments = await this.db.getDeploymentsByProjectId(projectId);
|
|
|
|
return dbDeployments;
|
|
|
|
}
|
|
|
|
|
2024-02-22 05:45:17 +00:00
|
|
|
async getEnvironmentVariablesByProjectId (
|
|
|
|
projectId: string
|
|
|
|
): Promise<EnvironmentVariable[]> {
|
|
|
|
const dbEnvironmentVariables =
|
|
|
|
await this.db.getEnvironmentVariablesByProjectId(projectId);
|
2024-02-02 09:32:12 +00:00
|
|
|
return dbEnvironmentVariables;
|
|
|
|
}
|
|
|
|
|
2024-02-22 05:45:17 +00:00
|
|
|
async getProjectMembersByProjectId (
|
|
|
|
projectId: string
|
|
|
|
): Promise<ProjectMember[]> {
|
|
|
|
const dbProjectMembers =
|
|
|
|
await this.db.getProjectMembersByProjectId(projectId);
|
2024-02-02 09:32:12 +00:00
|
|
|
return dbProjectMembers;
|
|
|
|
}
|
|
|
|
|
2024-02-22 11:56:26 +00:00
|
|
|
async searchProjects (user: User, searchText: string): Promise<Project[]> {
|
|
|
|
const dbProjects = await this.db.getProjectsBySearchText(user.id, searchText);
|
2024-02-02 09:32:12 +00:00
|
|
|
return dbProjects;
|
|
|
|
}
|
|
|
|
|
2024-02-22 05:45:17 +00:00
|
|
|
async getDomainsByProjectId (
|
|
|
|
projectId: string,
|
|
|
|
filter?: FindOptionsWhere<Domain>
|
|
|
|
): Promise<Domain[]> {
|
2024-02-06 08:48:06 +00:00
|
|
|
const dbDomains = await this.db.getDomainsByProjectId(projectId, filter);
|
2024-02-02 09:32:12 +00:00
|
|
|
return dbDomains;
|
|
|
|
}
|
2024-02-05 10:51:55 +00:00
|
|
|
|
2024-02-22 05:45:17 +00:00
|
|
|
async updateProjectMember (
|
|
|
|
projectMemberId: string,
|
|
|
|
data: { permissions: Permission[] }
|
|
|
|
): Promise<boolean> {
|
2024-02-06 08:48:06 +00:00
|
|
|
return this.db.updateProjectMemberById(projectMemberId, data);
|
2024-02-05 10:51:55 +00:00
|
|
|
}
|
|
|
|
|
2024-02-22 05:45:17 +00:00
|
|
|
async addProjectMember (
|
|
|
|
projectId: string,
|
2024-02-05 10:51:55 +00:00
|
|
|
data: {
|
2024-02-22 05:45:17 +00:00
|
|
|
email: string;
|
|
|
|
permissions: Permission[];
|
|
|
|
}
|
|
|
|
): Promise<ProjectMember> {
|
2024-02-06 08:48:06 +00:00
|
|
|
// TODO: Send invitation
|
|
|
|
let user = await this.db.getUser({
|
|
|
|
where: {
|
|
|
|
email: data.email
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
if (!user) {
|
|
|
|
user = await this.db.addUser({
|
|
|
|
email: data.email
|
2024-02-05 10:51:55 +00:00
|
|
|
});
|
2024-02-06 08:48:06 +00:00
|
|
|
}
|
2024-02-05 10:51:55 +00:00
|
|
|
|
2024-02-06 08:48:06 +00:00
|
|
|
const newProjectMember = await this.db.addProjectMember({
|
|
|
|
project: {
|
|
|
|
id: projectId
|
|
|
|
},
|
|
|
|
permissions: data.permissions,
|
|
|
|
isPending: true,
|
|
|
|
member: {
|
|
|
|
id: user.id
|
2024-02-05 10:51:55 +00:00
|
|
|
}
|
2024-02-06 08:48:06 +00:00
|
|
|
});
|
2024-02-05 10:51:55 +00:00
|
|
|
|
2024-02-06 08:48:06 +00:00
|
|
|
return newProjectMember;
|
|
|
|
}
|
|
|
|
|
2024-02-22 11:56:26 +00:00
|
|
|
async removeProjectMember (user: User, projectMemberId: string): Promise<boolean> {
|
2024-02-06 08:48:06 +00:00
|
|
|
const member = await this.db.getProjectMemberById(projectMemberId);
|
|
|
|
|
2024-02-22 11:56:26 +00:00
|
|
|
if (String(member.member.id) === user.id) {
|
2024-02-06 08:48:06 +00:00
|
|
|
throw new Error('Invalid operation: cannot remove self');
|
|
|
|
}
|
|
|
|
|
|
|
|
const memberProject = member.project;
|
|
|
|
assert(memberProject);
|
2024-02-05 10:51:55 +00:00
|
|
|
|
2024-02-22 11:56:26 +00:00
|
|
|
if (String(user.id) === String(memberProject.owner.id)) {
|
2024-02-06 08:48:06 +00:00
|
|
|
return this.db.removeProjectMemberById(projectMemberId);
|
|
|
|
} else {
|
|
|
|
throw new Error('Invalid operation: not authorized');
|
2024-02-05 10:51:55 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-02-22 05:45:17 +00:00
|
|
|
async addEnvironmentVariables (
|
|
|
|
projectId: string,
|
|
|
|
data: { environments: string[]; key: string; value: string }[]
|
|
|
|
): Promise<EnvironmentVariable[]> {
|
|
|
|
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
|
|
|
|
})
|
|
|
|
};
|
2024-02-05 10:51:55 +00:00
|
|
|
});
|
2024-02-22 05:45:17 +00:00
|
|
|
})
|
|
|
|
.flat();
|
2024-02-05 10:51:55 +00:00
|
|
|
|
2024-02-22 05:45:17 +00:00
|
|
|
const savedEnvironmentVariables = await this.db.addEnvironmentVariables(
|
|
|
|
formattedEnvironmentVariables
|
|
|
|
);
|
2024-02-06 08:48:06 +00:00
|
|
|
return savedEnvironmentVariables;
|
2024-02-05 10:51:55 +00:00
|
|
|
}
|
|
|
|
|
2024-02-22 05:45:17 +00:00
|
|
|
async updateEnvironmentVariable (
|
|
|
|
environmentVariableId: string,
|
|
|
|
data: DeepPartial<EnvironmentVariable>
|
|
|
|
): Promise<boolean> {
|
2024-02-06 08:48:06 +00:00
|
|
|
return this.db.updateEnvironmentVariable(environmentVariableId, data);
|
2024-02-05 10:51:55 +00:00
|
|
|
}
|
|
|
|
|
2024-02-22 05:45:17 +00:00
|
|
|
async removeEnvironmentVariable (
|
|
|
|
environmentVariableId: string
|
|
|
|
): Promise<boolean> {
|
2024-02-06 08:48:06 +00:00
|
|
|
return this.db.deleteEnvironmentVariable(environmentVariableId);
|
2024-02-05 10:51:55 +00:00
|
|
|
}
|
|
|
|
|
2024-02-22 11:56:26 +00:00
|
|
|
async updateDeploymentToProd (user: User, deploymentId: string): Promise<Deployment> {
|
2024-02-12 06:04:01 +00:00
|
|
|
const oldDeployment = await this.db.getDeployment({
|
|
|
|
where: { id: deploymentId },
|
|
|
|
relations: {
|
|
|
|
project: true
|
|
|
|
}
|
|
|
|
});
|
2024-02-06 08:48:06 +00:00
|
|
|
|
2024-02-12 06:04:01 +00:00
|
|
|
if (!oldDeployment) {
|
2024-02-06 08:48:06 +00:00
|
|
|
throw new Error('Deployment does not exist');
|
|
|
|
}
|
|
|
|
|
2024-02-22 05:45:17 +00:00
|
|
|
const prodBranchDomains = await this.db.getDomainsByProjectId(
|
|
|
|
oldDeployment.project.id,
|
|
|
|
{ branch: oldDeployment.project.prodBranch }
|
|
|
|
);
|
2024-02-06 08:48:06 +00:00
|
|
|
|
2024-02-22 11:56:26 +00:00
|
|
|
const octokit = await this.getOctokit(user.id);
|
2024-02-14 05:33:22 +00:00
|
|
|
|
2024-02-22 11:56:26 +00:00
|
|
|
const newDeployment = await this.createDeployment(user.id,
|
2024-02-14 05:33:22 +00:00
|
|
|
octokit,
|
2024-02-12 06:04:01 +00:00
|
|
|
{
|
|
|
|
project: oldDeployment.project,
|
|
|
|
branch: oldDeployment.branch,
|
|
|
|
environment: Environment.Production,
|
|
|
|
domain: prodBranchDomains[0],
|
2024-02-14 08:55:50 +00:00
|
|
|
commitHash: oldDeployment.commitHash,
|
|
|
|
commitMessage: oldDeployment.commitMessage
|
2024-02-12 06:04:01 +00:00
|
|
|
});
|
2024-02-08 09:29:19 +00:00
|
|
|
|
2024-02-19 08:13:29 +00:00
|
|
|
return newDeployment;
|
2024-02-12 06:04:01 +00:00
|
|
|
}
|
|
|
|
|
2024-02-15 11:54:57 +00:00
|
|
|
async createDeployment (
|
|
|
|
userId: string,
|
|
|
|
octokit: Octokit,
|
|
|
|
data: DeepPartial<Deployment>,
|
|
|
|
recordData: { repoUrl?: string } = {}
|
|
|
|
): Promise<Deployment> {
|
2024-02-14 05:33:22 +00:00
|
|
|
assert(data.project?.repository, 'Project repository not found');
|
2024-02-22 05:45:17 +00:00
|
|
|
log(
|
|
|
|
`Creating deployment in project ${data.project.name} from branch ${data.branch}`
|
|
|
|
);
|
2024-02-14 05:33:22 +00:00
|
|
|
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');
|
2024-02-22 04:34:33 +00:00
|
|
|
const packageJSON: PackageJSON = JSON.parse(atob(packageJSONData.content));
|
|
|
|
|
|
|
|
assert(packageJSON.name, "name field doesn't exist in package.json");
|
2024-02-14 05:33:22 +00:00
|
|
|
|
2024-02-15 11:54:57 +00:00
|
|
|
if (!recordData.repoUrl) {
|
2024-02-22 05:45:17 +00:00
|
|
|
const { data: repoDetails } = await octokit.rest.repos.get({
|
|
|
|
owner,
|
|
|
|
repo
|
|
|
|
});
|
2024-02-15 11:54:57 +00:00
|
|
|
recordData.repoUrl = repoDetails.html_url;
|
|
|
|
}
|
|
|
|
|
2024-02-15 12:32:37 +00:00
|
|
|
// TODO: Set environment variables for each deployment (environment variables can`t be set in application record)
|
2024-02-22 05:45:17 +00:00
|
|
|
const { applicationRecordId, applicationRecordData } =
|
|
|
|
await this.registry.createApplicationRecord({
|
|
|
|
appName: repo,
|
|
|
|
packageJSON,
|
|
|
|
appType: data.project!.template!,
|
|
|
|
commitHash: data.commitHash!,
|
|
|
|
repoUrl: recordData.repoUrl
|
|
|
|
});
|
2024-02-15 11:54:57 +00:00
|
|
|
|
|
|
|
// Update previous deployment with prod branch domain
|
|
|
|
// TODO: Fix unique constraint error for domain
|
2024-02-22 07:20:35 +00:00
|
|
|
if (data.domain) {
|
|
|
|
await this.db.updateDeployment({
|
|
|
|
domainId: data.domain.id
|
|
|
|
}, {
|
|
|
|
domain: null
|
|
|
|
});
|
|
|
|
}
|
2024-02-06 08:48:06 +00:00
|
|
|
|
2024-02-19 08:13:29 +00:00
|
|
|
const newDeployment = await this.db.addDeployment({
|
2024-02-12 06:04:01 +00:00
|
|
|
project: data.project,
|
|
|
|
branch: data.branch,
|
|
|
|
commitHash: data.commitHash,
|
2024-02-14 08:55:50 +00:00
|
|
|
commitMessage: data.commitMessage,
|
2024-02-12 06:04:01 +00:00
|
|
|
environment: data.environment,
|
|
|
|
status: DeploymentStatus.Building,
|
2024-02-19 08:13:29 +00:00
|
|
|
applicationRecordId,
|
|
|
|
applicationRecordData,
|
2024-02-12 06:04:01 +00:00
|
|
|
domain: data.domain,
|
|
|
|
createdBy: Object.assign(new User(), {
|
|
|
|
id: userId
|
|
|
|
})
|
|
|
|
});
|
2024-02-08 09:29:19 +00:00
|
|
|
|
2024-02-19 08:13:29 +00:00
|
|
|
log(`Created deployment ${newDeployment.id} and published application record ${applicationRecordId}`);
|
2024-02-22 07:20:35 +00:00
|
|
|
|
|
|
|
const environmentVariables = await this.db.getEnvironmentVariablesByProjectId(data.project.id!, { environment: Environment.Production });
|
|
|
|
|
|
|
|
const environmentVariablesObj = environmentVariables.reduce((acc, env) => {
|
|
|
|
acc[env.key] = env.value;
|
|
|
|
|
|
|
|
return acc;
|
|
|
|
}, {} as { [key: string]: string });
|
|
|
|
|
|
|
|
const { applicationDeploymentRequestId, applicationDeploymentRequestData } = await this.registry.createApplicationDeploymentRequest(
|
|
|
|
{
|
|
|
|
deployment: newDeployment,
|
|
|
|
appName: repo,
|
|
|
|
packageJsonName: packageJSON.name,
|
|
|
|
repository: recordData.repoUrl,
|
|
|
|
environmentVariables: environmentVariablesObj
|
|
|
|
});
|
|
|
|
|
|
|
|
await this.db.updateDeploymentById(newDeployment.id, { applicationDeploymentRequestId, applicationDeploymentRequestData });
|
|
|
|
|
2024-02-19 08:13:29 +00:00
|
|
|
return newDeployment;
|
2024-02-05 10:51:55 +00:00
|
|
|
}
|
|
|
|
|
2024-02-22 11:56:26 +00:00
|
|
|
async addProject (user: User, organizationSlug: string, data: DeepPartial<Project>): Promise<Project | undefined> {
|
2024-02-07 13:11:54 +00:00
|
|
|
const organization = await this.db.getOrganization({
|
|
|
|
where: {
|
|
|
|
slug: organizationSlug
|
|
|
|
}
|
|
|
|
});
|
|
|
|
if (!organization) {
|
|
|
|
throw new Error('Organization does not exist');
|
|
|
|
}
|
|
|
|
|
2024-02-22 11:56:26 +00:00
|
|
|
const project = await this.db.addProject(user, organization.id, data);
|
2024-02-12 06:04:01 +00:00
|
|
|
|
2024-02-22 11:56:26 +00:00
|
|
|
const octokit = await this.getOctokit(user.id);
|
2024-02-14 05:33:22 +00:00
|
|
|
const [owner, repo] = project.repository.split('/');
|
|
|
|
|
2024-02-22 05:45:17 +00:00
|
|
|
const {
|
|
|
|
data: [latestCommit]
|
|
|
|
} = await octokit.rest.repos.listCommits({
|
2024-02-14 05:33:22 +00:00
|
|
|
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
|
2024-02-22 11:56:26 +00:00
|
|
|
await this.createDeployment(user.id,
|
2024-02-14 05:33:22 +00:00
|
|
|
octokit,
|
2024-02-12 06:04:01 +00:00
|
|
|
{
|
|
|
|
project,
|
|
|
|
branch: project.prodBranch,
|
|
|
|
environment: Environment.Production,
|
2024-02-14 05:33:22 +00:00
|
|
|
domain: null,
|
2024-02-14 08:55:50 +00:00
|
|
|
commitHash: latestCommit.sha,
|
|
|
|
commitMessage: latestCommit.commit.message
|
2024-02-15 11:54:57 +00:00
|
|
|
},
|
|
|
|
{
|
|
|
|
repoUrl: repoDetails.html_url
|
|
|
|
}
|
|
|
|
);
|
2024-02-12 06:04:01 +00:00
|
|
|
|
2024-02-15 11:54:57 +00:00
|
|
|
await this.createRepoHook(octokit, project);
|
2024-02-14 05:33:22 +00:00
|
|
|
|
2024-02-12 06:04:01 +00:00
|
|
|
return project;
|
2024-02-05 10:51:55 +00:00
|
|
|
}
|
|
|
|
|
2024-02-15 11:54:57 +00:00
|
|
|
async createRepoHook (octokit: Octokit, project: Project): Promise<void> {
|
|
|
|
try {
|
|
|
|
const [owner, repo] = project.repository.split('/');
|
|
|
|
await octokit.rest.repos.createWebhook({
|
|
|
|
owner,
|
|
|
|
repo,
|
|
|
|
config: {
|
2024-02-22 05:45:17 +00:00
|
|
|
url: new URL(
|
|
|
|
'api/github/webhook',
|
|
|
|
this.config.gitHubConfig.webhookUrl
|
|
|
|
).href,
|
2024-02-15 11:54:57 +00:00
|
|
|
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 (
|
2024-02-22 05:45:17 +00:00
|
|
|
!(
|
|
|
|
err instanceof RequestError &&
|
|
|
|
err.status === 422 &&
|
|
|
|
(err.response?.data as any).errors.some(
|
|
|
|
(err: any) => err.message === GITHUB_UNIQUE_WEBHOOK_ERROR
|
|
|
|
)
|
|
|
|
)
|
2024-02-15 12:32:37 +00:00
|
|
|
) {
|
2024-02-15 11:54:57 +00:00
|
|
|
throw err;
|
|
|
|
}
|
|
|
|
|
|
|
|
log(GITHUB_UNIQUE_WEBHOOK_ERROR);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
async handleGitHubPush (data: GitPushEventPayload): Promise<void> {
|
|
|
|
const { repository, ref, head_commit: headCommit } = data;
|
|
|
|
log(`Handling GitHub push event from repository: ${repository.full_name}`);
|
2024-02-22 05:45:17 +00:00
|
|
|
const projects = await this.db.getProjects({
|
|
|
|
where: { repository: repository.full_name }
|
|
|
|
});
|
2024-02-15 11:54:57 +00:00
|
|
|
|
|
|
|
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);
|
2024-02-22 05:45:17 +00:00
|
|
|
const [domain] = await this.db.getDomainsByProjectId(project.id, {
|
|
|
|
branch
|
|
|
|
});
|
2024-02-15 11:54:57 +00:00
|
|
|
|
|
|
|
// Create deployment with branch and latest commit in GitHub data
|
2024-02-22 05:45:17 +00:00
|
|
|
await this.createDeployment(project.ownerId, octokit, {
|
|
|
|
project,
|
|
|
|
branch,
|
|
|
|
environment:
|
|
|
|
project.prodBranch === branch
|
|
|
|
? Environment.Production
|
|
|
|
: Environment.Preview,
|
|
|
|
domain,
|
|
|
|
commitHash: headCommit.id,
|
|
|
|
commitMessage: headCommit.message
|
|
|
|
});
|
2024-02-15 11:54:57 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-02-22 05:45:17 +00:00
|
|
|
async updateProject (
|
|
|
|
projectId: string,
|
|
|
|
data: DeepPartial<Project>
|
|
|
|
): Promise<boolean> {
|
2024-02-06 08:48:06 +00:00
|
|
|
return this.db.updateProjectById(projectId, data);
|
2024-02-05 10:51:55 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
async deleteProject (projectId: string): Promise<boolean> {
|
2024-02-15 11:54:57 +00:00
|
|
|
// TODO: Remove GitHub repo hook
|
2024-02-06 08:48:06 +00:00
|
|
|
return this.db.deleteProjectById(projectId);
|
2024-02-05 10:51:55 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
async deleteDomain (domainId: string): Promise<boolean> {
|
2024-02-06 08:48:06 +00:00
|
|
|
const domainsRedirectedFrom = await this.db.getDomains({
|
|
|
|
where: {
|
2024-02-06 13:41:53 +00:00
|
|
|
redirectToId: domainId
|
2024-02-06 08:48:06 +00:00
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
if (domainsRedirectedFrom.length > 0) {
|
2024-02-22 05:45:17 +00:00
|
|
|
throw new Error(
|
|
|
|
'Cannot delete domain since it has redirects from other domains'
|
|
|
|
);
|
2024-02-06 08:48:06 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
return this.db.deleteDomainById(domainId);
|
|
|
|
}
|
|
|
|
|
2024-02-22 11:56:26 +00:00
|
|
|
async redeployToProd (user: User, deploymentId: string): Promise<Deployment> {
|
2024-02-12 06:04:01 +00:00
|
|
|
const oldDeployment = await this.db.getDeployment({
|
2024-02-06 08:48:06 +00:00
|
|
|
relations: {
|
|
|
|
project: true,
|
|
|
|
domain: true,
|
|
|
|
createdBy: true
|
|
|
|
},
|
|
|
|
where: {
|
|
|
|
id: deploymentId
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
2024-02-12 06:04:01 +00:00
|
|
|
if (oldDeployment === null) {
|
2024-02-06 08:48:06 +00:00
|
|
|
throw new Error('Deployment not found');
|
|
|
|
}
|
|
|
|
|
2024-02-22 11:56:26 +00:00
|
|
|
const octokit = await this.getOctokit(user.id);
|
2024-02-14 05:33:22 +00:00
|
|
|
|
2024-02-22 11:56:26 +00:00
|
|
|
const newDeployment = await this.createDeployment(user.id,
|
2024-02-14 05:33:22 +00:00
|
|
|
octokit,
|
2024-02-12 06:04:01 +00:00
|
|
|
{
|
|
|
|
project: oldDeployment.project,
|
|
|
|
// TODO: Put isCurrent field in project
|
|
|
|
branch: oldDeployment.branch,
|
|
|
|
environment: Environment.Production,
|
|
|
|
domain: oldDeployment.domain,
|
2024-02-14 08:55:50 +00:00
|
|
|
commitHash: oldDeployment.commitHash,
|
|
|
|
commitMessage: oldDeployment.commitMessage
|
2024-02-05 10:51:55 +00:00
|
|
|
});
|
2024-02-06 08:48:06 +00:00
|
|
|
|
2024-02-19 08:13:29 +00:00
|
|
|
return newDeployment;
|
2024-02-06 08:48:06 +00:00
|
|
|
}
|
2024-02-05 10:51:55 +00:00
|
|
|
|
2024-02-22 05:45:17 +00:00
|
|
|
async rollbackDeployment (
|
|
|
|
projectId: string,
|
|
|
|
deploymentId: string
|
|
|
|
): Promise<boolean> {
|
2024-02-06 08:48:06 +00:00
|
|
|
// TODO: Implement transactions
|
|
|
|
const oldCurrentDeployment = await this.db.getDeployment({
|
|
|
|
relations: {
|
|
|
|
domain: true
|
|
|
|
},
|
|
|
|
where: {
|
|
|
|
project: {
|
|
|
|
id: projectId
|
|
|
|
},
|
|
|
|
isCurrent: true
|
2024-02-05 10:51:55 +00:00
|
|
|
}
|
2024-02-06 08:48:06 +00:00
|
|
|
});
|
2024-02-05 10:51:55 +00:00
|
|
|
|
2024-02-06 08:48:06 +00:00
|
|
|
if (!oldCurrentDeployment) {
|
2024-02-19 08:13:29 +00:00
|
|
|
throw new Error('Current deployment doesnot exist');
|
2024-02-05 10:51:55 +00:00
|
|
|
}
|
2024-02-06 08:48:06 +00:00
|
|
|
|
2024-02-22 05:45:17 +00:00
|
|
|
const oldCurrentDeploymentUpdate = await this.db.updateDeploymentById(
|
|
|
|
oldCurrentDeployment.id,
|
|
|
|
{ isCurrent: false, domain: null }
|
|
|
|
);
|
2024-02-06 08:48:06 +00:00
|
|
|
|
2024-02-22 05:45:17 +00:00
|
|
|
const newCurrentDeploymentUpdate = await this.db.updateDeploymentById(
|
|
|
|
deploymentId,
|
|
|
|
{ isCurrent: true, domain: oldCurrentDeployment?.domain }
|
|
|
|
);
|
2024-02-06 08:48:06 +00:00
|
|
|
|
|
|
|
return newCurrentDeploymentUpdate && oldCurrentDeploymentUpdate;
|
2024-02-05 10:51:55 +00:00
|
|
|
}
|
|
|
|
|
2024-02-22 05:45:17 +00:00
|
|
|
async addDomain (
|
|
|
|
projectId: string,
|
|
|
|
data: { name: string }
|
|
|
|
): Promise<{
|
|
|
|
primaryDomain: Domain;
|
|
|
|
redirectedDomain: Domain;
|
2024-02-06 08:48:06 +00:00
|
|
|
}> {
|
|
|
|
const currentProject = await this.db.getProjectById(projectId);
|
|
|
|
|
|
|
|
if (currentProject === null) {
|
|
|
|
throw new Error(`Project with ${projectId} not found`);
|
|
|
|
}
|
|
|
|
|
|
|
|
const primaryDomainDetails = {
|
2024-02-14 12:05:02 +00:00
|
|
|
...data,
|
2024-02-06 08:48:06 +00:00
|
|
|
branch: currentProject.prodBranch,
|
|
|
|
project: currentProject
|
|
|
|
};
|
|
|
|
|
|
|
|
const savedPrimaryDomain = await this.db.addDomain(primaryDomainDetails);
|
|
|
|
|
2024-02-14 12:05:02 +00:00
|
|
|
const domainArr = data.name.split('www.');
|
2024-02-06 08:48:06 +00:00
|
|
|
|
|
|
|
const redirectedDomainDetails = {
|
|
|
|
name: domainArr.length > 1 ? domainArr[1] : `www.${domainArr[0]}`,
|
|
|
|
branch: currentProject.prodBranch,
|
|
|
|
project: currentProject,
|
|
|
|
redirectTo: savedPrimaryDomain
|
|
|
|
};
|
|
|
|
|
2024-02-22 05:45:17 +00:00
|
|
|
const savedRedirectedDomain = await this.db.addDomain(
|
|
|
|
redirectedDomainDetails
|
|
|
|
);
|
2024-02-06 08:48:06 +00:00
|
|
|
|
2024-02-22 05:45:17 +00:00
|
|
|
return {
|
|
|
|
primaryDomain: savedPrimaryDomain,
|
|
|
|
redirectedDomain: savedRedirectedDomain
|
|
|
|
};
|
2024-02-06 08:48:06 +00:00
|
|
|
}
|
|
|
|
|
2024-02-22 05:45:17 +00:00
|
|
|
async updateDomain (
|
|
|
|
domainId: string,
|
|
|
|
data: DeepPartial<Domain>
|
|
|
|
): Promise<boolean> {
|
2024-02-06 08:48:06 +00:00
|
|
|
const domain = await this.db.getDomain({
|
|
|
|
where: {
|
2024-02-06 13:41:53 +00:00
|
|
|
id: domainId
|
2024-02-06 08:48:06 +00:00
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
if (domain === null) {
|
|
|
|
throw new Error(`Error finding domain with id ${domainId}`);
|
|
|
|
}
|
|
|
|
|
|
|
|
const newDomain = {
|
2024-02-14 12:05:02 +00:00
|
|
|
...data
|
2024-02-06 08:48:06 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
const domainsRedirectedFrom = await this.db.getDomains({
|
|
|
|
where: {
|
|
|
|
project: {
|
|
|
|
id: domain.projectId
|
2024-02-05 10:51:55 +00:00
|
|
|
},
|
2024-02-06 08:48:06 +00:00
|
|
|
redirectToId: domain.id
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
// If there are domains redirecting to current domain, only branch of current domain can be updated
|
2024-02-14 12:05:02 +00:00
|
|
|
if (domainsRedirectedFrom.length > 0 && data.branch === domain.branch) {
|
2024-02-06 08:48:06 +00:00
|
|
|
throw new Error('Remove all redirects to this domain before updating');
|
|
|
|
}
|
|
|
|
|
2024-02-14 12:05:02 +00:00
|
|
|
if (data.redirectToId) {
|
2024-02-06 08:48:06 +00:00
|
|
|
const redirectedDomain = await this.db.getDomain({
|
2024-02-05 10:51:55 +00:00
|
|
|
where: {
|
2024-02-14 12:05:02 +00:00
|
|
|
id: data.redirectToId
|
2024-02-05 10:51:55 +00:00
|
|
|
}
|
|
|
|
});
|
|
|
|
|
2024-02-06 08:48:06 +00:00
|
|
|
if (redirectedDomain === null) {
|
|
|
|
throw new Error('Could not find Domain to redirect to');
|
2024-02-05 10:51:55 +00:00
|
|
|
}
|
|
|
|
|
2024-02-06 08:48:06 +00:00
|
|
|
if (redirectedDomain.redirectToId) {
|
2024-02-22 05:45:17 +00:00
|
|
|
throw new Error(
|
|
|
|
'Unable to redirect to the domain because it is already redirecting elsewhere. Redirects cannot be chained.'
|
|
|
|
);
|
2024-02-05 10:51:55 +00:00
|
|
|
}
|
|
|
|
|
2024-02-06 08:48:06 +00:00
|
|
|
newDomain.redirectTo = redirectedDomain;
|
|
|
|
}
|
2024-02-05 10:51:55 +00:00
|
|
|
|
2024-02-06 08:48:06 +00:00
|
|
|
const updateResult = await this.db.updateDomainById(domainId, newDomain);
|
2024-02-05 10:51:55 +00:00
|
|
|
|
2024-02-06 08:48:06 +00:00
|
|
|
return updateResult;
|
2024-02-05 10:51:55 +00:00
|
|
|
}
|
2024-02-08 09:29:19 +00:00
|
|
|
|
2024-02-22 11:56:26 +00:00
|
|
|
async authenticateGitHub (code:string, user: User): Promise<{token: string}> {
|
2024-02-14 05:33:22 +00:00
|
|
|
const { authentication: { token } } = await this.oauthApp.createToken({
|
2024-02-08 09:29:19 +00:00
|
|
|
code
|
|
|
|
});
|
|
|
|
|
2024-02-22 11:56:26 +00:00
|
|
|
await this.db.updateUser(user, { gitHubToken: token });
|
2024-02-08 09:29:19 +00:00
|
|
|
|
|
|
|
return { token };
|
|
|
|
}
|
|
|
|
|
2024-02-22 11:56:26 +00:00
|
|
|
async unauthenticateGitHub (user: User, data: DeepPartial<User>): Promise<boolean> {
|
|
|
|
return this.db.updateUser(user, data);
|
2024-02-08 09:29:19 +00:00
|
|
|
}
|
2024-02-02 09:32:12 +00:00
|
|
|
}
|