Merge branch 'ng-check-deployment-removal-record'

This commit is contained in:
Vivian Phung 2024-05-22 10:41:37 -04:00
commit 6dfe85cb1a
No known key found for this signature in database
21 changed files with 462 additions and 51 deletions

View File

@ -99,23 +99,11 @@ Let us assume the following domains for backend and frontend
- Get the private key and set `registryConfig.privateKey` in backend [config file](packages/backend/environments/local.toml) - Get the private key and set `registryConfig.privateKey` in backend [config file](packages/backend/environments/local.toml)
```bash ```bash
laconic-so --stack fixturenet-laconic-loaded deploy exec laconicd "laconicd keys export mykey --unarmored-hex --unsafe" laconic-so deployment --dir laconic-loaded-deployment exec laconicd "laconicd keys export mykey --unarmored-hex --unsafe"
# WARNING: The private key will be exported as an unarmored hexadecimal string. USE AT YOUR OWN RISK. Continue? [y/N]: y # WARNING: The private key will be exported as an unarmored hexadecimal string. USE AT YOUR OWN RISK. Continue? [y/N]: y
# 754cca7b4b729a99d156913aea95366411d072856666e95ba09ef6c664357d81 # 754cca7b4b729a99d156913aea95366411d072856666e95ba09ef6c664357d81
``` ```
- Get the REST and GQL endpoint ports of Laconicd and replace the ports for `registryConfig.restEndpoint` and `registryConfig.gqlEndpoint` in backend [config file](packages/backend/environments/local.toml)
```bash
# For registryConfig.restEndpoint
laconic-so --stack fixturenet-laconic-loaded deploy port laconicd 1317
# 0.0.0.0:32777
# For registryConfig.gqlEndpoint
laconic-so --stack fixturenet-laconic-loaded deploy port laconicd 9473
# 0.0.0.0:32771
```
- Set authority in `registryConfig.authority` in backend [config file](packages/backend/environments/local.toml) - Set authority in `registryConfig.authority` in backend [config file](packages/backend/environments/local.toml)
- Run the script to create bond, reserve the authority and set authority bond - Run the script to create bond, reserve the authority and set authority bond
@ -157,14 +145,14 @@ Let us assume the following domains for backend and frontend
- Copy the GitHub OAuth app client ID from previous steps and set it in frontend [.env](packages/frontend/.env) file - Copy the GitHub OAuth app client ID from previous steps and set it in frontend [.env](packages/frontend/.env) file
```env ```env
REACT_APP_GITHUB_CLIENT_ID = <CLIENT_ID> VITE_GITHUB_CLIENT_ID = <CLIENT_ID>
``` ```
- Set `REACT_APP_GITHUB_PWA_TEMPLATE_REPO` and `REACT_APP_GITHUB_IMAGE_UPLOAD_PWA_TEMPLATE_REPO` in [.env](packages/frontend/.env) file - Set `VITE_GITHUB_PWA_TEMPLATE_REPO` and `VITE_GITHUB_IMAGE_UPLOAD_PWA_TEMPLATE_REPO` in [.env](packages/frontend/.env) file
```env ```env
REACT_APP_GITHUB_PWA_TEMPLATE_REPO = 'cerc-io/test-progressive-web-app' # Set actual owner/name of the template repo that will be used for creating new repo VITE_GITHUB_PWA_TEMPLATE_REPO = 'cerc-io/test-progressive-web-app' # Set actual owner/name of the template repo that will be used for creating new repo
REACT_APP_GITHUB_IMAGE_UPLOAD_PWA_TEMPLATE_REPO = 'cerc-io/image-upload-pwa-example' # Set actual owner/name of the template repo that will be used for creating new repo VITE_GITHUB_IMAGE_UPLOAD_PWA_TEMPLATE_REPO = 'cerc-io/image-upload-pwa-example' # Set actual owner/name of the template repo that will be used for creating new repo
``` ```
- Production - Production
@ -172,17 +160,17 @@ Let us assume the following domains for backend and frontend
- Set the following values in [.env](packages/frontend/.env) file - Set the following values in [.env](packages/frontend/.env) file
```env ```env
REACT_APP_SERVER_URL = 'https://api.snowballtools.com' # Backend server endpoint VITE_SERVER_URL = 'https://api.snowballtools.com' # Backend server endpoint
``` ```
- Sign in to [wallet connect](https://cloud.walletconnect.com/sign-in) to create a project ID - Sign in to [wallet connect](https://cloud.walletconnect.com/sign-in) to create a project ID
- Create a project and add information to use wallet connect SDK - Create a project and add information to use wallet connect SDK
- Add project name and select project type as `App` - Add project name and select project type as `App`
- Set project home page URL to `https://dashboard.snowballtools.com` - Set project home page URL to `https://dashboard.snowballtools.com`
- On creation of project, use the `Project ID` and set it in `REACT_APP_WALLET_CONNECT_ID` in [.env](packages/frontend/.env) file - On creation of project, use the `Project ID` and set it in `VITE_WALLET_CONNECT_ID` in [.env](packages/frontend/.env) file
```env ```env
REACT_APP_WALLET_CONNECT_ID = <PROJECT_ID> VITE_WALLET_CONNECT_ID = <PROJECT_ID>
``` ```
- Build the React application - Build the React application
@ -202,17 +190,17 @@ Let us assume the following domains for backend and frontend
- Copy the graphQL endpoint from terminal and add the endpoint in the [.env](packages/frontend/.env) file present in `packages/frontend` - Copy the graphQL endpoint from terminal and add the endpoint in the [.env](packages/frontend/.env) file present in `packages/frontend`
```env ```env
REACT_APP_SERVER_URL = 'http://localhost:8000' VITE_SERVER_URL = 'http://localhost:8000'
``` ```
- Sign in to [wallet connect](https://cloud.walletconnect.com/sign-in) to create a project ID. - Sign in to [wallet connect](https://cloud.walletconnect.com/sign-in) to create a project ID.
- Create a project and add information to use wallet connect SDK - Create a project and add information to use wallet connect SDK
- Add project name and select project type as `App` - Add project name and select project type as `App`
- Project home page URL is not required to be set - Project home page URL is not required to be set
- On creation of project, use the `Project ID` and set it in `REACT_APP_WALLET_CONNECT_ID` in [.env](packages/frontend/.env) file - On creation of project, use the `Project ID` and set it in `VITE_WALLET_CONNECT_ID` in [.env](packages/frontend/.env) file
```env ```env
REACT_APP_WALLET_CONNECT_ID = <Project_ID> VITE_WALLET_CONNECT_ID = <Project_ID>
``` ```
- The React application will be running in `http://localhost:3000/` - The React application will be running in `http://localhost:3000/`

View File

@ -43,6 +43,7 @@
"lint": "tsc --noEmit", "lint": "tsc --noEmit",
"test:registry:init": "DEBUG=snowball:* ts-node ./test/initialize-registry.ts", "test:registry:init": "DEBUG=snowball:* ts-node ./test/initialize-registry.ts",
"test:registry:publish-deploy-records": "DEBUG=snowball:* ts-node ./test/publish-deploy-records.ts", "test:registry:publish-deploy-records": "DEBUG=snowball:* ts-node ./test/publish-deploy-records.ts",
"test:registry:publish-deployment-removal-records": "DEBUG=snowball:* ts-node ./test/publish-deployment-removal-records.ts",
"test:db:load:fixtures": "DEBUG=snowball:* ts-node ./test/initialize-db.ts", "test:db:load:fixtures": "DEBUG=snowball:* ts-node ./test/initialize-db.ts",
"test:db:delete": "DEBUG=snowball:* ts-node ./test/delete-db.ts" "test:db:delete": "DEBUG=snowball:* ts-node ./test/delete-db.ts"
}, },

