forked from cerc-io/snowballtools-base
Create deployments on push events in GitHub repo (#69)
* Create repo webhook and express handler for webhook * Create deployments from commits in GitHub * Update isCurrent in previous production deployment * Create script for setting authority * Update README for initialize registry script * Handle review changes * Use correct repo URL in record data * Handle github unique webhook error * Handle async execution of publishing records * Update readme with ngrok setup * Review changes * Add logs for GitHub webhooks --------- Co-authored-by: neeraj <neeraj.rtly@gmail.com>
This commit is contained in:
parent
9921dc5186
commit
c4ba59d97e
65
README.md
65
README.md
@ -38,25 +38,11 @@
|
|||||||
- Client ID and secret will be available after creating Github OAuth app
|
- Client ID and secret will be available after creating Github OAuth app
|
||||||
- https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app
|
- https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app
|
||||||
- In "Homepage URL", type `http://localhost:3000`
|
- In "Homepage URL", type `http://localhost:3000`
|
||||||
- In "Authorization callback URL", type `http://localhost:3000/projects/create`
|
- In "Authorization callback URL", type `http://localhost:3000/organization/projects/create`
|
||||||
- Generate a new client secret after app is created
|
- Generate a new client secret after app is created
|
||||||
|
|
||||||
- Run the laconicd stack following this [doc](https://git.vdb.to/cerc-io/stack-orchestrator/src/branch/main/docs/laconicd-with-console.md)
|
- Run the laconicd stack following this [doc](https://git.vdb.to/cerc-io/stack-orchestrator/src/branch/main/docs/laconicd-with-console.md)
|
||||||
|
|
||||||
- Create the bond and set `registryConfig.bondId` in backend [config file](packages/backend/environments/local.toml)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
laconic-so --stack fixturenet-laconic-loaded deploy exec cli "laconic cns bond create --type aphoton --quantity 1000000000 --gas 200000 --fees 200000aphoton"
|
|
||||||
|
|
||||||
# {"bondId":"b40f1308510f799860fb6f1ede47245a2d59f336631158f25ae0eec30aabaf89"}
|
|
||||||
```
|
|
||||||
|
|
||||||
- Export the bond id that is generated
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export BOND_ID=<BOND-ID>
|
|
||||||
```
|
|
||||||
|
|
||||||
- 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
|
||||||
@ -65,7 +51,7 @@
|
|||||||
# 754cca7b4b729a99d156913aea95366411d072856666e95ba09ef6c664357d81
|
# 754cca7b4b729a99d156913aea95366411d072856666e95ba09ef6c664357d81
|
||||||
```
|
```
|
||||||
|
|
||||||
- Get the rest and GQL endpoint of laconicd and set it to `registryConfig.restEndpoint` and `registryConfig.gqlEndpoint` in backend [config file](packages/backend/environments/local.toml)
|
- 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
|
```bash
|
||||||
# For registryConfig.restEndpoint
|
# For registryConfig.restEndpoint
|
||||||
@ -77,29 +63,34 @@
|
|||||||
# 0.0.0.0:32771
|
# 0.0.0.0:32771
|
||||||
```
|
```
|
||||||
|
|
||||||
- Reserve authorities for `snowballtools` and `cerc-io`
|
- Run the script to create bond, reserve the authority and set authority bond
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
laconic-so --stack fixturenet-laconic-loaded deploy exec cli "laconic cns authority reserve snowballtools"
|
yarn registry:init
|
||||||
# {"success":true}
|
# snowball:initialize-registry bondId: 6af0ab81973b93d3511ae79841756fb5da3fd2f70ea1279e81fae7c9b19af6c4 +0ms
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
- Get the bond id and set `registryConfig.bondId` in backend [config file](packages/backend/environments/local.toml)
|
||||||
laconic-so --stack fixturenet-laconic-loaded deploy exec cli "laconic cns authority reserve cerc-io"
|
|
||||||
# {"success":true}
|
|
||||||
```
|
|
||||||
|
|
||||||
- Set authority bond for `snowballtools` and `cerc-io`
|
- Setup ngrok for GitHub webhooks
|
||||||
|
- https://ngrok.com/docs/getting-started/
|
||||||
```bash
|
- Start ngrok and point to backend server endpoint
|
||||||
laconic-so --stack fixturenet-laconic-loaded deploy exec cli "laconic cns authority bond set snowballtools $BOND_ID"
|
```bash
|
||||||
# {"success":true}
|
ngrok http http://localhost:8000
|
||||||
```
|
```
|
||||||
|
- Look for the forwarding URL in ngrok
|
||||||
```bash
|
```
|
||||||
laconic-so --stack fixturenet-laconic-loaded deploy exec cli "laconic cns authority bond set cerc-io $BOND_ID"
|
...
|
||||||
# {"success":true}
|
Forwarding https://19c1-61-95-158-116.ngrok-free.app -> http://localhost:8000
|
||||||
```
|
...
|
||||||
|
```
|
||||||
|
- Set `gitHub.webhookUrl` in backend [config file](packages/backend/environments/local.toml)
|
||||||
|
```toml
|
||||||
|
...
|
||||||
|
[gitHub]
|
||||||
|
webhookUrl = "https://19c1-61-95-158-116.ngrok-free.app"
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
- Start the server in `packages/backend`
|
- Start the server in `packages/backend`
|
||||||
|
|
||||||
@ -127,6 +118,12 @@
|
|||||||
REACT_APP_GITHUB_CLIENT_ID = <CLIENT_ID>
|
REACT_APP_GITHUB_CLIENT_ID = <CLIENT_ID>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- Set `REACT_APP_GITHUB_TEMPLATE_REPO` in [.env](packages/frontend/.env) file
|
||||||
|
|
||||||
|
```env
|
||||||
|
REACT_APP_GITHUB_TEMPLATE_REPO = cerc-io/test-progressive-web-app
|
||||||
|
```
|
||||||
|
|
||||||
### Development
|
### Development
|
||||||
|
|
||||||
- Start the React application
|
- Start the React application
|
||||||
|
@ -6,9 +6,11 @@
|
|||||||
[database]
|
[database]
|
||||||
dbPath = "db/snowball"
|
dbPath = "db/snowball"
|
||||||
|
|
||||||
[githubOauth]
|
[gitHub]
|
||||||
clientId = ""
|
webhookUrl = ""
|
||||||
clientSecret = ""
|
[gitHub.oAuth]
|
||||||
|
clientId = ""
|
||||||
|
clientSecret = ""
|
||||||
|
|
||||||
[registryConfig]
|
[registryConfig]
|
||||||
restEndpoint = "http://localhost:1317"
|
restEndpoint = "http://localhost:1317"
|
||||||
|
@ -36,6 +36,7 @@
|
|||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"format:check": "prettier --check .",
|
"format:check": "prettier --check .",
|
||||||
|
"registry:init": "DEBUG=snowball:* ts-node scripts/initialize-registry.ts",
|
||||||
"db:load:fixtures": "DEBUG=snowball:* ts-node ./test/initialize-db.ts",
|
"db:load:fixtures": "DEBUG=snowball:* ts-node ./test/initialize-db.ts",
|
||||||
"db:delete": "DEBUG=snowball:* ts-node ./test/delete-db.ts"
|
"db:delete": "DEBUG=snowball:* ts-node ./test/delete-db.ts"
|
||||||
},
|
},
|
||||||
|
36
packages/backend/scripts/initialize-registry.ts
Normal file
36
packages/backend/scripts/initialize-registry.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import debug from 'debug';
|
||||||
|
|
||||||
|
import { Registry } from '@cerc-io/laconic-sdk';
|
||||||
|
|
||||||
|
import { DEFAULT_CONFIG_FILE_PATH } from '../src/constants';
|
||||||
|
import { Config } from '../src/config';
|
||||||
|
import { getConfig } from '../src/utils';
|
||||||
|
|
||||||
|
const log = debug('snowball:initialize-registry');
|
||||||
|
|
||||||
|
const DENOM = 'aphoton';
|
||||||
|
const BOND_AMOUNT = '1000000000';
|
||||||
|
|
||||||
|
// TODO: Get authority names from args
|
||||||
|
const AUTHORITY_NAMES = ['snowballtools', 'cerc-io'];
|
||||||
|
|
||||||
|
async function main () {
|
||||||
|
const { registryConfig } = await getConfig<Config>(DEFAULT_CONFIG_FILE_PATH);
|
||||||
|
|
||||||
|
const registry = new Registry(registryConfig.gqlEndpoint, registryConfig.restEndpoint, registryConfig.chainId);
|
||||||
|
|
||||||
|
const bondId = await registry.getNextBondId(registryConfig.privateKey);
|
||||||
|
log('bondId:', bondId);
|
||||||
|
await registry.createBond({ denom: DENOM, amount: BOND_AMOUNT }, registryConfig.privateKey, registryConfig.fee);
|
||||||
|
|
||||||
|
for await (const name of AUTHORITY_NAMES) {
|
||||||
|
await registry.reserveAuthority({ name }, registryConfig.privateKey, registryConfig.fee);
|
||||||
|
log('Reserved authority name:', name);
|
||||||
|
await registry.setAuthorityBond({ name, bondId }, registryConfig.privateKey, registryConfig.fee);
|
||||||
|
log(`Bond ${bondId} set for authority ${name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
log(err);
|
||||||
|
});
|
@ -8,9 +8,12 @@ export interface DatabaseConfig {
|
|||||||
dbPath: string;
|
dbPath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GithubOauthConfig {
|
export interface GitHubConfig {
|
||||||
clientId: string;
|
webhookUrl: string;
|
||||||
clientSecret: string;
|
oAuth: {
|
||||||
|
clientId: string;
|
||||||
|
clientSecret: string;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RegistryConfig {
|
export interface RegistryConfig {
|
||||||
@ -29,6 +32,6 @@ export interface RegistryConfig {
|
|||||||
export interface Config {
|
export interface Config {
|
||||||
server: ServerConfig;
|
server: ServerConfig;
|
||||||
database: DatabaseConfig;
|
database: DatabaseConfig;
|
||||||
githubOauth: GithubOauthConfig;
|
gitHub: GitHubConfig;
|
||||||
registryConfig: RegistryConfig;
|
registryConfig: RegistryConfig;
|
||||||
}
|
}
|
||||||
|
@ -83,6 +83,13 @@ export class Database {
|
|||||||
return userOrgs;
|
return userOrgs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getProjects (options: FindManyOptions<Project>): Promise<Project[]> {
|
||||||
|
const projectRepository = this.dataSource.getRepository(Project);
|
||||||
|
const projects = await projectRepository.find(options);
|
||||||
|
|
||||||
|
return projects;
|
||||||
|
}
|
||||||
|
|
||||||
async getProjectById (projectId: string): Promise<Project | null> {
|
async getProjectById (projectId: string): Promise<Project | null> {
|
||||||
const projectRepository = this.dataSource.getRepository(Project);
|
const projectRepository = this.dataSource.getRepository(Project);
|
||||||
|
|
||||||
@ -294,8 +301,12 @@ export class Database {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async updateDeploymentById (deploymentId: string, data: DeepPartial<Deployment>): Promise<boolean> {
|
async updateDeploymentById (deploymentId: string, data: DeepPartial<Deployment>): Promise<boolean> {
|
||||||
|
return this.updateDeployment({ id: deploymentId }, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateDeployment (criteria: FindOptionsWhere<Deployment>, data: DeepPartial<Deployment>): Promise<boolean> {
|
||||||
const deploymentRepository = this.dataSource.getRepository(Deployment);
|
const deploymentRepository = this.dataSource.getRepository(Deployment);
|
||||||
const updateResult = await deploymentRepository.update({ id: deploymentId }, data);
|
const updateResult = await deploymentRepository.update(criteria, data);
|
||||||
|
|
||||||
return Boolean(updateResult.affected);
|
return Boolean(updateResult.affected);
|
||||||
}
|
}
|
||||||
|
@ -49,6 +49,9 @@ export class Deployment {
|
|||||||
@JoinColumn({ name: 'projectId' })
|
@JoinColumn({ name: 'projectId' })
|
||||||
project!: Project;
|
project!: Project;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
domainId!: string | null;
|
||||||
|
|
||||||
@OneToOne(() => Domain)
|
@OneToOne(() => Domain)
|
||||||
@JoinColumn({ name: 'domainId' })
|
@JoinColumn({ name: 'domainId' })
|
||||||
domain!: Domain | null;
|
domain!: Domain | null;
|
||||||
|
@ -33,6 +33,9 @@ export class Project {
|
|||||||
@JoinColumn({ name: 'ownerId' })
|
@JoinColumn({ name: 'ownerId' })
|
||||||
owner!: User;
|
owner!: User;
|
||||||
|
|
||||||
|
@Column({ nullable: false })
|
||||||
|
ownerId!: string;
|
||||||
|
|
||||||
@ManyToOne(() => Organization, { nullable: true })
|
@ManyToOne(() => Organization, { nullable: true })
|
||||||
@JoinColumn({ name: 'organizationId' })
|
@JoinColumn({ name: 'organizationId' })
|
||||||
organization!: Organization | null;
|
organization!: Organization | null;
|
||||||
|
@ -19,24 +19,24 @@ const OAUTH_CLIENT_TYPE = 'oauth-app';
|
|||||||
|
|
||||||
export const main = async (): Promise<void> => {
|
export const main = async (): Promise<void> => {
|
||||||
// TODO: get config path using cli
|
// TODO: get config path using cli
|
||||||
const { server, database, githubOauth, registryConfig } = await getConfig<Config>(DEFAULT_CONFIG_FILE_PATH);
|
const { server, database, gitHub, registryConfig } = await getConfig<Config>(DEFAULT_CONFIG_FILE_PATH);
|
||||||
|
|
||||||
const app = new OAuthApp({
|
const app = new OAuthApp({
|
||||||
clientType: OAUTH_CLIENT_TYPE,
|
clientType: OAUTH_CLIENT_TYPE,
|
||||||
clientId: githubOauth.clientId,
|
clientId: gitHub.oAuth.clientId,
|
||||||
clientSecret: githubOauth.clientSecret
|
clientSecret: gitHub.oAuth.clientSecret
|
||||||
});
|
});
|
||||||
|
|
||||||
const db = new Database(database);
|
const db = new Database(database);
|
||||||
await db.init();
|
await db.init();
|
||||||
|
|
||||||
const registry = new Registry(registryConfig);
|
const registry = new Registry(registryConfig);
|
||||||
const service = new Service(db, app, registry);
|
const service = new Service({ gitHubConfig: gitHub }, db, app, registry);
|
||||||
|
|
||||||
const typeDefs = fs.readFileSync(path.join(__dirname, 'schema.gql')).toString();
|
const typeDefs = fs.readFileSync(path.join(__dirname, 'schema.gql')).toString();
|
||||||
const resolvers = await createResolvers(service);
|
const resolvers = await createResolvers(service);
|
||||||
|
|
||||||
await createAndStartServer(typeDefs, resolvers, server);
|
await createAndStartServer(server, typeDefs, resolvers, service);
|
||||||
};
|
};
|
||||||
|
|
||||||
main()
|
main()
|
||||||
|
@ -28,11 +28,13 @@ export class Registry {
|
|||||||
async createApplicationRecord ({
|
async createApplicationRecord ({
|
||||||
packageJSON,
|
packageJSON,
|
||||||
commitHash,
|
commitHash,
|
||||||
appType
|
appType,
|
||||||
|
repoUrl
|
||||||
}: {
|
}: {
|
||||||
packageJSON: PackageJSON
|
packageJSON: PackageJSON
|
||||||
|
commitHash: string,
|
||||||
appType: string,
|
appType: string,
|
||||||
commitHash: string
|
repoUrl: string
|
||||||
}): Promise<{registryRecordId: string, registryRecordData: ApplicationRecord}> {
|
}): Promise<{registryRecordId: string, registryRecordData: ApplicationRecord}> {
|
||||||
// Use laconic-sdk to publish record
|
// Use laconic-sdk to publish record
|
||||||
// Reference: https://git.vdb.to/cerc-io/test-progressive-web-app/src/branch/main/scripts/publish-app-record.sh
|
// Reference: https://git.vdb.to/cerc-io/test-progressive-web-app/src/branch/main/scripts/publish-app-record.sh
|
||||||
@ -54,13 +56,13 @@ export class Registry {
|
|||||||
type: APP_RECORD_TYPE,
|
type: APP_RECORD_TYPE,
|
||||||
version: nextVersion,
|
version: nextVersion,
|
||||||
repository_ref: commitHash,
|
repository_ref: commitHash,
|
||||||
|
repository: [repoUrl],
|
||||||
app_type: appType,
|
app_type: appType,
|
||||||
...(packageJSON.name && { name: packageJSON.name }),
|
...(packageJSON.name && { name: packageJSON.name }),
|
||||||
...(packageJSON.description && { description: packageJSON.description }),
|
...(packageJSON.description && { description: packageJSON.description }),
|
||||||
...(packageJSON.homepage && { homepage: packageJSON.homepage }),
|
...(packageJSON.homepage && { homepage: packageJSON.homepage }),
|
||||||
...(packageJSON.license && { license: packageJSON.license }),
|
...(packageJSON.license && { license: packageJSON.license }),
|
||||||
...(packageJSON.author && { author: typeof packageJSON.author === 'object' ? JSON.stringify(packageJSON.author) : packageJSON.author }),
|
...(packageJSON.author && { author: typeof packageJSON.author === 'object' ? JSON.stringify(packageJSON.author) : packageJSON.author }),
|
||||||
...(packageJSON.repository && { repository: [packageJSON.repository] }),
|
|
||||||
...(packageJSON.version && { app_version: packageJSON.version })
|
...(packageJSON.version && { app_version: packageJSON.version })
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -78,6 +80,7 @@ export class Registry {
|
|||||||
|
|
||||||
// TODO: Discuss computation of CRN
|
// TODO: Discuss computation of CRN
|
||||||
const crn = this.getCrn(packageJSON.name ?? '');
|
const crn = this.getCrn(packageJSON.name ?? '');
|
||||||
|
log(`Setting name: ${crn} for record ID: ${result.data.id}`);
|
||||||
|
|
||||||
await this.registry.setName({ cid: result.data.id, crn }, this.registryConfig.privateKey, this.registryConfig.fee);
|
await this.registry.setName({ cid: result.data.id, crn }, this.registryConfig.privateKey, this.registryConfig.fee);
|
||||||
await this.registry.setName({ cid: result.data.id, crn: `${crn}@${applicationRecord.app_version}` }, this.registryConfig.privateKey, this.registryConfig.fee);
|
await this.registry.setName({ cid: result.data.id, crn: `${crn}@${applicationRecord.app_version}` }, this.registryConfig.privateKey, this.registryConfig.fee);
|
||||||
|
26
packages/backend/src/routes/github.ts
Normal file
26
packages/backend/src/routes/github.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import debug from 'debug';
|
||||||
|
|
||||||
|
import { Service } from '../service';
|
||||||
|
|
||||||
|
const log = debug('snowball:routes-github');
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
/* POST GitHub webhook handler */
|
||||||
|
// https://docs.github.com/en/webhooks/using-webhooks/handling-webhook-deliveries#javascript-example
|
||||||
|
router.post('/webhook', async (req, res) => {
|
||||||
|
// Server should respond with a 2XX response within 10 seconds of receiving a webhook delivery
|
||||||
|
// If server takes longer than that to respond, then GitHub terminates the connection and considers the delivery a failure
|
||||||
|
res.status(202).send('Accepted');
|
||||||
|
|
||||||
|
const service = req.app.get('service') as Service;
|
||||||
|
const githubEvent = req.headers['x-github-event'];
|
||||||
|
log(`Received GitHub webhook for event ${githubEvent}`);
|
||||||
|
|
||||||
|
if (githubEvent === 'push') {
|
||||||
|
// Create deployments using push event data
|
||||||
|
await service.handleGitHubPush(req.body);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
@ -12,13 +12,16 @@ import { makeExecutableSchema } from '@graphql-tools/schema';
|
|||||||
|
|
||||||
import { ServerConfig } from './config';
|
import { ServerConfig } from './config';
|
||||||
import { DEFAULT_GQL_PATH, USER_ID } from './constants';
|
import { DEFAULT_GQL_PATH, USER_ID } from './constants';
|
||||||
|
import githubRouter from './routes/github';
|
||||||
|
import { Service } from './service';
|
||||||
|
|
||||||
const log = debug('snowball:server');
|
const log = debug('snowball:server');
|
||||||
|
|
||||||
export const createAndStartServer = async (
|
export const createAndStartServer = async (
|
||||||
|
serverConfig: ServerConfig,
|
||||||
typeDefs: TypeSource,
|
typeDefs: TypeSource,
|
||||||
resolvers: any,
|
resolvers: any,
|
||||||
serverConfig: ServerConfig
|
service: Service
|
||||||
): Promise<ApolloServer> => {
|
): Promise<ApolloServer> => {
|
||||||
const { host, port, gqlPath = DEFAULT_GQL_PATH } = serverConfig;
|
const { host, port, gqlPath = DEFAULT_GQL_PATH } = serverConfig;
|
||||||
|
|
||||||
@ -54,6 +57,10 @@ export const createAndStartServer = async (
|
|||||||
path: gqlPath
|
path: gqlPath
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.set('service', service);
|
||||||
|
app.use(express.json());
|
||||||
|
app.use('/api/github', githubRouter);
|
||||||
|
|
||||||
httpServer.listen(port, host, () => {
|
httpServer.listen(port, host, () => {
|
||||||
log(`Server is listening on ${host}:${port}${server.graphqlPath}`);
|
log(`Server is listening on ${host}:${port}${server.graphqlPath}`);
|
||||||
});
|
});
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import assert from 'assert';
|
import assert from 'assert';
|
||||||
import debug from 'debug';
|
import debug from 'debug';
|
||||||
import { DeepPartial, FindOptionsWhere } from 'typeorm';
|
import { DeepPartial, FindOptionsWhere } from 'typeorm';
|
||||||
import { Octokit } from 'octokit';
|
import { Octokit, RequestError } from 'octokit';
|
||||||
|
|
||||||
import { OAuthApp } from '@octokit/oauth-app';
|
import { OAuthApp } from '@octokit/oauth-app';
|
||||||
|
|
||||||
@ -14,18 +14,27 @@ import { Project } from './entity/Project';
|
|||||||
import { Permission, ProjectMember } from './entity/ProjectMember';
|
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 } from './config';
|
||||||
|
import { GitPushEventPayload } from './types';
|
||||||
|
|
||||||
const log = debug('snowball:service');
|
const log = debug('snowball:service');
|
||||||
|
const GITHUB_UNIQUE_WEBHOOK_ERROR = 'Hook already exists on this repository';
|
||||||
|
|
||||||
|
interface Config {
|
||||||
|
gitHubConfig: GitHubConfig
|
||||||
|
}
|
||||||
|
|
||||||
export class Service {
|
export class Service {
|
||||||
private db: Database;
|
private db: Database;
|
||||||
private oauthApp: OAuthApp;
|
private oauthApp: OAuthApp;
|
||||||
private registry: Registry;
|
private registry: Registry;
|
||||||
|
private config: Config;
|
||||||
|
|
||||||
constructor (db: Database, app: OAuthApp, registry: Registry) {
|
constructor (config: Config, db: Database, app: OAuthApp, registry: Registry) {
|
||||||
this.db = db;
|
this.db = db;
|
||||||
this.oauthApp = app;
|
this.oauthApp = app;
|
||||||
this.registry = registry;
|
this.registry = registry;
|
||||||
|
this.config = config;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUser (userId: string): Promise<User | null> {
|
async getUser (userId: string): Promise<User | null> {
|
||||||
@ -176,38 +185,6 @@ export class Service {
|
|||||||
|
|
||||||
const prodBranchDomains = await this.db.getDomainsByProjectId(oldDeployment.project.id, { branch: oldDeployment.project.prodBranch });
|
const prodBranchDomains = await this.db.getDomainsByProjectId(oldDeployment.project.id, { branch: oldDeployment.project.prodBranch });
|
||||||
|
|
||||||
// TODO: Fix unique constraint error for domain
|
|
||||||
const deploymentWithProdBranchDomain = await this.db.getDeployment({
|
|
||||||
where: {
|
|
||||||
domain: {
|
|
||||||
id: prodBranchDomains[0].id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (deploymentWithProdBranchDomain) {
|
|
||||||
await this.db.updateDeploymentById(deploymentWithProdBranchDomain.id, {
|
|
||||||
domain: null,
|
|
||||||
isCurrent: false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const oldCurrentDeployment = await this.db.getDeployment({
|
|
||||||
relations: {
|
|
||||||
domain: true
|
|
||||||
},
|
|
||||||
where: {
|
|
||||||
project: {
|
|
||||||
id: oldDeployment.project.id
|
|
||||||
},
|
|
||||||
isCurrent: true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (oldCurrentDeployment) {
|
|
||||||
await this.db.updateDeploymentById(oldCurrentDeployment.id, { isCurrent: false, domain: null });
|
|
||||||
}
|
|
||||||
|
|
||||||
const octokit = await this.getOctokit(userId);
|
const octokit = await this.getOctokit(userId);
|
||||||
|
|
||||||
const newDeployement = await this.createDeployment(userId,
|
const newDeployement = await this.createDeployment(userId,
|
||||||
@ -225,8 +202,14 @@ export class Service {
|
|||||||
return newDeployement;
|
return newDeployement;
|
||||||
}
|
}
|
||||||
|
|
||||||
async createDeployment (userId: string, octokit: Octokit, data: DeepPartial<Deployment>): Promise<Deployment> {
|
async createDeployment (
|
||||||
|
userId: string,
|
||||||
|
octokit: Octokit,
|
||||||
|
data: DeepPartial<Deployment>,
|
||||||
|
recordData: { repoUrl?: string } = {}
|
||||||
|
): Promise<Deployment> {
|
||||||
assert(data.project?.repository, 'Project repository not found');
|
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 [owner, repo] = data.project.repository.split('/');
|
||||||
|
|
||||||
const { data: packageJSONData } = await octokit.rest.repos.getContent({
|
const { data: packageJSONData } = await octokit.rest.repos.getContent({
|
||||||
@ -243,10 +226,35 @@ export class Service {
|
|||||||
assert(!Array.isArray(packageJSONData) && packageJSONData.type === 'file');
|
assert(!Array.isArray(packageJSONData) && packageJSONData.type === 'file');
|
||||||
const packageJSON = JSON.parse(atob(packageJSONData.content));
|
const packageJSON = JSON.parse(atob(packageJSONData.content));
|
||||||
|
|
||||||
|
if (!recordData.repoUrl) {
|
||||||
|
const { data: repoDetails } = await octokit.rest.repos.get({ owner, repo });
|
||||||
|
recordData.repoUrl = repoDetails.html_url;
|
||||||
|
}
|
||||||
|
|
||||||
const { registryRecordId, registryRecordData } = await this.registry.createApplicationRecord({
|
const { registryRecordId, registryRecordData } = await this.registry.createApplicationRecord({
|
||||||
packageJSON,
|
packageJSON,
|
||||||
appType: data.project!.template!,
|
appType: data.project!.template!,
|
||||||
commitHash: data.commitHash!
|
commitHash: data.commitHash!,
|
||||||
|
repoUrl: recordData.repoUrl
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if new deployment is set to current
|
||||||
|
if (data.isCurrent) {
|
||||||
|
// Update previous current deployment
|
||||||
|
await this.db.updateDeployment({
|
||||||
|
project: {
|
||||||
|
id: data.project.id
|
||||||
|
},
|
||||||
|
isCurrent: true
|
||||||
|
}, { isCurrent: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update previous deployment with prod branch domain
|
||||||
|
// TODO: Fix unique constraint error for domain
|
||||||
|
await this.db.updateDeployment({
|
||||||
|
domainId: data.domain?.id
|
||||||
|
}, {
|
||||||
|
domain: null
|
||||||
});
|
});
|
||||||
|
|
||||||
const newDeployement = await this.db.addDeployement({
|
const newDeployement = await this.db.addDeployement({
|
||||||
@ -265,7 +273,7 @@ export class Service {
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
log(`Application record ${registryRecordId} published for deployment ${newDeployement.id}`);
|
log(`Created deployment ${newDeployement.id} and published application record ${registryRecordId}`);
|
||||||
return newDeployement;
|
return newDeployement;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -304,29 +312,91 @@ export class Service {
|
|||||||
domain: null,
|
domain: null,
|
||||||
commitHash: latestCommit.sha,
|
commitHash: latestCommit.sha,
|
||||||
commitMessage: latestCommit.commit.message
|
commitMessage: latestCommit.commit.message
|
||||||
});
|
},
|
||||||
|
{
|
||||||
|
repoUrl: repoDetails.html_url
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const { registryRecordId, registryRecordData } = await this.registry.createApplicationDeploymentRequest(
|
const { registryRecordId, registryRecordData } = await this.registry.createApplicationDeploymentRequest(
|
||||||
{
|
{
|
||||||
appName: newDeployment.registryRecordData.name!,
|
appName: newDeployment.registryRecordData.name!,
|
||||||
commitHash: latestCommit.sha,
|
commitHash: latestCommit.sha,
|
||||||
repository: repoDetails.git_url
|
repository: repoDetails.html_url
|
||||||
});
|
});
|
||||||
await this.db.updateProjectById(project.id, {
|
await this.db.updateProjectById(project.id, {
|
||||||
registryRecordId,
|
registryRecordId,
|
||||||
registryRecordData
|
registryRecordData
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO: Setup repo webhook for push events
|
await this.createRepoHook(octokit, project);
|
||||||
|
|
||||||
return project;
|
return project;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createRepoHook (octokit: Octokit, project: Project): Promise<void> {
|
||||||
|
try {
|
||||||
|
const [owner, repo] = project.repository.split('/');
|
||||||
|
await octokit.rest.repos.createWebhook({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
config: {
|
||||||
|
url: new URL('api/github/webhook', this.config.gitHubConfig.webhookUrl).href,
|
||||||
|
content_type: 'json'
|
||||||
|
},
|
||||||
|
events: ['push']
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// https://docs.github.com/en/rest/repos/webhooks?apiVersion=2022-11-28#create-a-repository-webhook--status-codes
|
||||||
|
if (
|
||||||
|
!(err instanceof RequestError &&
|
||||||
|
err.status === 422 &&
|
||||||
|
(err.response?.data as any).errors.some((err: any) => err.message === GITHUB_UNIQUE_WEBHOOK_ERROR))) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
log(GITHUB_UNIQUE_WEBHOOK_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleGitHubPush (data: GitPushEventPayload): Promise<void> {
|
||||||
|
const { repository, ref, head_commit: headCommit } = data;
|
||||||
|
log(`Handling GitHub push event from repository: ${repository.full_name}`);
|
||||||
|
const projects = await this.db.getProjects({ where: { repository: repository.full_name } });
|
||||||
|
|
||||||
|
if (!projects.length) {
|
||||||
|
log(`No projects found for repository ${repository.full_name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The `ref` property contains the full reference, including the branch name
|
||||||
|
// For example, "refs/heads/main" or "refs/heads/feature-branch"
|
||||||
|
const branch = ref.split('/').pop();
|
||||||
|
|
||||||
|
for await (const project of projects) {
|
||||||
|
const octokit = await this.getOctokit(project.ownerId);
|
||||||
|
const [domain] = await this.db.getDomainsByProjectId(project.id, { branch });
|
||||||
|
|
||||||
|
// Create deployment with branch and latest commit in GitHub data
|
||||||
|
await this.createDeployment(project.ownerId,
|
||||||
|
octokit,
|
||||||
|
{
|
||||||
|
project,
|
||||||
|
isCurrent: project.prodBranch === branch,
|
||||||
|
branch,
|
||||||
|
environment: project.prodBranch === branch ? Environment.Production : Environment.Preview,
|
||||||
|
domain,
|
||||||
|
commitHash: headCommit.id,
|
||||||
|
commitMessage: headCommit.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async updateProject (projectId: string, data: DeepPartial<Project>): Promise<boolean> {
|
async updateProject (projectId: string, data: DeepPartial<Project>): Promise<boolean> {
|
||||||
return this.db.updateProjectById(projectId, data);
|
return this.db.updateProjectById(projectId, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteProject (projectId: string): Promise<boolean> {
|
async deleteProject (projectId: string): Promise<boolean> {
|
||||||
|
// TODO: Remove GitHub repo hook
|
||||||
return this.db.deleteProjectById(projectId);
|
return this.db.deleteProjectById(projectId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -360,8 +430,6 @@ export class Service {
|
|||||||
throw new Error('Deployment not found');
|
throw new Error('Deployment not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.db.updateDeploymentById(deploymentId, { domain: null, isCurrent: false });
|
|
||||||
|
|
||||||
const octokit = await this.getOctokit(userId);
|
const octokit = await this.getOctokit(userId);
|
||||||
|
|
||||||
const newDeployement = await this.createDeployment(userId,
|
const newDeployement = await this.createDeployment(userId,
|
||||||
|
@ -7,3 +7,21 @@ export interface PackageJSON {
|
|||||||
license?: string;
|
license?: string;
|
||||||
repository?: string;
|
repository?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GitRepositoryDetails {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
full_name: string;
|
||||||
|
visibility?: string;
|
||||||
|
updated_at?: string | null;
|
||||||
|
default_branch?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitPushEventPayload {
|
||||||
|
repository: GitRepositoryDetails;
|
||||||
|
ref: string;
|
||||||
|
head_commit: {
|
||||||
|
id: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
REACT_APP_GQL_SERVER_URL = 'http://localhost:8000/graphql'
|
REACT_APP_GQL_SERVER_URL = 'http://localhost:8000/graphql'
|
||||||
|
|
||||||
REACT_APP_GITHUB_CLIENT_ID =
|
REACT_APP_GITHUB_CLIENT_ID =
|
||||||
|
REACT_APP_GITHUB_TEMPLATE_REPO =
|
||||||
|
@ -3,10 +3,10 @@ import React from 'react';
|
|||||||
import { Typography, IconButton } from '@material-tailwind/react';
|
import { Typography, IconButton } from '@material-tailwind/react';
|
||||||
|
|
||||||
import { relativeTimeISO } from '../../../utils/time';
|
import { relativeTimeISO } from '../../../utils/time';
|
||||||
import { GitCommitDetails } from '../../../types';
|
import { GitCommitWithBranch } from '../../../types';
|
||||||
|
|
||||||
interface ActivityCardProps {
|
interface ActivityCardProps {
|
||||||
activity: GitCommitDetails;
|
activity: GitCommitWithBranch;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ActivityCard = ({ activity }: ActivityCardProps) => {
|
const ActivityCard = ({ activity }: ActivityCardProps) => {
|
||||||
|
@ -33,13 +33,19 @@ const CreateRepo = () => {
|
|||||||
assert(data.account);
|
assert(data.account);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
assert(
|
||||||
|
process.env.REACT_APP_GITHUB_TEMPLATE_REPO,
|
||||||
|
'Config REACT_APP_GITHUB_TEMPLATE_REPO is not set in .env',
|
||||||
|
);
|
||||||
|
const [owner, repo] =
|
||||||
|
process.env.REACT_APP_GITHUB_TEMPLATE_REPO.split('/');
|
||||||
|
|
||||||
// TODO: Handle this functionality in backend
|
// TODO: Handle this functionality in backend
|
||||||
const gitRepo = await octokit?.rest.repos.createUsingTemplate({
|
const gitRepo = await octokit?.rest.repos.createUsingTemplate({
|
||||||
template_owner: 'github-rest',
|
template_owner: owner,
|
||||||
template_repo: 'test-progressive-web-app',
|
template_repo: repo,
|
||||||
owner: data.account,
|
owner: data.account,
|
||||||
name: data.repoName,
|
name: data.repoName,
|
||||||
description: 'This is your first repository',
|
|
||||||
include_all_branches: false,
|
include_all_branches: false,
|
||||||
private: data.isPrivate,
|
private: data.isPrivate,
|
||||||
});
|
});
|
||||||
@ -60,6 +66,7 @@ const CreateRepo = () => {
|
|||||||
`/${orgSlug}/projects/create/template/deploy?projectId=${addProject.id}`,
|
`/${orgSlug}/projects/create/template/deploy?projectId=${addProject.id}`,
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
toast.error('Error deploying project');
|
toast.error('Error deploying project');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -8,7 +8,7 @@ import { Typography, Button, Chip, Avatar } from '@material-tailwind/react';
|
|||||||
import ActivityCard from '../../../../components/projects/project/ActivityCard';
|
import ActivityCard from '../../../../components/projects/project/ActivityCard';
|
||||||
import { relativeTimeMs } from '../../../../utils/time';
|
import { relativeTimeMs } from '../../../../utils/time';
|
||||||
import { useOctokit } from '../../../../context/OctokitContext';
|
import { useOctokit } from '../../../../context/OctokitContext';
|
||||||
import { GitCommitDetails, OutletContextType } from '../../../../types';
|
import { GitCommitWithBranch, OutletContextType } from '../../../../types';
|
||||||
import { useGQLClient } from '../../../../context/GQLClientContext';
|
import { useGQLClient } from '../../../../context/GQLClientContext';
|
||||||
|
|
||||||
const COMMITS_PER_PAGE = 4;
|
const COMMITS_PER_PAGE = 4;
|
||||||
@ -16,7 +16,7 @@ const COMMITS_PER_PAGE = 4;
|
|||||||
const OverviewTabPanel = () => {
|
const OverviewTabPanel = () => {
|
||||||
const { octokit } = useOctokit();
|
const { octokit } = useOctokit();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [activities, setActivities] = useState<GitCommitDetails[]>([]);
|
const [activities, setActivities] = useState<GitCommitWithBranch[]>([]);
|
||||||
const [liveDomain, setLiveDomain] = useState<Domain>();
|
const [liveDomain, setLiveDomain] = useState<Domain>();
|
||||||
|
|
||||||
const client = useGQLClient();
|
const client = useGQLClient();
|
||||||
@ -78,6 +78,7 @@ const OverviewTabPanel = () => {
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Show warning in activity section on request error
|
||||||
console.log(err.message);
|
console.log(err.message);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -29,7 +29,7 @@ export interface GitBranchDetails {
|
|||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GitCommitDetails {
|
export interface GitCommitWithBranch {
|
||||||
branch: GitBranchDetails;
|
branch: GitBranchDetails;
|
||||||
commit: {
|
commit: {
|
||||||
author: {
|
author: {
|
||||||
|
Loading…
Reference in New Issue
Block a user