Integrate SP auctions for app deployment (#2)

Part of [Service provider auctions for web deployments](https://www.notion.so/Service-provider-auctions-for-web-deployments-104a6b22d47280dbad51d28aa3a91d75)
- Add support for configuring web-app deployers by -
  - Configuring deployer LRN (for targeted deployments)
  - Configuring SP auction params for deployment auction (max price and number of providers)

Co-authored-by: IshaVenikar <ishavenikar7@gmail.com>
Reviewed-on: cerc-io/snowballtools-base#2
This commit is contained in:
2024-10-18 12:37:01 +00:00
co-authored by IshaVenikar
parent 42bdd21089
commit 5aefda1248
41 changed files with 1609 additions and 2347 deletions
@@ -29,6 +29,7 @@
[registryConfig]
fetchDeploymentRecordDelay = 5000
checkAuctionStatusDelay = 5000
restEndpoint = "http://localhost:1317"
gqlEndpoint = "http://localhost:9473/api"
chainId = "laconic_9000-1"
@@ -36,9 +37,17 @@
bondId = ""
authority = ""
[registryConfig.fee]
gas = "200000"
fees = "200000alnt"
gasPrice = ""
gas = ""
fees = ""
gasPrice = "1alnt"
# Durations are set to 2 mins as deployers may take time with ongoing deployments and auctions
[auction]
commitFee = "100000"
commitsDuration = "120s"
revealFee = "100000"
revealsDuration = "120s"
denom = "alnt"
[misc]
projectDomain = "apps.snowballtools.com"
+10
View File
@@ -34,6 +34,7 @@ export interface RegistryConfig {
privateKey: string;
bondId: string;
fetchDeploymentRecordDelay: number;
checkAuctionStatusDelay: number;
authority: string;
fee: {
gas: string;
@@ -42,6 +43,14 @@ export interface RegistryConfig {
};
}
export interface AuctionConfig {
commitFee: string;
commitsDuration: string;
revealFee: string;
revealsDuration: string;
denom: string;
}
export interface MiscConfig {
projectDomain: string;
}
@@ -51,6 +60,7 @@ export interface Config {
database: DatabaseConfig;
gitHub: GitHubConfig;
registryConfig: RegistryConfig;
auction: AuctionConfig;
misc: MiscConfig;
turnkey: {
apiBaseUrl: string;
-2
View File
@@ -465,8 +465,6 @@ export class Database {
id: organizationId
});
newProject.subDomain = `${newProject.name}.${this.projectDomain}`;
return projectRepository.save(newProject);
}
+11 -3
View File
@@ -33,6 +33,8 @@ export interface ApplicationDeploymentRequest {
version: string;
name: string;
application: string;
lrn?: string;
auction?: string;
config: string;
meta: string;
}
@@ -112,19 +114,22 @@ export class Deployment {
@Column('simple-json', { nullable: true })
applicationDeploymentRecordData!: AppDeploymentRecordAttributes | null;
@Column('varchar', { nullable: true })
applicationDeploymentRemovalRequestId!: string | null;
@Column('simple-json', { nullable: true })
applicationDeploymentRemovalRequestData!: ApplicationDeploymentRemovalRequest | null;
@Column('varchar', { nullable: true })
applicationDeploymentRemovalRecordId!: string | null;
@Column('simple-json', { nullable: true })
applicationDeploymentRemovalRecordData!: AppDeploymentRemovalRecordAttributes | null;
@Column('varchar')
deployerLrn!: string;
@Column({
enum: Environment
})
@@ -133,6 +138,9 @@ export class Deployment {
@Column('boolean', { default: false })
isCurrent!: boolean;
@Column('varchar', { nullable: true })
baseDomain!: string | null;
@Column({
enum: DeploymentStatus
})
@@ -147,7 +155,7 @@ export class Deployment {
@UpdateDateColumn()
updatedAt!: Date;
@DeleteDateColumn()
deletedAt!: Date | null;
}
+8 -2
View File
@@ -46,6 +46,12 @@ export class Project {
@Column('text', { default: '' })
description!: string;
@Column('varchar', { nullable: true })
auctionId!: string | null;
@Column({ type: 'simple-array', nullable: true })
deployerLrns!: string[] | null;
// TODO: Compute template & framework in import repository
@Column('varchar', { nullable: true })
template!: string | null;
@@ -61,8 +67,8 @@ export class Project {
@Column('varchar')
icon!: string;
@Column('varchar')
subDomain!: string;
@Column({ type: 'simple-array', nullable: true })
baseDomains!: string[] | null;
@CreateDateColumn()
createdAt!: Date;
+163 -30
View File
@@ -1,9 +1,11 @@
import debug from 'debug';
import assert from 'assert';
import { inc as semverInc } from 'semver';
import debug from 'debug';
import { DateTime } from 'luxon';
import { Octokit } from 'octokit';
import { inc as semverInc } from 'semver';
import { DeepPartial } from 'typeorm';
import { Registry as LaconicRegistry, parseGasAndFees } from '@cerc-io/registry-sdk';
import { Registry as LaconicRegistry, getGasPrice, parseGasAndFees } from '@cerc-io/registry-sdk';
import { RegistryConfig } from './config';
import {
@@ -12,12 +14,13 @@ import {
ApplicationDeploymentRequest,
ApplicationDeploymentRemovalRequest
} from './entity/Deployment';
import { AppDeploymentRecord, AppDeploymentRemovalRecord, PackageJSON } from './types';
import { sleep } from './utils';
import { AppDeploymentRecord, AppDeploymentRemovalRecord, AuctionParams } from './types';
import { getConfig, getRepoDetails, sleep } from './utils';
const log = debug('snowball:registry');
const APP_RECORD_TYPE = 'ApplicationRecord';
const APP_DEPLOYMENT_AUCTION_RECORD_TYPE = 'ApplicationDeploymentAuction';
const APP_DEPLOYMENT_REQUEST_TYPE = 'ApplicationDeploymentRequest';
const APP_DEPLOYMENT_REMOVAL_REQUEST_TYPE = 'ApplicationDeploymentRemovalRequest';
const APP_DEPLOYMENT_RECORD_TYPE = 'ApplicationDeploymentRecord';
@@ -29,31 +32,33 @@ export class Registry {
private registry: LaconicRegistry;
private registryConfig: RegistryConfig;
constructor (registryConfig: RegistryConfig) {
constructor(registryConfig: RegistryConfig) {
this.registryConfig = registryConfig;
const gasPrice = getGasPrice(registryConfig.fee.gasPrice);
this.registry = new LaconicRegistry(
registryConfig.gqlEndpoint,
registryConfig.restEndpoint,
{ chainId: registryConfig.chainId }
{ chainId: registryConfig.chainId, gasPrice }
);
}
async createApplicationRecord ({
appName,
packageJSON,
async createApplicationRecord({
octokit,
repository,
commitHash,
appType,
repoUrl
}: {
appName: string;
packageJSON: PackageJSON;
octokit: Octokit
repository: string;
commitHash: string;
appType: string;
repoUrl: string;
}): Promise<{
applicationRecordId: string;
applicationRecordData: ApplicationRecord;
}> {
const { repo, repoUrl, packageJSON } = await getRepoDetails(octokit, repository, commitHash)
// Use registry-sdk to publish record
// Reference: https://git.vdb.to/cerc-io/test-progressive-web-app/src/branch/main/scripts/publish-app-record.sh
// Fetch previous records
@@ -87,7 +92,7 @@ export class Registry {
repository_ref: commitHash,
repository: [repoUrl],
app_type: appType,
name: appName,
name: repo,
...(packageJSON.description && { description: packageJSON.description }),
...(packageJSON.homepage && { homepage: packageJSON.homepage }),
...(packageJSON.license && { license: packageJSON.license }),
@@ -112,22 +117,29 @@ export class Registry {
fee
);
log(`Published application record ${result.id}`);
log('Application record data:', applicationRecord);
// TODO: Discuss computation of LRN
const lrn = this.getLrn(appName);
const lrn = this.getLrn(repo);
log(`Setting name: ${lrn} for record ID: ${result.id}`);
await sleep(SLEEP_DURATION);
await this.registry.setName(
{ cid: result.id, lrn },
{
cid: result.id,
lrn
},
this.registryConfig.privateKey,
fee
);
await sleep(SLEEP_DURATION);
await this.registry.setName(
{ cid: result.id, lrn: `${lrn}@${applicationRecord.app_version}` },
{
cid: result.id,
lrn: `${lrn}@${applicationRecord.app_version}`
},
this.registryConfig.privateKey,
fee
);
@@ -148,10 +160,78 @@ export class Registry {
};
}
async createApplicationDeploymentRequest (data: {
async createApplicationDeploymentAuction(
appName: string,
octokit: Octokit,
auctionParams: AuctionParams,
data: DeepPartial<Deployment>,
): Promise<{
applicationDeploymentAuctionId: string;
}> {
assert(data.project?.repository, 'Project repository not found');
await this.createApplicationRecord({
octokit,
repository: data.project.repository,
appType: data.project!.template!,
commitHash: data.commitHash!,
});
const lrn = this.getLrn(appName);
const config = await getConfig();
const auctionConfig = config.auction;
const fee = parseGasAndFees(this.registryConfig.fee.gas, this.registryConfig.fee.fees);
const auctionResult = await this.registry.createProviderAuction(
{
commitFee: auctionConfig.commitFee,
commitsDuration: auctionConfig.commitsDuration,
revealFee: auctionConfig.revealFee,
revealsDuration: auctionConfig.revealsDuration,
denom: auctionConfig.denom,
maxPrice: auctionParams.maxPrice,
numProviders: auctionParams.numProviders,
},
this.registryConfig.privateKey,
fee
)
if (!auctionResult.auction) {
throw new Error('Error creating auction');
}
// Create record of type applicationDeploymentAuction and publish
const applicationDeploymentAuction = {
application: lrn,
auction: auctionResult.auction.id,
type: APP_DEPLOYMENT_AUCTION_RECORD_TYPE,
};
const result = await this.registry.setRecord(
{
privateKey: this.registryConfig.privateKey,
record: applicationDeploymentAuction,
bondId: this.registryConfig.bondId
},
this.registryConfig.privateKey,
fee
);
log(`Application deployment auction created: ${auctionResult.auction.id}`);
log(`Application deployment auction record published: ${result.id}`);
log('Application deployment auction data:', applicationDeploymentAuction);
return {
applicationDeploymentAuctionId: auctionResult.auction.id,
};
}
async createApplicationDeploymentRequest(data: {
deployment: Deployment,
appName: string,
repository: string,
auctionId?: string,
lrn: string,
environmentVariables: { [key: string]: string },
dns: string,
}): Promise<{
@@ -174,9 +254,6 @@ export class Registry {
application: `${lrn}@${applicationRecord.attributes.app_version}`,
dns: data.dns,
// TODO: Not set in test-progressive-web-app CI
// deployment: '$CERC_REGISTRY_DEPLOYMENT_LRN',
// https://git.vdb.to/cerc-io/laconic-registry-cli/commit/129019105dfb93bebcea02fde0ed64d0f8e5983b
config: JSON.stringify({
env: data.environmentVariables
@@ -187,7 +264,9 @@ export class Registry {
)}`,
repository: data.repository,
repository_ref: data.deployment.commitHash
})
}),
deployer: data.lrn,
...(data.auctionId && { auction: data.auctionId }),
};
await sleep(SLEEP_DURATION);
@@ -212,10 +291,38 @@ export class Registry {
};
}
async getAuctionWinningDeployers(
auctionId: string
): Promise<string[]> {
const records = await this.registry.getAuctionsByIds([auctionId]);
const auctionResult = records[0];
let deployerLrns = [];
const { winnerAddresses } = auctionResult;
for (const auctionWinner of winnerAddresses) {
const deployerRecords = await this.registry.queryRecords(
{
paymentAddress: auctionWinner,
},
true
);
for (const record of deployerRecords) {
if (record.names && record.names.length > 0) {
deployerLrns.push(record.names[0]);
break;
}
}
}
return deployerLrns;
}
/**
* Fetch ApplicationDeploymentRecords for deployments
*/
async getDeploymentRecords (
async getDeploymentRecords(
deployments: Deployment[]
): Promise<AppDeploymentRecord[]> {
// Fetch ApplicationDeploymentRecords for corresponding ApplicationRecord set in deployments
@@ -227,11 +334,11 @@ export class Registry {
true
);
// Filter records with ApplicationRecord ID and Deployment specific URL
// Filter records with ApplicationDeploymentRequestId ID and Deployment specific URL
return records.filter((record: AppDeploymentRecord) =>
deployments.some(
(deployment) =>
deployment.applicationRecordId === record.attributes.application &&
deployment.applicationDeploymentRequestId === record.attributes.request &&
record.attributes.url.includes(deployment.id)
)
);
@@ -240,7 +347,7 @@ export class Registry {
/**
* Fetch ApplicationDeploymentRecords by filter
*/
async getDeploymentRecordsByFilter (filter: { [key: string]: any }): Promise<AppDeploymentRecord[]> {
async getDeploymentRecordsByFilter(filter: { [key: string]: any }): Promise<AppDeploymentRecord[]> {
return this.registry.queryRecords(
{
type: APP_DEPLOYMENT_RECORD_TYPE,
@@ -253,7 +360,7 @@ export class Registry {
/**
* Fetch ApplicationDeploymentRemovalRecords for deployments
*/
async getDeploymentRemovalRecords (
async getDeploymentRemovalRecords(
deployments: Deployment[]
): Promise<AppDeploymentRemovalRecord[]> {
// Fetch ApplicationDeploymentRemovalRecords for corresponding ApplicationDeploymentRecord set in deployments
@@ -274,8 +381,9 @@ export class Registry {
);
}
async createApplicationDeploymentRemovalRequest (data: {
async createApplicationDeploymentRemovalRequest(data: {
deploymentId: string;
deployerLrn: string;
}): Promise<{
applicationDeploymentRemovalRequestId: string;
applicationDeploymentRemovalRequestData: ApplicationDeploymentRemovalRequest;
@@ -283,7 +391,8 @@ export class Registry {
const applicationDeploymentRemovalRequest = {
type: APP_DEPLOYMENT_REMOVAL_REQUEST_TYPE,
version: '1.0.0',
deployment: data.deploymentId
deployment: data.deploymentId,
deployer: data.deployerLrn
};
const fee = parseGasAndFees(this.registryConfig.fee.gas, this.registryConfig.fee.fees);
@@ -307,6 +416,30 @@ export class Registry {
};
}
async getCompletedAuctionIds(auctionIds: (string | null | undefined)[]): Promise<string[] | null> {
const validAuctionIds = auctionIds.filter((id): id is string => id !== null && id !== undefined);
if (!validAuctionIds.length) {
return null;
}
const auctions = await this.registry.getAuctionsByIds(validAuctionIds);
const completedAuctions = auctions
.filter((auction: { id: string, status: string }) => auction.status === 'completed')
.map((auction: { id: string, status: string }) => auction.id);
return completedAuctions;
}
async getRecordsByName(name: string): Promise<any> {
return this.registry.resolveNames([name]);
}
async getAuctionData(auctionId: string): Promise<any> {
return this.registry.getAuctionsByIds([auctionId]);
}
getLrn(appName: string): string {
assert(this.registryConfig.authority, "Authority doesn't exist");
return `lrn://${this.registryConfig.authority}/applications/${appName}`;
+17 -4
View File
@@ -6,7 +6,7 @@ import { Permission } from './entity/ProjectMember';
import { Domain } from './entity/Domain';
import { Project } from './entity/Project';
import { EnvironmentVariable } from './entity/EnvironmentVariable';
import { AddProjectFromTemplateInput } from './types';
import { AddProjectFromTemplateInput, AuctionParams } from './types';
const log = debug('snowball:resolver');
@@ -69,6 +69,13 @@ export const createResolvers = async (service: Service): Promise<any> => {
) => {
return service.getDomainsByProjectId(projectId, filter);
},
getAuctionData: async (
_: any,
{ auctionId }: { auctionId: string },
) => {
return service.getAuctionData(auctionId);
},
},
// TODO: Return error in GQL response
@@ -203,7 +210,9 @@ export const createResolvers = async (service: Service): Promise<any> => {
{
organizationSlug,
data,
}: { organizationSlug: string; data: AddProjectFromTemplateInput },
lrn,
auctionParams
}: { organizationSlug: string; data: AddProjectFromTemplateInput; lrn: string; auctionParams: AuctionParams },
context: any,
) => {
try {
@@ -211,6 +220,8 @@ export const createResolvers = async (service: Service): Promise<any> => {
context.user,
organizationSlug,
data,
lrn,
auctionParams
);
} catch (err) {
log(err);
@@ -223,11 +234,13 @@ export const createResolvers = async (service: Service): Promise<any> => {
{
organizationSlug,
data,
}: { organizationSlug: string; data: DeepPartial<Project> },
lrn,
auctionParams
}: { organizationSlug: string; data: DeepPartial<Project>; lrn: string; auctionParams: AuctionParams },
context: any,
) => {
try {
return await service.addProject(context.user, organizationSlug, data);
return await service.addProject(context.user, organizationSlug, data, lrn, auctionParams);
} catch (err) {
log(err);
throw err;
+63 -2
View File
@@ -22,6 +22,13 @@ enum DeploymentStatus {
Deleting
}
enum AuctionStatus {
completed
reveal
commit
expired
}
enum DomainStatus {
Live
Pending
@@ -65,6 +72,8 @@ type Project {
repository: String!
prodBranch: String!
description: String
deployerLrns: [String]
auctionId: String
template: String
framework: String
webhooks: [String!]
@@ -74,7 +83,7 @@ type Project {
updatedAt: String!
organization: Organization!
icon: String
subDomain: String
baseDomains: [String!]
}
type ProjectMember {
@@ -94,7 +103,9 @@ type Deployment {
commitMessage: String!
url: String
environment: Environment!
deployerLrn: String
isCurrent: Boolean!
baseDomain: String
status: DeploymentStatus!
createdAt: String!
updatedAt: String!
@@ -182,6 +193,48 @@ input FilterDomainsInput {
status: DomainStatus
}
type Fee {
type: String!
quantity: String!
}
type Bid {
auctionId: String!
bidderAddress: String!
status: String!
commitHash: String!
commitTime: String
commitFee: Fee
revealTime: String
revealFee: Fee
bidAmount: Fee
}
type Auction {
id: String!
kind: String!
status: String!
ownerAddress: String!
createTime: String!
commitsEndTime: String!
revealsEndTime: String!
commitFee: Fee!
revealFee: Fee!
minimumBid: Fee
winnerAddresses: [String!]!
winnerBids: [Fee!]
winnerPrice: Fee
maxPrice: Fee
numProviders: Int!
fundsReleased: Boolean!
bids: [Bid!]!
}
input AuctionParams {
maxPrice: String,
numProviders: Int,
}
type Query {
user: User!
organizations: [Organization!]
@@ -192,6 +245,7 @@ type Query {
environmentVariables(projectId: String!): [EnvironmentVariable!]
projectMembers(projectId: String!): [ProjectMember!]
searchProjects(searchText: String!): [Project!]
getAuctionData(auctionId: String!): Auction!
domains(projectId: String!, filter: FilterDomainsInput): [Domain]
}
@@ -215,8 +269,15 @@ type Mutation {
addProjectFromTemplate(
organizationSlug: String!
data: AddProjectFromTemplateInput
lrn: String
auctionParams: AuctionParams
): Project!
addProject(
organizationSlug: String!
data: AddProjectInput!
lrn: String
auctionParams: AuctionParams
): Project!
addProject(organizationSlug: String!, data: AddProjectInput): Project!
updateProject(projectId: String!, data: UpdateProjectInput): Boolean!
redeployToProd(deploymentId: String!): Boolean!
deleteProject(projectId: String!): Boolean!
+341 -140
View File
@@ -1,12 +1,12 @@
import assert from 'assert';
import debug from 'debug';
import { DeepPartial, FindOptionsWhere } from 'typeorm';
import { DeepPartial, FindOptionsWhere, IsNull, Not } from 'typeorm';
import { Octokit, RequestError } from 'octokit';
import { OAuthApp } from '@octokit/oauth-app';
import { Database } from './database';
import { Deployment, DeploymentStatus, Environment } from './entity/Deployment';
import { ApplicationRecord, Deployment, DeploymentStatus, Environment } from './entity/Deployment';
import { Domain } from './entity/Domain';
import { EnvironmentVariable } from './entity/EnvironmentVariable';
import { Organization } from './entity/Organization';
@@ -19,10 +19,11 @@ import {
AddProjectFromTemplateInput,
AppDeploymentRecord,
AppDeploymentRemovalRecord,
AuctionParams,
GitPushEventPayload,
PackageJSON,
} from './types';
import { Role } from './entity/UserOrganization';
import { getRepoDetails } from './utils';
const log = debug('snowball:service');
@@ -39,15 +40,16 @@ interface Config {
export class Service {
private db: Database;
private oauthApp: OAuthApp;
private registry: Registry;
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.registry = registry;
this.laconicRegistry = registry;
this.config = config;
this.init();
}
@@ -60,6 +62,8 @@ export class Service {
this.checkDeployRecordsAndUpdate();
// Start check for ApplicationDeploymentRemovalRecords asynchronously
this.checkDeploymentRemovalRecordsAndUpdate();
// Start check for Deployment Auctions asynchronously
this.checkAuctionStatus();
}
/**
@@ -67,6 +71,7 @@ export class Service {
*/
destroy(): void {
clearTimeout(this.deployRecordCheckTimeout);
clearTimeout(this.auctionStatusCheckTimeout);
}
/**
@@ -108,7 +113,7 @@ export class Service {
}
// Fetch ApplicationDeploymentRecord for deployments
const records = await this.registry.getDeploymentRecords(deployments);
const records = await this.laconicRegistry.getDeploymentRecords(deployments);
log(`Found ${records.length} ApplicationDeploymentRecords`);
// Update deployments for which ApplicationDeploymentRecords were returned
@@ -141,7 +146,7 @@ export class Service {
// Fetch ApplicationDeploymentRemovalRecords for deployments
const records =
await this.registry.getDeploymentRemovalRecords(deployments);
await this.laconicRegistry.getDeploymentRemovalRecords(deployments);
log(`Found ${records.length} ApplicationDeploymentRemovalRecords`);
// Update deployments for which ApplicationDeploymentRemovalRecords were returned
@@ -157,41 +162,24 @@ export class Service {
/**
* Update deployments with ApplicationDeploymentRecord data
* Deployments that are completed but not updated in DB
*/
async updateDeploymentsWithRecordData(
records: AppDeploymentRecord[],
): Promise<void> {
// Get deployments for ApplicationDeploymentRecords
// get and update deployments to be updated using request id
const deployments = await this.db.getDeployments({
where: records.map((record) => ({
applicationRecordId: record.attributes.application,
applicationDeploymentRequestId: record.attributes.request,
})),
order: {
createdAt: 'DESC',
},
});
// Get project IDs of deployments that are in production environment
const productionDeploymentProjectIds = deployments.reduce(
(acc, deployment): Set<string> => {
if (deployment.environment === Environment.Production) {
acc.add(deployment.projectId);
}
return acc;
},
new Set<string>(),
);
// Set old deployments isCurrent to false
await this.db.updateDeploymentsByProjectIds(
Array.from(productionDeploymentProjectIds),
{ isCurrent: false },
);
const recordToDeploymentsMap = deployments.reduce(
(acc: { [key: string]: Deployment }, deployment) => {
acc[deployment.applicationRecordId] = deployment;
acc[deployment.applicationDeploymentRequestId!] = deployment;
return acc;
},
{},
@@ -199,21 +187,64 @@ export class Service {
// Update deployment data for ApplicationDeploymentRecords
const deploymentUpdatePromises = records.map(async (record) => {
const deployment = recordToDeploymentsMap[record.attributes.application];
const deployment = recordToDeploymentsMap[record.attributes.request];
const project = await this.getProjectById(deployment.projectId)
assert(project)
await this.db.updateDeploymentById(deployment.id, {
applicationDeploymentRecordId: record.id,
applicationDeploymentRecordData: record.attributes,
url: record.attributes.url,
status: DeploymentStatus.Ready,
isCurrent: deployment.environment === Environment.Production,
});
const parts = record.attributes.url.replace('https://', '').split('.');
const baseDomain = parts.slice(1).join('.');
deployment.applicationDeploymentRecordId = record.id;
deployment.applicationDeploymentRecordData = record.attributes;
deployment.url = record.attributes.url;
deployment.baseDomain = baseDomain;
deployment.status = DeploymentStatus.Ready;
deployment.isCurrent = deployment.environment === Environment.Production;
await this.db.updateDeploymentById(deployment.id, deployment);
const baseDomains = project.baseDomains || [];
if (!baseDomains.includes(baseDomain)) {
baseDomains.push(baseDomain);
}
await this.db.updateProjectById(project.id, {
baseDomains
})
log(
`Updated deployment ${deployment.id} with URL ${record.attributes.url}`,
);
});
await Promise.all(deploymentUpdatePromises);
// if iscurrent is true for this deployment then update the old ones
const prodDeployments = Object.values(recordToDeploymentsMap).filter(deployment => deployment.isCurrent);
// Get deployment IDs of deployments that are in production environment
for (const deployment of prodDeployments) {
const projectDeployments = await this.db.getDeploymentsByProjectId(deployment.projectId);
const oldDeployments = projectDeployments
.filter(projectDeployment => projectDeployment.deployerLrn === deployment.deployerLrn && projectDeployment.id !== deployment.id);
for (const oldDeployment of oldDeployments) {
await this.db.updateDeployment(
{ id: oldDeployment.id },
{ isCurrent: false }
);
}
}
// Get old deployments for ApplicationDeploymentRecords
// flter out deps with is current false
// loop over these deps
// get the project
// get all the deployemnts in that proj with the same deployer lrn (query filter not above updated dep)
// set is current to false
await Promise.all(deploymentUpdatePromises);
}
@@ -262,6 +293,58 @@ export class Service {
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 allProjects = await this.db.getProjects({
where: {
auctionId: Not(IsNull()),
},
relations: ['deployments'],
withDeleted: true,
});
// Should only check on the first deployment
const projects = allProjects.filter(project => {
if (project.deletedAt !== null) return false;
return project.deployments.length === 0;
});
const auctionIds = projects.map((project) => project.auctionId);
const completedAuctionIds = await this.laconicRegistry.getCompletedAuctionIds(auctionIds);
if (completedAuctionIds) {
const projectsToBedeployed = projects.filter((project) =>
completedAuctionIds.includes(project.auctionId!)
);
for (const project of projectsToBedeployed) {
const deployerLrns = await this.laconicRegistry.getAuctionWinningDeployers(project!.auctionId!);
if (!deployerLrns) {
log(`No winning deployer for auction ${project!.auctionId}`);
} else {
// Update project with deployer LRNs
await this.db.updateProjectById(project.id!, {
deployerLrns
});
for (const deployer of deployerLrns) {
log(`Creating deployment for deployer LRN ${deployer}`);
await this.createDeploymentFromAuction(project, deployer);
}
}
}
}
this.auctionStatusCheckTimeout = setTimeout(() => {
this.checkAuctionStatus();
}, this.config.registryConfig.checkAuctionStatusDelay);
}
async getUser(userId: string): Promise<User | null> {
return this.db.getUser({
where: {
@@ -519,6 +602,7 @@ export class Service {
domain: prodBranchDomains[0],
commitHash: oldDeployment.commitHash,
commitMessage: oldDeployment.commitMessage,
deployerLrn: oldDeployment.deployerLrn
});
return newDeployment;
@@ -527,45 +611,20 @@ export class Service {
async createDeployment(
userId: string,
octokit: Octokit,
data: DeepPartial<Deployment>,
data: DeepPartial<Deployment>
): Promise<Deployment> {
assert(data.project?.repository, 'Project repository not found');
log(
`Creating deployment in project ${data.project.name} from branch ${data.branch}`,
);
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: PackageJSON = JSON.parse(atob(packageJSONData.content));
assert(packageJSON.name, "name field doesn't exist in package.json");
const repoUrl = (
await octokit.rest.repos.get({
owner,
repo,
})
).data.html_url;
// TODO: Set environment variables for each deployment (environment variables can`t be set in application record)
const { applicationRecordId, applicationRecordData } =
await this.registry.createApplicationRecord({
appName: repo,
packageJSON,
await this.laconicRegistry.createApplicationRecord({
octokit,
repository: data.project.repository,
appType: data.project!.template!,
commitHash: data.commitHash!,
repoUrl,
});
// Update previous deployment with prod branch domain
@@ -581,6 +640,125 @@ export class Service {
);
}
const newDeployment = await this.createDeploymentFromData(userId, data, data.deployerLrn!, applicationRecordId, applicationRecordData);
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) {
// On deleting deployment later, project DNS deployment is also deleted
// So publish project DNS deployment first so that ApplicationDeploymentRecord for the same is available when deleting deployment later
await this.laconicRegistry.createApplicationDeploymentRequest({
deployment: newDeployment,
appName: repo,
repository: repoUrl,
environmentVariables: environmentVariablesObj,
dns: `${newDeployment.project.name}`,
lrn: data.deployerLrn!
});
}
const { applicationDeploymentRequestId, applicationDeploymentRequestData } =
await this.laconicRegistry.createApplicationDeploymentRequest({
deployment: newDeployment,
appName: repo,
repository: repoUrl,
lrn: data.deployerLrn!,
environmentVariables: environmentVariablesObj,
dns: `${newDeployment.project.name}-${newDeployment.id}`,
});
await this.db.updateDeploymentById(newDeployment.id, {
applicationDeploymentRequestId,
applicationDeploymentRequestData,
});
return newDeployment;
}
async createDeploymentFromAuction(
project: DeepPartial<Project>,
deployerLrn: string
): 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;
// 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 newDeployment = await this.createDeploymentFromData(project.ownerId!, deploymentData, deployerLrn, applicationRecordId, applicationRecordData);
const environmentVariablesObj = await this.getEnvVariables(project!.id!);
// To set project DNS
if (deploymentData.environment === Environment.Production) {
// On deleting deployment later, project DNS deployment is also deleted
// So publish project DNS deployment first so that ApplicationDeploymentRecord for the same is available when deleting deployment later
await this.laconicRegistry.createApplicationDeploymentRequest({
deployment: newDeployment,
appName: repo,
repository: repoUrl,
environmentVariables: environmentVariablesObj,
dns: `${newDeployment.project.name}`,
auctionId: project.auctionId!,
lrn: deployerLrn,
});
}
const { applicationDeploymentRequestId, applicationDeploymentRequestData } =
// Create requests for all the deployers
await this.laconicRegistry.createApplicationDeploymentRequest({
deployment: newDeployment,
appName: repo,
repository: repoUrl,
auctionId: project.auctionId!,
lrn: deployerLrn,
environmentVariables: environmentVariablesObj,
dns: `${newDeployment.project.name}-${newDeployment.id}`,
});
await this.db.updateDeploymentById(newDeployment.id, {
applicationDeploymentRequestId,
applicationDeploymentRequestData,
});
return newDeployment;
}
async createDeploymentFromData(
userId: string,
data: DeepPartial<Deployment>,
deployerLrn: string,
applicationRecordId: string,
applicationRecordData: ApplicationRecord,
): Promise<Deployment> {
const newDeployment = await this.db.addDeployment({
project: data.project,
branch: data.branch,
@@ -594,52 +772,10 @@ export class Service {
createdBy: Object.assign(new User(), {
id: userId,
}),
deployerLrn,
});
log(
`Created deployment ${newDeployment.id} and published application record ${applicationRecordId}`,
);
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 },
);
// To set project DNS
if (data.environment === Environment.Production) {
// On deleting deployment later, project DNS deployment is also deleted
// So publish project DNS deployment first so that ApplicationDeploymentRecord for the same is available when deleting deployment later
await this.registry.createApplicationDeploymentRequest({
deployment: newDeployment,
appName: repo,
repository: repoUrl,
environmentVariables: environmentVariablesObj,
dns: `${newDeployment.project.name}`,
});
}
const { applicationDeploymentRequestId, applicationDeploymentRequestData } =
await this.registry.createApplicationDeploymentRequest({
deployment: newDeployment,
appName: repo,
repository: repoUrl,
environmentVariables: environmentVariablesObj,
dns: `${newDeployment.project.name}-${newDeployment.id}`,
});
await this.db.updateDeploymentById(newDeployment.id, {
applicationDeploymentRequestId,
applicationDeploymentRequestData,
});
log(`Created deployment ${newDeployment.id}`);
return newDeployment;
}
@@ -648,6 +784,8 @@ export class Service {
user: User,
organizationSlug: string,
data: AddProjectFromTemplateInput,
lrn?: string,
auctionParams?: AuctionParams
): Promise<Project | undefined> {
try {
const octokit = await this.getOctokit(user.id);
@@ -678,7 +816,7 @@ export class Service {
repository: gitRepo.data.full_name,
// TODO: Set selected template
template: 'webapp',
});
}, lrn, auctionParams);
if (!project || !project.id) {
throw new Error('Failed to create project from template');
@@ -695,6 +833,8 @@ export class Service {
user: User,
organizationSlug: string,
data: DeepPartial<Project>,
lrn?: string,
auctionParams?: AuctionParams
): Promise<Project | undefined> {
const organization = await this.db.getOrganization({
where: {
@@ -706,6 +846,7 @@ export class Service {
}
const project = await this.db.addProject(user, organization.id, data);
log(`Project created ${project.id}`);
const octokit = await this.getOctokit(user.id);
const [owner, repo] = project.repository.split('/');
@@ -720,19 +861,26 @@ export class Service {
});
// Create deployment with prod branch and latest commit
const deployment = await this.createDeployment(user.id, octokit, {
const deploymentData = {
project,
branch: project.prodBranch,
environment: Environment.Production,
domain: null,
commitHash: latestCommit.sha,
commitMessage: latestCommit.commit.message,
});
deployerLrn: lrn
};
if (auctionParams) {
const { applicationDeploymentAuctionId } = await this.laconicRegistry.createApplicationDeploymentAuction(repo, octokit, auctionParams!, deploymentData);
await this.updateProject(project.id, { auctionId: applicationDeploymentAuctionId })
} else {
await this.createDeployment(user.id, octokit, deploymentData);
await this.updateProject(project.id, { deployerLrns: [lrn!] })
}
await this.createRepoHook(octokit, project);
console.log('projectid is', project.id);
return project;
}
@@ -798,18 +946,29 @@ export class Service {
branch,
});
// 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,
domain,
commitHash: headCommit.id,
commitMessage: headCommit.message,
});
const deployers = project.deployerLrns;
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,
domain,
commitHash: headCommit.id,
commitMessage: headCommit.message,
deployerLrn: deployer
},
);
}
}
}
@@ -859,15 +1018,25 @@ export class Service {
const octokit = await this.getOctokit(user.id);
const newDeployment = await this.createDeployment(user.id, octokit, {
project: oldDeployment.project,
// TODO: Put isCurrent field in project
branch: oldDeployment.branch,
environment: Environment.Production,
domain: oldDeployment.domain,
commitHash: oldDeployment.commitHash,
commitMessage: oldDeployment.commitMessage,
});
let newDeployment: Deployment;
if (oldDeployment.project.auctionId) {
// TODO: Discuss creating applicationRecord for redeployments
newDeployment = await this.createDeploymentFromAuction(oldDeployment.project, oldDeployment.deployerLrn);
} else {
newDeployment = await this.createDeployment(user.id, octokit,
{
project: oldDeployment.project,
// TODO: Put isCurrent field in project
branch: oldDeployment.branch,
environment: Environment.Production,
domain: oldDeployment.domain,
commitHash: oldDeployment.commitHash,
commitMessage: oldDeployment.commitMessage,
deployerLrn: oldDeployment.deployerLrn
}
);
}
return newDeployment;
}
@@ -919,10 +1088,11 @@ export class Service {
if (deployment && deployment.applicationDeploymentRecordId) {
// If deployment is current, remove deployment for project subdomain as well
if (deployment.isCurrent) {
const currentDeploymentURL = `https://${deployment.project.subDomain}`;
const currentDeploymentURL = `https://${(deployment.project.name).toLowerCase()}.${deployment.baseDomain}`;
// TODO: Store the latest DNS deployment record
const deploymentRecords =
await this.registry.getDeploymentRecordsByFilter({
await this.laconicRegistry.getDeploymentRecordsByFilter({
application: deployment.applicationRecordId,
url: currentDeploymentURL,
});
@@ -935,14 +1105,20 @@ export class Service {
return false;
}
await this.registry.createApplicationDeploymentRemovalRequest({
deploymentId: deploymentRecords[0].id,
// 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.deployerLrn
});
}
const result =
await this.registry.createApplicationDeploymentRemovalRequest({
await this.laconicRegistry.createApplicationDeploymentRemovalRequest({
deploymentId: deployment.applicationDeploymentRecordId,
deployerLrn: deployment.deployerLrn
});
await this.db.updateDeploymentById(deployment.id, {
@@ -1077,4 +1253,29 @@ export class Service {
): 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];
}
}
+7
View File
@@ -29,6 +29,8 @@ export interface GitPushEventPayload {
export interface AppDeploymentRecordAttributes {
application: string;
auction: string;
deployer: string;
dns: string;
meta: string;
name: string;
@@ -69,3 +71,8 @@ export interface AddProjectFromTemplateInput {
name: string;
isPrivate: boolean;
}
export interface AuctionParams {
maxPrice: string,
numProviders: number,
}
+43 -1
View File
@@ -1,12 +1,14 @@
import assert from 'assert';
import debug from 'debug';
import fs from 'fs-extra';
import { Octokit } from 'octokit';
import path from 'path';
import toml from 'toml';
import debug from 'debug';
import { DataSource, DeepPartial, EntityTarget, ObjectLiteral } from 'typeorm';
import { Config } from './config';
import { DEFAULT_CONFIG_FILE_PATH } from './constants';
import { PackageJSON } from './types';
const log = debug('snowball:utils');
@@ -78,3 +80,43 @@ export const loadAndSaveData = async <Entity extends ObjectLiteral>(
export const sleep = async (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms));
export const getRepoDetails = async (
octokit: Octokit,
repository: string,
commitHash: string | undefined,
): Promise<{
repo: string;
packageJSON: PackageJSON;
repoUrl: string;
}> => {
const [owner, repo] = repository.split('/');
const { data: packageJSONData } = await octokit.rest.repos.getContent({
owner,
repo,
path: 'package.json',
ref: commitHash,
});
if (!packageJSONData) {
throw new Error('Package.json file not found');
}
assert(!Array.isArray(packageJSONData) && packageJSONData.type === 'file');
const packageJSON: PackageJSON = JSON.parse(atob(packageJSONData.content));
assert(packageJSON.name, "name field doesn't exist in package.json");
const repoUrl = (
await octokit.rest.repos.get({
owner,
repo,
})
).data.html_url;
return {
repo,
packageJSON,
repoUrl
};
}
@@ -73,7 +73,7 @@ async function main() {
// Remove deployment for project subdomain if deployment is for production environment
if (deployment.environment === Environment.Production) {
applicationDeploymentRecord.url = `https://${deployment.project.subDomain}`
applicationDeploymentRecord.url = `https://${deployment.project.name}.${deployment.baseDomain}`;
await registry.setRecord(
{