View File

@ -436,6 +436,19 @@ export class Database {
return Boolean(updateResult.affected); return Boolean(updateResult.affected);
} }
async deleteDeploymentById (deploymentId: string): Promise<boolean> {
const deploymentRepository = this.dataSource.getRepository(Deployment);
const deployment = await deploymentRepository.findOneOrFail({
where: {
id: deploymentId
}
});
const deleteResult = await deploymentRepository.softRemove(deployment);
return Boolean(deleteResult);
}
async addProject (user: User, organizationId: string, data: DeepPartial<Project>): Promise<Project> { async addProject (user: User, organizationId: string, data: DeepPartial<Project>): Promise<Project> {
const projectRepository = this.dataSource.getRepository(Project); const projectRepository = this.dataSource.getRepository(Project);

View File

@ -6,13 +6,14 @@ import {
UpdateDateColumn, UpdateDateColumn,
ManyToOne, ManyToOne,
OneToOne, OneToOne,
JoinColumn JoinColumn,
DeleteDateColumn
} from 'typeorm'; } from 'typeorm';
import { Project } from './Project'; import { Project } from './Project';
import { Domain } from './Domain'; import { Domain } from './Domain';
import { User } from './User'; import { User } from './User';
import { AppDeploymentRecordAttributes } from '../types'; import { AppDeploymentRecordAttributes, AppDeploymentRemovalRecordAttributes } from '../types';
export enum Environment { export enum Environment {
Production = 'Production', Production = 'Production',
@ -24,6 +25,7 @@ export enum DeploymentStatus {
Building = 'Building', Building = 'Building',
Ready = 'Ready', Ready = 'Ready',
Error = 'Error', Error = 'Error',
Deleting = 'Deleting',
} }
export interface ApplicationDeploymentRequest { export interface ApplicationDeploymentRequest {
@ -35,6 +37,18 @@ export interface ApplicationDeploymentRequest {
meta: string; meta: string;
} }
export interface ApplicationDeploymentRemovalRequest {
type: string;
version: string;
deployment: string;
}
export interface ApplicationDeploymentRemovalRequest {
type: string;
version: string;
deployment: string;
}
export interface ApplicationRecord { export interface ApplicationRecord {
type: string; type: string;
version: string; version: string;
@ -99,6 +113,18 @@ export class Deployment {
@Column('simple-json', { nullable: true }) @Column('simple-json', { nullable: true })
applicationDeploymentRecordData!: AppDeploymentRecordAttributes | null; 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({ @Column({
enum: Environment enum: Environment
}) })
@ -121,4 +147,7 @@ export class Deployment {
@UpdateDateColumn() @UpdateDateColumn()
updatedAt!: Date; updatedAt!: Date;
@DeleteDateColumn()
deletedAt!: Date | null;
} }

View File

@ -9,16 +9,19 @@ import { RegistryConfig } from './config';
import { import {
ApplicationRecord, ApplicationRecord,
Deployment, Deployment,
ApplicationDeploymentRequest ApplicationDeploymentRequest,
ApplicationDeploymentRemovalRequest
} from './entity/Deployment'; } from './entity/Deployment';
import { AppDeploymentRecord, PackageJSON } from './types'; import { AppDeploymentRecord, AppDeploymentRemovalRecord, PackageJSON } from './types';
import { sleep } from './utils'; import { sleep } from './utils';
const log = debug('snowball:registry'); const log = debug('snowball:registry');
const APP_RECORD_TYPE = 'ApplicationRecord'; const APP_RECORD_TYPE = 'ApplicationRecord';
const APP_DEPLOYMENT_REQUEST_TYPE = 'ApplicationDeploymentRequest'; const APP_DEPLOYMENT_REQUEST_TYPE = 'ApplicationDeploymentRequest';
const APP_DEPLOYMENT_REMOVAL_REQUEST_TYPE = 'ApplicationDeploymentRemovalRequest';
const APP_DEPLOYMENT_RECORD_TYPE = 'ApplicationDeploymentRecord'; const APP_DEPLOYMENT_RECORD_TYPE = 'ApplicationDeploymentRecord';
const APP_DEPLOYMENT_REMOVAL_RECORD_TYPE = 'ApplicationDeploymentRemovalRecord';
const SLEEP_DURATION = 1000; const SLEEP_DURATION = 1000;
// TODO: Move registry code to laconic-sdk/watcher-ts // TODO: Move registry code to laconic-sdk/watcher-ts
@ -229,6 +232,74 @@ export class Registry {
); );
} }
/**
* Fetch ApplicationDeploymentRecords by filter
*/
async getDeploymentRecordsByFilter (filter: { [key: string]: any }): Promise<AppDeploymentRecord[]> {
return this.registry.queryRecords(
{
type: APP_DEPLOYMENT_RECORD_TYPE,
...filter
},
true
);
}
/**
* Fetch ApplicationDeploymentRemovalRecords for deployments
*/
async getDeploymentRemovalRecords (
deployments: Deployment[]
): Promise<AppDeploymentRemovalRecord[]> {
// Fetch ApplicationDeploymentRemovalRecords for corresponding ApplicationDeploymentRecord set in deployments
const records = await this.registry.queryRecords(
{
type: APP_DEPLOYMENT_REMOVAL_RECORD_TYPE
},
true
);
// Filter records with ApplicationDeploymentRecord and ApplicationDeploymentRemovalRequest IDs
return records.filter((record: AppDeploymentRemovalRecord) =>
deployments.some(
(deployment) =>
deployment.applicationDeploymentRemovalRequestId === record.attributes.request &&
deployment.applicationDeploymentRecordId === record.attributes.deployment
)
);
}
async createApplicationDeploymentRemovalRequest (data: {
deploymentId: string;
}): Promise<{
applicationDeploymentRemovalRequestId: string;
applicationDeploymentRemovalRequestData: ApplicationDeploymentRemovalRequest;
}> {
const applicationDeploymentRemovalRequest = {
type: APP_DEPLOYMENT_REMOVAL_REQUEST_TYPE,
version: '1.0.0',
deployment: data.deploymentId
};
const result = await this.registry.setRecord(
{
privateKey: this.registryConfig.privateKey,
record: applicationDeploymentRemovalRequest,
bondId: this.registryConfig.bondId
},
'',
this.registryConfig.fee
);
log(`Application deployment removal request record published: ${result.data.id}`);
log('Application deployment removal request data:', applicationDeploymentRemovalRequest);
return {
applicationDeploymentRemovalRequestId: result.data.id,
applicationDeploymentRemovalRequestData: applicationDeploymentRemovalRequest
};
}
getCrn (appName: string): string { getCrn (appName: string): string {
assert(this.registryConfig.authority, "Authority doesn't exist"); assert(this.registryConfig.authority, "Authority doesn't exist");
return `crn://${this.registryConfig.authority}/applications/${appName}`; return `crn://${this.registryConfig.authority}/applications/${appName}`;

View File

@ -255,6 +255,20 @@ export const createResolvers = async (service: Service): Promise<any> => {
} }
}, },
deleteDeployment: async (
_: any,
{
deploymentId
}: { deploymentId: string; }
) => {
try {
return await service.deleteDeployment(deploymentId);
} catch (err) {
log(err);
return false;
}
},
addDomain: async ( addDomain: async (
_: any, _: any,
{ projectId, data }: { projectId: string; data: { name: string } } { projectId, data }: { projectId: string; data: { name: string } }

View File

@ -19,6 +19,7 @@ enum DeploymentStatus {
Building Building
Ready Ready
Error Error
Deleting
} }
enum DomainStatus { enum DomainStatus {
@ -209,6 +210,7 @@ type Mutation {
deleteProject(projectId: String!): Boolean! deleteProject(projectId: String!): Boolean!
deleteDomain(domainId: String!): Boolean! deleteDomain(domainId: String!): Boolean!
rollbackDeployment(projectId: String!, deploymentId: String!): Boolean! rollbackDeployment(projectId: String!, deploymentId: String!): Boolean!
deleteDeployment(deploymentId: String!): Boolean!
addDomain(projectId: String!, data: AddDomainInput!): Boolean! addDomain(projectId: String!, data: AddDomainInput!): Boolean!
updateDomain(domainId: String!, data: UpdateDomainInput!): Boolean! updateDomain(domainId: String!, data: UpdateDomainInput!): Boolean!
authenticateGitHub(code: String!): AuthResult! authenticateGitHub(code: String!): AuthResult!

View File

@ -15,13 +15,16 @@ import { Permission, ProjectMember } from './entity/ProjectMember';
import { User } from './entity/User'; import { User } from './entity/User';
import { Registry } from './registry'; import { Registry } from './registry';
import { GitHubConfig, RegistryConfig } from './config'; import { GitHubConfig, RegistryConfig } from './config';
import { AppDeploymentRecord, GitPushEventPayload, PackageJSON } from './types'; import { AppDeploymentRecord, AppDeploymentRemovalRecord, GitPushEventPayload, PackageJSON } from './types';
import { Role } from './entity/UserOrganization'; import { Role } from './entity/UserOrganization';
const log = debug('snowball:service'); const log = debug('snowball:service');
const GITHUB_UNIQUE_WEBHOOK_ERROR = 'Hook already exists on this repository'; 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 { interface Config {
gitHubConfig: GitHubConfig; gitHubConfig: GitHubConfig;
registryConfig: RegistryConfig; registryConfig: RegistryConfig;
@ -49,6 +52,8 @@ export class Service {
init (): void { init (): void {
// Start check for ApplicationDeploymentRecords asynchronously // Start check for ApplicationDeploymentRecords asynchronously
this.checkDeployRecordsAndUpdate(); this.checkDeployRecordsAndUpdate();
// Start check for ApplicationDeploymentRemovalRecords asynchronously
this.checkDeploymentRemovalRecordsAndUpdate();
} }
/** /**
@ -60,14 +65,13 @@ export class Service {
/** /**
* Checks for ApplicationDeploymentRecord and update corresponding deployments * Checks for ApplicationDeploymentRecord and update corresponding deployments
* Continues check in loop after a delay of DEPLOY_RECORD_CHECK_DELAY_MS * Continues check in loop after a delay of registryConfig.fetchDeploymentRecordDelay
*/ */
async checkDeployRecordsAndUpdate (): Promise<void> { async checkDeployRecordsAndUpdate (): Promise<void> {
// Fetch deployments in building state // Fetch deployments in building state
const deployments = await this.db.getDeployments({ const deployments = await this.db.getDeployments({
where: { where: {
status: DeploymentStatus.Building status: DeploymentStatus.Building
// TODO: Fetch and check records for recent deployments
} }
}); });
@ -76,6 +80,28 @@ export class Service {
`Found ${deployments.length} deployments in ${DeploymentStatus.Building} state` `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 // Fetch ApplicationDeploymentRecord for deployments
const records = await this.registry.getDeploymentRecords(deployments); const records = await this.registry.getDeploymentRecords(deployments);
log(`Found ${records.length} ApplicationDeploymentRecords`); log(`Found ${records.length} ApplicationDeploymentRecords`);
@ -91,6 +117,38 @@ export class Service {
}, this.config.registryConfig.fetchDeploymentRecordDelay); }, 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.registry.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 * Update deployments with ApplicationDeploymentRecord data
*/ */
@ -153,6 +211,45 @@ export class Service {
await Promise.all(deploymentUpdatePromises); await Promise.all(deploymentUpdatePromises);
} }
/**
* 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) => {
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);
}
async getUser (userId: string): Promise<User | null> { async getUser (userId: string): Promise<User | null> {
return this.db.getUser({ return this.db.getUser({
where: { where: {
@ -479,17 +576,10 @@ export class Service {
return acc; return acc;
}, {} as { [key: string]: string }); }, {} as { [key: string]: string });
const { applicationDeploymentRequestId, applicationDeploymentRequestData } = await this.registry.createApplicationDeploymentRequest(
{
deployment: newDeployment,
appName: repo,
repository: repoUrl,
environmentVariables: environmentVariablesObj,
dns: `${newDeployment.project.name}-${newDeployment.id}`
});
// To set project DNS // To set project DNS
if (data.environment === Environment.Production) { 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( await this.registry.createApplicationDeploymentRequest(
{ {
deployment: newDeployment, deployment: newDeployment,
@ -500,6 +590,15 @@ export class Service {
}); });
} }
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 }); await this.db.updateDeploymentById(newDeployment.id, { applicationDeploymentRequestId, applicationDeploymentRequestData });
return newDeployment; return newDeployment;
@ -717,6 +816,51 @@ export class Service {
return newCurrentDeploymentUpdate && oldCurrentDeploymentUpdate; return newCurrentDeploymentUpdate && oldCurrentDeploymentUpdate;
} }
async deleteDeployment (deploymentId: string): Promise<boolean> {
const deployment = await this.db.getDeployment({
where: {
id: deploymentId
},
relations: {
project: true
}
});
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 deploymentRecords = await this.registry.getDeploymentRecordsByFilter({
application: deployment.applicationRecordId,
url: currentDeploymentURL
})
if (!deploymentRecords.length) {
log(`No ApplicationDeploymentRecord found for URL ${currentDeploymentURL} and ApplicationDeploymentRecord id ${deployment.applicationDeploymentRecordId}`);
return false;
}
await this.registry.createApplicationDeploymentRemovalRequest({ deploymentId: deploymentRecords[0].id });
}
const result = await this.registry.createApplicationDeploymentRemovalRequest({ deploymentId: deployment.applicationDeploymentRecordId });
await this.db.updateDeploymentById(
deployment.id,
{
status: DeploymentStatus.Deleting,
applicationDeploymentRemovalRequestId: result.applicationDeploymentRemovalRequestId,
applicationDeploymentRemovalRequestData: result.applicationDeploymentRemovalRequestData
}
);
return (result !== undefined || result !== null);
}
return false;
}
async addDomain ( async addDomain (
projectId: string, projectId: string,
data: { name: string } data: { name: string }

View File

@ -38,6 +38,13 @@ export interface AppDeploymentRecordAttributes {
version: string; version: string;
} }
export interface AppDeploymentRemovalRecordAttributes {
deployment: string;
request: string;
type: "ApplicationDeploymentRemovalRecord";
version: string;
}
interface RegistryRecord { interface RegistryRecord {
id: string; id: string;
names: string[] | null; names: string[] | null;
@ -50,3 +57,7 @@ interface RegistryRecord {
export interface AppDeploymentRecord extends RegistryRecord { export interface AppDeploymentRecord extends RegistryRecord {
attributes: AppDeploymentRecordAttributes; attributes: AppDeploymentRecordAttributes;
} }
export interface AppDeploymentRemovalRecord extends RegistryRecord {
attributes: AppDeploymentRemovalRecordAttributes;
}

View File

@ -7,7 +7,7 @@ import { Registry } from '@snowballtools/laconic-sdk';
import { Config } from '../src/config'; import { Config } from '../src/config';
import { DEFAULT_CONFIG_FILE_PATH } from '../src/constants'; import { DEFAULT_CONFIG_FILE_PATH } from '../src/constants';
import { getConfig } from '../src/utils'; import { getConfig } from '../src/utils';
import { Deployment, DeploymentStatus } from '../src/entity/Deployment'; import { Deployment, DeploymentStatus, Environment } from '../src/entity/Deployment';
const log = debug('snowball:publish-deploy-records'); const log = debug('snowball:publish-deploy-records');
@ -40,7 +40,7 @@ async function main () {
}); });
for await (const deployment of deployments) { for await (const deployment of deployments) {
const url = `${deployment.project.name}-${deployment.id}.${misc.projectDomain}`; const url = `https://${deployment.project.name}-${deployment.id}.${misc.projectDomain}`;
const applicationDeploymentRecord = { const applicationDeploymentRecord = {
type: 'ApplicationDeploymentRecord', type: 'ApplicationDeploymentRecord',
@ -71,6 +71,21 @@ async function main () {
registryConfig.fee registryConfig.fee
); );
// Remove deployment for project subdomain if deployment is for production environment
if (deployment.environment === Environment.Production) {
applicationDeploymentRecord.url = `https://${deployment.project.subDomain}`
await registry.setRecord(
{
privateKey: registryConfig.privateKey,
record: applicationDeploymentRecord,
bondId: registryConfig.bondId
},
'',
registryConfig.fee
);
}
log('Application deployment record data:', applicationDeploymentRecord); log('Application deployment record data:', applicationDeploymentRecord);
log(`Application deployment record published: ${result.data.id}`); log(`Application deployment record published: ${result.data.id}`);
} }

View File

@ -0,0 +1,67 @@
import debug from 'debug';
import { DataSource } from 'typeorm';
import path from 'path';
import { Registry } from '@cerc-io/laconic-sdk';
import { Config } from '../src/config';
import { DEFAULT_CONFIG_FILE_PATH } from '../src/constants';
import { getConfig } from '../src/utils';
import { Deployment, DeploymentStatus } from '../src/entity/Deployment';
const log = debug('snowball:publish-deployment-removal-records');
async function main () {
const { registryConfig, database, misc } = await getConfig<Config>(DEFAULT_CONFIG_FILE_PATH);
const registry = new Registry(
registryConfig.gqlEndpoint,
registryConfig.restEndpoint,
registryConfig.chainId
);
const dataSource = new DataSource({
type: 'better-sqlite3',
database: database.dbPath,
synchronize: true,
entities: [path.join(__dirname, '../src/entity/*')]
});
await dataSource.initialize();
const deploymentRepository = dataSource.getRepository(Deployment);
const deployments = await deploymentRepository.find({
relations: {
project: true
},
where: {
status: DeploymentStatus.Deleting
}
});
for await (const deployment of deployments) {
const applicationDeploymentRemovalRecord = {
type: "ApplicationDeploymentRemovalRecord",
version: "1.0.0",
deployment: deployment.applicationDeploymentRecordId,
request: deployment.applicationDeploymentRemovalRequestId,
}
const result = await registry.setRecord(
{
privateKey: registryConfig.privateKey,
record: applicationDeploymentRemovalRecord,
bondId: registryConfig.bondId
},
'',
registryConfig.fee
);
log('Application deployment removal record data:', applicationDeploymentRemovalRecord);
log(`Application deployment removal record published: ${result.data.id}`);
}
}
main().catch((err) => {
log(err);
});

View File

@ -53,6 +53,16 @@ export const ProjectCard = ({
navigate(`projects/${project.id}`); navigate(`projects/${project.id}`);
}, [project.id, navigate]); }, [project.id, navigate]);
const navigateToSettingsOnClick = useCallback(
(
e: React.MouseEvent<HTMLLIElement> | React.MouseEvent<HTMLButtonElement>,
) => {
e.stopPropagation();
navigate(`projects/${project.id}/settings`);
},
[project.id, navigate],
);
return ( return (
<div <div
{...props} {...props}
@ -92,8 +102,15 @@ export const ProjectCard = ({
</Button> </Button>
</MenuHandler> </MenuHandler>
<MenuList> <MenuList>
<MenuItem>Project settings</MenuItem> <MenuItem onClick={navigateToSettingsOnClick}>
<MenuItem className="text-red-500">Delete project</MenuItem> Project settings
</MenuItem>
<MenuItem
className="text-red-500"
onClick={navigateToSettingsOnClick}
>
Delete project
</MenuItem>
</MenuList> </MenuList>
</Menu> </Menu>
</div> </div>

View File

@ -155,13 +155,13 @@ export const RepositoryList = () => {
{Boolean(repositoryDetails.length) ? ( {Boolean(repositoryDetails.length) ? (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{repositoryDetails.map((repo, index) => ( {repositoryDetails.map((repo, index) => (
<> <div key={index}>
<ProjectRepoCard repository={repo} key={index} /> <ProjectRepoCard repository={repo} />
{/* Horizontal line */} {/* Horizontal line */}
{index !== repositoryDetails.length - 1 && ( {index !== repositoryDetails.length - 1 && (
<div className="border-b border-border-separator/[0.06] w-full" /> <div className="border-b border-border-separator/[0.06] w-full" />
)} )}
</> </div>
))} ))}
</div> </div>
) : ( ) : (

View File

@ -38,6 +38,7 @@ const STATUS_COLORS: {
[DeploymentStatus.Building]: 'emphasized', [DeploymentStatus.Building]: 'emphasized',
[DeploymentStatus.Ready]: 'positive', [DeploymentStatus.Ready]: 'positive',
[DeploymentStatus.Error]: 'negative', [DeploymentStatus.Error]: 'negative',
[DeploymentStatus.Deleting]: 'neutral',
}; };
const DeploymentDetailsCard = ({ const DeploymentDetailsCard = ({
@ -48,7 +49,7 @@ const DeploymentDetailsCard = ({
prodBranchDomains, prodBranchDomains,
}: DeployDetailsCardProps) => { }: DeployDetailsCardProps) => {
const getIconByDeploymentStatus = (status: DeploymentStatus) => { const getIconByDeploymentStatus = (status: DeploymentStatus) => {
if (status === DeploymentStatus.Building) { if (status === DeploymentStatus.Building || status === DeploymentStatus.Deleting) {
return <LoadingIcon className="animate-spin" />; return <LoadingIcon className="animate-spin" />;
} }
if (status === DeploymentStatus.Ready) { if (status === DeploymentStatus.Ready) {

View File

@ -9,6 +9,7 @@ import {
RefreshIcon, RefreshIcon,
RocketIcon, RocketIcon,
UndoIcon, UndoIcon,
CrossCircleIcon,
} from 'components/shared/CustomIcon'; } from 'components/shared/CustomIcon';
import { import {
Menu, Menu,
@ -79,6 +80,16 @@ export const DeploymentMenu = ({
} }
}; };
const deleteDeployment = async () => {
const isDeleted = await client.deleteDeployment(deployment.id);
if (isDeleted) {
await onUpdate();
toast.success('Deleted deployment');
} else {
toast.error('Unable to delete deployment');
}
};
return ( return (
<> <>
<div className={cn('max-w-[32px]', className)} {...props}> <div className={cn('max-w-[32px]', className)} {...props}>
@ -147,6 +158,12 @@ export const DeploymentMenu = ({
> >
<UndoIcon /> Rollback to this version <UndoIcon /> Rollback to this version
</MenuItem> </MenuItem>
<MenuItem
className="hover:bg-base-bg-emphasized flex items-center gap-3"
onClick={() => deleteDeployment()}
>
<CrossCircleIcon /> Delete deployment
</MenuItem>
</MenuList> </MenuList>
</Menu> </Menu>
</div> </div>

View File

@ -107,7 +107,6 @@ export const UserSelect = ({ options, value }: UserSelectProps) => {
ref: inputWrapperRef, ref: inputWrapperRef,
suppressRefError: true, suppressRefError: true,
})} })}
ref={inputWrapperRef}
onClick={() => !dropdownOpen && openMenu()} onClick={() => !dropdownOpen && openMenu()}
className="cursor-pointer relative py-2 pl-2 pr-4 flex min-w-[200px] w-full items-center justify-between rounded-xl bg-surface-card shadow-sm" className="cursor-pointer relative py-2 pl-2 pr-4 flex min-w-[200px] w-full items-center justify-between rounded-xl bg-surface-card shadow-sm"
> >

View File

@ -21,7 +21,7 @@ const root = ReactDOM.createRoot(
assert( assert(
import.meta.env.VITE_SERVER_URL, import.meta.env.VITE_SERVER_URL,
'REACT_APP_SERVER_URL is not set in env', 'VITE_SERVER_URL is not set in env',
); );
const gqlEndpoint = `${import.meta.env.VITE_SERVER_URL}/${SERVER_GQL_PATH}`; const gqlEndpoint = `${import.meta.env.VITE_SERVER_URL}/${SERVER_GQL_PATH}`;

View File

@ -276,6 +276,17 @@ export class GQLClient {
return data; return data;
} }
async deleteDeployment (deploymentId: string): Promise<types.DeleteDeploymentResponse> {
const { data } = await this.client.mutate({
mutation: mutations.deleteDeployment,
variables: {
deploymentId
}
});
return data;
}
async addDomain (projectId: string, data: types.AddDomainInput): Promise<types.AddDomainResponse> { async addDomain (projectId: string, data: types.AddDomainInput): Promise<types.AddDomainResponse> {
const result = await this.client.mutate({ const result = await this.client.mutate({
mutation: mutations.addDomain, mutation: mutations.addDomain,

View File

@ -82,6 +82,12 @@ mutation ($projectId: String! ,$deploymentId: String!) {
} }
`; `;
export const deleteDeployment = gql`
mutation ($deploymentId: String!) {
deleteDeployment(deploymentId: $deploymentId)
}
`;
export const addDomain = gql` export const addDomain = gql`
mutation ($projectId: String!, $data: AddDomainInput!) { mutation ($projectId: String!, $data: AddDomainInput!) {
addDomain(projectId: $projectId, data: $data) addDomain(projectId: $projectId, data: $data)

View File

@ -21,6 +21,7 @@ export enum DeploymentStatus {
Building = 'Building', Building = 'Building',
Ready = 'Ready', Ready = 'Ready',
Error = 'Error', Error = 'Error',
Deleting = 'Deleting'
} }
export enum DomainStatus { export enum DomainStatus {
@ -269,6 +270,10 @@ export type RollbackDeploymentResponse = {
rollbackDeployment: boolean rollbackDeployment: boolean
} }
export type DeleteDeploymentResponse = {
deleteDeployment: boolean
}
export type AddDomainInput = { export type AddDomainInput = {
name: string name: string
} }