Added project pages and cards, most of the screens in chris samuels figma design document. Still need to implement project initialization modal and walkthrough, connect to backend and connect to wallet (maybe be beyond scope of this project)
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
# backend
|
||||
|
||||
This backend is a [node.js](https://nodejs.org/) [express.js](https://expressjs.com/) [apollo server](https://www.apollographql.com/docs/apollo-server/) project in a [yarn workspace](https://yarnpkg.com/features/workspaces).
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Install dependencies
|
||||
|
||||
In the root of the project, run:
|
||||
|
||||
```zsh
|
||||
yarn
|
||||
```
|
||||
|
||||
### Build backend
|
||||
|
||||
```zsh
|
||||
yarn build --ignore frontend
|
||||
```
|
||||
|
||||
### Environment variables
|
||||
|
||||
#### Local
|
||||
|
||||
Copy the `environments/local.toml.example` file to `environments/local.toml`:
|
||||
|
||||
```zsh
|
||||
cp environments/local.toml.example environments/local.toml
|
||||
```
|
||||
|
||||
#### Staging environment variables
|
||||
|
||||
In the deployment repository, update staging [staging/configmaps/config/prod.toml](https://git.vdb.to/cerc-io/snowballtools-base-api-deployments/src/commit/318c2bc09f334dca79c3501838512749f9431bf1/deployments/staging/configmaps/config/prod.toml)
|
||||
|
||||
#### Production environment variables
|
||||
|
||||
In the deployment repository, update production [production/configmaps/config/prod.toml](https://git.vdb.to/cerc-io/snowballtools-base-api-deployments/src/commit/318c2bc09f334dca79c3501838512749f9431bf1/deployments/production/configmaps/config/prod.toml)
|
||||
|
||||
### Run development server
|
||||
|
||||
```zsh
|
||||
yarn start
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
Clone the [deployer repository](https://git.vdb.to/cerc-io/snowballtools-base-api-deployments):
|
||||
|
||||
```zsh
|
||||
git clone git@git.vdb.to:cerc-io/snowballtools-base-api-deployments.git
|
||||
```
|
||||
|
||||
### Staging
|
||||
|
||||
```zsh
|
||||
echo trigger >> .gitea/workflows/triggers/staging-deploy
|
||||
git commit -a -m "Deploy v0.0.8" # replace with version number
|
||||
git push
|
||||
```
|
||||
|
||||
### Production
|
||||
|
||||
```zsh
|
||||
echo trigger >> .gitea/workflows/triggers/production-deploy
|
||||
git commit -a -m "Deploy v0.0.8" # replace with version number
|
||||
git push
|
||||
```
|
||||
|
||||
### Deployment status
|
||||
|
||||
Dumb for now
|
||||
|
||||
- [Staging](https://snowballtools-base-api.staging.apps.snowballtools.com/staging/version)
|
||||
- [Production](https://snowballtools-base-api.apps.snowballtools.com/staging/version)
|
||||
|
||||
Update version number manually in [routes/staging.ts](/packages/backend/src/routes/staging.ts)
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"lineWidth": 80
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"suspicious": {
|
||||
"noExplicitAny": "off"
|
||||
},
|
||||
"style": {
|
||||
"noNonNullAssertion": "off"
|
||||
}
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"quoteStyle": "single",
|
||||
"trailingCommas": "none",
|
||||
"semicolons": "asNeeded"
|
||||
}
|
||||
},
|
||||
"files": {
|
||||
"ignore": ["dist/**/*", "node_modules/**/*", ".turbo/**/*"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
[server]
|
||||
host = "127.0.0.1"
|
||||
port = 8000
|
||||
gqlPath = "/graphql"
|
||||
[server.session]
|
||||
secret = ""
|
||||
# Frontend webapp URL origin
|
||||
appOriginUrl = "http://localhost:3000"
|
||||
# Set to true if server running behind proxy
|
||||
trustProxy = false
|
||||
# Backend URL hostname
|
||||
domain = "localhost"
|
||||
|
||||
[database]
|
||||
dbPath = "db/snowball"
|
||||
|
||||
[gitHub]
|
||||
webhookUrl = ""
|
||||
[gitHub.oAuth]
|
||||
clientId = ""
|
||||
clientSecret = ""
|
||||
|
||||
[registryConfig]
|
||||
fetchDeploymentRecordDelay = 5000
|
||||
checkAuctionStatusDelay = 5000
|
||||
restEndpoint = "http://localhost:1317"
|
||||
gqlEndpoint = "http://localhost:9473/api"
|
||||
chainId = "laconic_9000-1"
|
||||
privateKey = ""
|
||||
bondId = ""
|
||||
authority = ""
|
||||
[registryConfig.fee]
|
||||
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"
|
||||
@@ -0,0 +1,43 @@
|
||||
[server]
|
||||
host = "127.0.0.1"
|
||||
port = 8000
|
||||
gqlPath = "/graphql"
|
||||
[server.session]
|
||||
secret = ""
|
||||
# Frontend webapp URL origin
|
||||
appOriginUrl = "http://localhost:3000"
|
||||
# Set to true if server running behind proxy
|
||||
trustProxy = false
|
||||
# Backend URL hostname
|
||||
domain = "localhost"
|
||||
|
||||
[database]
|
||||
dbPath = "db/snowball"
|
||||
|
||||
[gitHub]
|
||||
webhookUrl = ""
|
||||
[gitHub.oAuth]
|
||||
clientId = ""
|
||||
clientSecret = ""
|
||||
|
||||
[registryConfig]
|
||||
fetchDeploymentRecordDelay = 5000
|
||||
checkAuctionStatusDelay = 5000
|
||||
restEndpoint = "http://localhost:1317"
|
||||
gqlEndpoint = "http://localhost:9473/api"
|
||||
chainId = "laconic_9000-1"
|
||||
privateKey = ""
|
||||
bondId = ""
|
||||
authority = ""
|
||||
[registryConfig.fee]
|
||||
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"
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"name": "@qwrk/backend",
|
||||
"license": "UNLICENSED",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"dependencies": {
|
||||
"@cerc-io/registry-sdk": "^0.2.11",
|
||||
"@cosmjs/stargate": "^0.33.0",
|
||||
"@graphql-tools/schema": "^10.0.2",
|
||||
"@graphql-tools/utils": "^10.0.12",
|
||||
"@octokit/oauth-app": "^6.1.0",
|
||||
"@turnkey/sdk-server": "^0.1.0",
|
||||
"@types/debug": "^4.1.5",
|
||||
"@types/node": "^20.11.0",
|
||||
"@types/semver": "^7.5.8",
|
||||
"apollo-server-core": "^3.13.0",
|
||||
"apollo-server-express": "^3.13.0",
|
||||
"cookie-session": "^2.1.0",
|
||||
"cors": "^2.8.5",
|
||||
"debug": "^4.3.1",
|
||||
"express": "^4.18.2",
|
||||
"express-async-errors": "^3.1.1",
|
||||
"express-session": "^1.18.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
"graphql": "^16.8.1",
|
||||
"luxon": "^3.5.0",
|
||||
"nanoid": "3",
|
||||
"nanoid-dictionary": "^5.0.0-beta.1",
|
||||
"octokit": "^3.1.2",
|
||||
"openpgp": "^6.0.1",
|
||||
"reflect-metadata": "^0.2.1",
|
||||
"semver": "^7.6.0",
|
||||
"siwe": "^3.0.0",
|
||||
"toml": "^3.0.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"typeorm": "^0.3.19",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "DEBUG=snowball:* node --enable-source-maps ./dist/index.js",
|
||||
"start:dev": "DEBUG=snowball:* ts-node ./src/index.ts",
|
||||
"copy-assets": "copyfiles -u 1 src/**/*.gql dist/",
|
||||
"clean": "rm -rf ./dist",
|
||||
"build": "pnpm clean && tsc && pnpm copy-assets",
|
||||
"format": "biome format .",
|
||||
"format:check": "biome format --check .",
|
||||
"lint": "biome check .",
|
||||
"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-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:delete": "DEBUG=snowball:* ts-node ./test/delete-db.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.9.4",
|
||||
"@types/cookie-session": "^2.0.49",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/express-session": "^1.17.10",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"better-sqlite3": "^9.2.2",
|
||||
"copyfiles": "^2.4.1",
|
||||
"prettier": "^3.1.1",
|
||||
"workspace": "^0.0.1-preview.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
export interface SessionConfig {
|
||||
secret: string
|
||||
appOriginUrl: string
|
||||
trustProxy: boolean
|
||||
domain: string
|
||||
}
|
||||
|
||||
export interface ServerConfig {
|
||||
host: string
|
||||
port: number
|
||||
gqlPath?: string
|
||||
sessionSecret: string
|
||||
appOriginUrl: string
|
||||
isProduction: boolean
|
||||
session: SessionConfig
|
||||
}
|
||||
|
||||
export interface DatabaseConfig {
|
||||
dbPath: string
|
||||
}
|
||||
|
||||
export interface GitHubConfig {
|
||||
webhookUrl: string
|
||||
oAuth: {
|
||||
clientId: string
|
||||
clientSecret: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface RegistryConfig {
|
||||
restEndpoint: string
|
||||
gqlEndpoint: string
|
||||
chainId: string
|
||||
privateKey: string
|
||||
bondId: string
|
||||
fetchDeploymentRecordDelay: number
|
||||
checkAuctionStatusDelay: number
|
||||
authority: string
|
||||
fee: {
|
||||
gas: string
|
||||
fees: string
|
||||
gasPrice: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface AuctionConfig {
|
||||
commitFee: string
|
||||
commitsDuration: string
|
||||
revealFee: string
|
||||
revealsDuration: string
|
||||
denom: string
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
server: ServerConfig
|
||||
database: DatabaseConfig
|
||||
gitHub: GitHubConfig
|
||||
registryConfig: RegistryConfig
|
||||
auction: AuctionConfig
|
||||
turnkey: {
|
||||
apiBaseUrl: string
|
||||
apiPublicKey: string
|
||||
apiPrivateKey: string
|
||||
defaultOrganizationId: string
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import process from 'node:process'
|
||||
|
||||
export const DEFAULT_CONFIG_FILE_PATH =
|
||||
process.env.SNOWBALL_BACKEND_CONFIG_FILE_PATH ||
|
||||
'apps/backend/environments/local.toml'
|
||||
|
||||
export const DEFAULT_GQL_PATH = '/graphql'
|
||||
@@ -0,0 +1,694 @@
|
||||
import assert from 'node:assert'
|
||||
import path from 'node:path'
|
||||
import debug from 'debug'
|
||||
import { customAlphabet } from 'nanoid'
|
||||
import { lowercase, numbers } from 'nanoid-dictionary'
|
||||
import {
|
||||
DataSource,
|
||||
type DeepPartial,
|
||||
type FindManyOptions,
|
||||
type FindOneOptions,
|
||||
type FindOptionsWhere,
|
||||
IsNull,
|
||||
Not
|
||||
} from 'typeorm'
|
||||
|
||||
import type { DatabaseConfig } from './config'
|
||||
import { Deployer } from './entity/Deployer'
|
||||
import { Deployment, DeploymentStatus } from './entity/Deployment'
|
||||
import { Domain } from './entity/Domain'
|
||||
import { EnvironmentVariable } from './entity/EnvironmentVariable'
|
||||
import { Organization } from './entity/Organization'
|
||||
import { Project } from './entity/Project'
|
||||
import { ProjectMember } from './entity/ProjectMember'
|
||||
import { User } from './entity/User'
|
||||
import { UserOrganization } from './entity/UserOrganization'
|
||||
import type { DNSRecordAttributes } from './types'
|
||||
import { getEntities, loadAndSaveData } from './utils'
|
||||
|
||||
const ORGANIZATION_DATA_PATH = '../test/fixtures/organizations.json'
|
||||
|
||||
const log = debug('snowball:database')
|
||||
|
||||
const nanoid = customAlphabet(lowercase + numbers, 8)
|
||||
|
||||
// TODO: Fix order of methods
|
||||
export class Database {
|
||||
private dataSource: DataSource
|
||||
|
||||
constructor({ dbPath }: DatabaseConfig) {
|
||||
this.dataSource = new DataSource({
|
||||
type: 'better-sqlite3',
|
||||
database: dbPath,
|
||||
entities: [path.join(__dirname, '/entity/*')],
|
||||
synchronize: true,
|
||||
logging: false
|
||||
})
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
await this.dataSource.initialize()
|
||||
log('database initialized')
|
||||
|
||||
let organizations = await this.getOrganizations({})
|
||||
|
||||
// Load an organization if none exist
|
||||
if (!organizations.length) {
|
||||
const orgEntities = await getEntities(
|
||||
path.resolve(__dirname, ORGANIZATION_DATA_PATH)
|
||||
)
|
||||
organizations = await loadAndSaveData(Organization, this.dataSource, [
|
||||
orgEntities[0]
|
||||
])
|
||||
}
|
||||
|
||||
// Hotfix for updating old DB data
|
||||
if (organizations[0].slug === 'snowball-tools-1') {
|
||||
const [orgEntity] = await getEntities(
|
||||
path.resolve(__dirname, ORGANIZATION_DATA_PATH)
|
||||
)
|
||||
|
||||
await this.updateOrganization(organizations[0].id, {
|
||||
slug: orgEntity.slug as string,
|
||||
name: orgEntity.name as string
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async getUser(options: FindOneOptions<User>): Promise<User | null> {
|
||||
const userRepository = this.dataSource.getRepository(User)
|
||||
const user = await userRepository.findOne(options)
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
async addUser(data: DeepPartial<User>): Promise<User> {
|
||||
const userRepository = this.dataSource.getRepository(User)
|
||||
const user = await userRepository.save(data)
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
async updateUser(user: User, data: DeepPartial<User>): Promise<boolean> {
|
||||
const userRepository = this.dataSource.getRepository(User)
|
||||
const updateResult = await userRepository.update({ id: user.id }, data)
|
||||
assert(updateResult.affected)
|
||||
|
||||
return updateResult.affected > 0
|
||||
}
|
||||
|
||||
async getOrganizations(
|
||||
options: FindManyOptions<Organization>
|
||||
): Promise<Organization[]> {
|
||||
const organizationRepository = this.dataSource.getRepository(Organization)
|
||||
const organizations = await organizationRepository.find(options)
|
||||
|
||||
return organizations
|
||||
}
|
||||
|
||||
async getOrganization(
|
||||
options: FindOneOptions<Organization>
|
||||
): Promise<Organization | null> {
|
||||
const organizationRepository = this.dataSource.getRepository(Organization)
|
||||
const organization = await organizationRepository.findOne(options)
|
||||
|
||||
return organization
|
||||
}
|
||||
|
||||
async getOrganizationsByUserId(userId: string): Promise<Organization[]> {
|
||||
const organizationRepository = this.dataSource.getRepository(Organization)
|
||||
|
||||
const userOrgs = await organizationRepository.find({
|
||||
where: {
|
||||
userOrganizations: {
|
||||
member: {
|
||||
id: userId
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return userOrgs
|
||||
}
|
||||
|
||||
async addUserOrganization(
|
||||
data: DeepPartial<UserOrganization>
|
||||
): Promise<UserOrganization> {
|
||||
const userOrganizationRepository =
|
||||
this.dataSource.getRepository(UserOrganization)
|
||||
const newUserOrganization = await userOrganizationRepository.save(data)
|
||||
|
||||
return newUserOrganization
|
||||
}
|
||||
|
||||
async updateOrganization(
|
||||
organizationId: string,
|
||||
data: DeepPartial<Organization>
|
||||
): Promise<boolean> {
|
||||
const organizationRepository = this.dataSource.getRepository(Organization)
|
||||
const updateResult = await organizationRepository.update(
|
||||
{ id: organizationId },
|
||||
data
|
||||
)
|
||||
assert(updateResult.affected)
|
||||
|
||||
return updateResult.affected > 0
|
||||
}
|
||||
|
||||
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> {
|
||||
const projectRepository = this.dataSource.getRepository(Project)
|
||||
|
||||
const project = await projectRepository
|
||||
.createQueryBuilder('project')
|
||||
.leftJoinAndSelect(
|
||||
'project.deployments',
|
||||
'deployments',
|
||||
'deployments.isCurrent = true AND deployments.isCanonical = true'
|
||||
)
|
||||
.leftJoinAndSelect('deployments.createdBy', 'user')
|
||||
.leftJoinAndSelect('deployments.deployer', 'deployer')
|
||||
.leftJoinAndSelect('project.owner', 'owner')
|
||||
.leftJoinAndSelect('project.deployers', 'deployers')
|
||||
.leftJoinAndSelect('project.organization', 'organization')
|
||||
.where('project.id = :projectId', {
|
||||
projectId
|
||||
})
|
||||
.getOne()
|
||||
|
||||
return project
|
||||
}
|
||||
|
||||
async allProjectsWithoutDeployments(): Promise<Project[]> {
|
||||
const allProjects = await this.getProjects({
|
||||
where: {
|
||||
auctionId: Not(IsNull())
|
||||
},
|
||||
relations: ['deployments'],
|
||||
withDeleted: true
|
||||
})
|
||||
|
||||
const projects = allProjects.filter((project) => {
|
||||
if (project.deletedAt !== null) return false
|
||||
|
||||
return project.deployments.length === 0
|
||||
})
|
||||
|
||||
return projects
|
||||
}
|
||||
|
||||
async getProjectsInOrganization(
|
||||
userId: string,
|
||||
organizationSlug: string
|
||||
): Promise<Project[]> {
|
||||
const projectRepository = this.dataSource.getRepository(Project)
|
||||
|
||||
const projects = await projectRepository
|
||||
.createQueryBuilder('project')
|
||||
.leftJoinAndSelect(
|
||||
'project.deployments',
|
||||
'deployments',
|
||||
'deployments.isCurrent = true AND deployments.isCanonical = true'
|
||||
)
|
||||
.leftJoin('project.projectMembers', 'projectMembers')
|
||||
.leftJoin('project.organization', 'organization')
|
||||
.where(
|
||||
'(project.ownerId = :userId OR projectMembers.userId = :userId) AND organization.slug = :organizationSlug',
|
||||
{
|
||||
userId,
|
||||
organizationSlug
|
||||
}
|
||||
)
|
||||
.getMany()
|
||||
|
||||
return projects
|
||||
}
|
||||
|
||||
/**
|
||||
* Get deployments with specified filter
|
||||
*/
|
||||
async getDeployments(
|
||||
options: FindManyOptions<Deployment>
|
||||
): Promise<Deployment[]> {
|
||||
const deploymentRepository = this.dataSource.getRepository(Deployment)
|
||||
const deployments = await deploymentRepository.find(options)
|
||||
|
||||
return deployments
|
||||
}
|
||||
|
||||
async getDeploymentsByProjectId(projectId: string): Promise<Deployment[]> {
|
||||
return this.getDeployments({
|
||||
relations: {
|
||||
project: true,
|
||||
createdBy: true,
|
||||
deployer: true
|
||||
},
|
||||
where: {
|
||||
project: {
|
||||
id: projectId
|
||||
}
|
||||
},
|
||||
order: {
|
||||
createdAt: 'DESC'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async getNonCanonicalDeploymentsByProjectId(
|
||||
projectId: string
|
||||
): Promise<Deployment[]> {
|
||||
return this.getDeployments({
|
||||
relations: {
|
||||
project: true,
|
||||
createdBy: true,
|
||||
deployer: true
|
||||
},
|
||||
where: {
|
||||
project: {
|
||||
id: projectId
|
||||
},
|
||||
isCanonical: false
|
||||
},
|
||||
order: {
|
||||
createdAt: 'DESC'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async getDeployment(
|
||||
options: FindOneOptions<Deployment>
|
||||
): Promise<Deployment | null> {
|
||||
const deploymentRepository = this.dataSource.getRepository(Deployment)
|
||||
const deployment = await deploymentRepository.findOne(options)
|
||||
|
||||
return deployment
|
||||
}
|
||||
|
||||
async getDomains(options: FindManyOptions<Domain>): Promise<Domain[]> {
|
||||
const domainRepository = this.dataSource.getRepository(Domain)
|
||||
const domains = await domainRepository.find(options)
|
||||
|
||||
return domains
|
||||
}
|
||||
|
||||
async addDeployment(data: DeepPartial<Deployment>): Promise<Deployment> {
|
||||
const deploymentRepository = this.dataSource.getRepository(Deployment)
|
||||
|
||||
const id = nanoid()
|
||||
|
||||
const updatedData = {
|
||||
...data,
|
||||
id
|
||||
}
|
||||
const deployment = await deploymentRepository.save(updatedData)
|
||||
|
||||
return deployment
|
||||
}
|
||||
|
||||
async getProjectMembersByProjectId(
|
||||
projectId: string
|
||||
): Promise<ProjectMember[]> {
|
||||
const projectMemberRepository = this.dataSource.getRepository(ProjectMember)
|
||||
|
||||
const projectMembers = await projectMemberRepository.find({
|
||||
relations: {
|
||||
project: true,
|
||||
member: true
|
||||
},
|
||||
where: {
|
||||
project: {
|
||||
id: projectId
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return projectMembers
|
||||
}
|
||||
|
||||
async getEnvironmentVariablesByProjectId(
|
||||
projectId: string,
|
||||
filter?: FindOptionsWhere<EnvironmentVariable>
|
||||
): Promise<EnvironmentVariable[]> {
|
||||
const environmentVariableRepository =
|
||||
this.dataSource.getRepository(EnvironmentVariable)
|
||||
|
||||
const environmentVariables = await environmentVariableRepository.find({
|
||||
where: {
|
||||
project: {
|
||||
id: projectId
|
||||
},
|
||||
...filter
|
||||
}
|
||||
})
|
||||
|
||||
return environmentVariables
|
||||
}
|
||||
|
||||
async removeProjectMemberById(projectMemberId: string): Promise<boolean> {
|
||||
const projectMemberRepository = this.dataSource.getRepository(ProjectMember)
|
||||
|
||||
const deleteResult = await projectMemberRepository.delete({
|
||||
id: projectMemberId
|
||||
})
|
||||
|
||||
if (deleteResult.affected) {
|
||||
return deleteResult.affected > 0
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
async updateProjectMemberById(
|
||||
projectMemberId: string,
|
||||
data: DeepPartial<ProjectMember>
|
||||
): Promise<boolean> {
|
||||
const projectMemberRepository = this.dataSource.getRepository(ProjectMember)
|
||||
const updateResult = await projectMemberRepository.update(
|
||||
{ id: projectMemberId },
|
||||
data
|
||||
)
|
||||
|
||||
return Boolean(updateResult.affected)
|
||||
}
|
||||
|
||||
async addProjectMember(
|
||||
data: DeepPartial<ProjectMember>
|
||||
): Promise<ProjectMember> {
|
||||
const projectMemberRepository = this.dataSource.getRepository(ProjectMember)
|
||||
const newProjectMember = await projectMemberRepository.save(data)
|
||||
|
||||
return newProjectMember
|
||||
}
|
||||
|
||||
async addEnvironmentVariables(
|
||||
data: DeepPartial<EnvironmentVariable>[]
|
||||
): Promise<EnvironmentVariable[]> {
|
||||
const environmentVariableRepository =
|
||||
this.dataSource.getRepository(EnvironmentVariable)
|
||||
const savedEnvironmentVariables =
|
||||
await environmentVariableRepository.save(data)
|
||||
|
||||
return savedEnvironmentVariables
|
||||
}
|
||||
|
||||
async updateEnvironmentVariable(
|
||||
environmentVariableId: string,
|
||||
data: DeepPartial<EnvironmentVariable>
|
||||
): Promise<boolean> {
|
||||
const environmentVariableRepository =
|
||||
this.dataSource.getRepository(EnvironmentVariable)
|
||||
const updateResult = await environmentVariableRepository.update(
|
||||
{ id: environmentVariableId },
|
||||
data
|
||||
)
|
||||
|
||||
return Boolean(updateResult.affected)
|
||||
}
|
||||
|
||||
async deleteEnvironmentVariable(
|
||||
environmentVariableId: string
|
||||
): Promise<boolean> {
|
||||
const environmentVariableRepository =
|
||||
this.dataSource.getRepository(EnvironmentVariable)
|
||||
const deleteResult = await environmentVariableRepository.delete({
|
||||
id: environmentVariableId
|
||||
})
|
||||
|
||||
if (deleteResult.affected) {
|
||||
return deleteResult.affected > 0
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
async getProjectMemberById(projectMemberId: string): Promise<ProjectMember> {
|
||||
const projectMemberRepository = this.dataSource.getRepository(ProjectMember)
|
||||
|
||||
const projectMemberWithProject = await projectMemberRepository.find({
|
||||
relations: {
|
||||
project: {
|
||||
owner: true
|
||||
},
|
||||
member: true
|
||||
},
|
||||
where: {
|
||||
id: projectMemberId
|
||||
}
|
||||
})
|
||||
|
||||
if (projectMemberWithProject.length === 0) {
|
||||
throw new Error('Member does not exist')
|
||||
}
|
||||
|
||||
return projectMemberWithProject[0]
|
||||
}
|
||||
|
||||
async getProjectsBySearchText(
|
||||
userId: string,
|
||||
searchText: string
|
||||
): Promise<Project[]> {
|
||||
const projectRepository = this.dataSource.getRepository(Project)
|
||||
|
||||
const projects = await projectRepository
|
||||
.createQueryBuilder('project')
|
||||
.leftJoinAndSelect('project.organization', 'organization')
|
||||
.leftJoin('project.projectMembers', 'projectMembers')
|
||||
.where(
|
||||
'(project.owner = :userId OR projectMembers.member.id = :userId) AND project.name LIKE :searchText',
|
||||
{
|
||||
userId,
|
||||
searchText: `%${searchText}%`
|
||||
}
|
||||
)
|
||||
.getMany()
|
||||
|
||||
return projects
|
||||
}
|
||||
|
||||
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 updateResult = await deploymentRepository.update(criteria, data)
|
||||
|
||||
return Boolean(updateResult.affected)
|
||||
}
|
||||
|
||||
async updateDeploymentsByProjectIds(
|
||||
projectIds: string[],
|
||||
data: DeepPartial<Deployment>
|
||||
): Promise<boolean> {
|
||||
const deploymentRepository = this.dataSource.getRepository(Deployment)
|
||||
|
||||
const updateResult = await deploymentRepository
|
||||
.createQueryBuilder()
|
||||
.update(Deployment)
|
||||
.set(data)
|
||||
.where('projectId IN (:...projectIds)', { projectIds })
|
||||
.execute()
|
||||
|
||||
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> {
|
||||
const projectRepository = this.dataSource.getRepository(Project)
|
||||
|
||||
// TODO: Check if organization exists
|
||||
const newProject = projectRepository.create(data)
|
||||
// TODO: Set default empty array for webhooks in TypeORM
|
||||
newProject.webhooks = []
|
||||
// TODO: Set icon according to framework
|
||||
newProject.icon = ''
|
||||
|
||||
newProject.owner = user
|
||||
|
||||
newProject.organization = Object.assign(new Organization(), {
|
||||
id: organizationId
|
||||
})
|
||||
|
||||
return projectRepository.save(newProject)
|
||||
}
|
||||
|
||||
async saveProject(project: Project): Promise<Project> {
|
||||
const projectRepository = this.dataSource.getRepository(Project)
|
||||
|
||||
return projectRepository.save(project)
|
||||
}
|
||||
|
||||
async updateProjectById(
|
||||
projectId: string,
|
||||
data: DeepPartial<Project>
|
||||
): Promise<boolean> {
|
||||
const projectRepository = this.dataSource.getRepository(Project)
|
||||
const updateResult = await projectRepository.update({ id: projectId }, data)
|
||||
|
||||
return Boolean(updateResult.affected)
|
||||
}
|
||||
|
||||
async deleteProjectById(projectId: string): Promise<boolean> {
|
||||
const projectRepository = this.dataSource.getRepository(Project)
|
||||
const project = await projectRepository.findOneOrFail({
|
||||
where: {
|
||||
id: projectId
|
||||
},
|
||||
relations: {
|
||||
projectMembers: true
|
||||
}
|
||||
})
|
||||
|
||||
const deleteResult = await projectRepository.softRemove(project)
|
||||
|
||||
return Boolean(deleteResult)
|
||||
}
|
||||
|
||||
async deleteDomainById(domainId: string): Promise<boolean> {
|
||||
const domainRepository = this.dataSource.getRepository(Domain)
|
||||
|
||||
const deleteResult = await domainRepository.softDelete({ id: domainId })
|
||||
|
||||
if (deleteResult.affected) {
|
||||
return deleteResult.affected > 0
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
async addDomain(data: DeepPartial<Domain>): Promise<Domain> {
|
||||
const domainRepository = this.dataSource.getRepository(Domain)
|
||||
const newDomain = await domainRepository.save(data)
|
||||
|
||||
return newDomain
|
||||
}
|
||||
|
||||
async getDomain(options: FindOneOptions<Domain>): Promise<Domain | null> {
|
||||
const domainRepository = this.dataSource.getRepository(Domain)
|
||||
const domain = await domainRepository.findOne(options)
|
||||
|
||||
return domain
|
||||
}
|
||||
|
||||
async updateDomainById(
|
||||
domainId: string,
|
||||
data: DeepPartial<Domain>
|
||||
): Promise<boolean> {
|
||||
const domainRepository = this.dataSource.getRepository(Domain)
|
||||
const updateResult = await domainRepository.update({ id: domainId }, data)
|
||||
|
||||
return Boolean(updateResult.affected)
|
||||
}
|
||||
|
||||
async getDomainsByProjectId(
|
||||
projectId: string,
|
||||
filter?: FindOptionsWhere<Domain>
|
||||
): Promise<Domain[]> {
|
||||
const domainRepository = this.dataSource.getRepository(Domain)
|
||||
|
||||
const domains = await domainRepository.find({
|
||||
relations: {
|
||||
redirectTo: true
|
||||
},
|
||||
where: {
|
||||
project: {
|
||||
id: projectId
|
||||
},
|
||||
...filter
|
||||
}
|
||||
})
|
||||
|
||||
return domains
|
||||
}
|
||||
|
||||
async getOldestDomainByProjectId(projectId: string): Promise<Domain | null> {
|
||||
const domainRepository = this.dataSource.getRepository(Domain)
|
||||
|
||||
const domain = await domainRepository.findOne({
|
||||
where: {
|
||||
project: {
|
||||
id: projectId
|
||||
}
|
||||
},
|
||||
order: {
|
||||
createdAt: 'ASC'
|
||||
}
|
||||
})
|
||||
|
||||
return domain
|
||||
}
|
||||
|
||||
async getLatestDNSRecordByProjectId(
|
||||
projectId: string
|
||||
): Promise<DNSRecordAttributes | null> {
|
||||
const deploymentRepository = this.dataSource.getRepository(Deployment)
|
||||
|
||||
const deployment = await deploymentRepository.findOne({
|
||||
where: {
|
||||
project: {
|
||||
id: projectId
|
||||
},
|
||||
status: DeploymentStatus.Ready
|
||||
},
|
||||
order: {
|
||||
createdAt: 'DESC'
|
||||
}
|
||||
})
|
||||
|
||||
if (deployment === null) {
|
||||
throw new Error(`No deployment found for project ${projectId}`)
|
||||
}
|
||||
|
||||
return deployment.dnsRecordData
|
||||
}
|
||||
|
||||
async addDeployer(data: DeepPartial<Deployer>): Promise<Deployer> {
|
||||
const deployerRepository = this.dataSource.getRepository(Deployer)
|
||||
const newDomain = await deployerRepository.save(data)
|
||||
|
||||
return newDomain
|
||||
}
|
||||
|
||||
async getDeployers(): Promise<Deployer[]> {
|
||||
const deployerRepository = this.dataSource.getRepository(Deployer)
|
||||
const deployers = await deployerRepository.find()
|
||||
return deployers
|
||||
}
|
||||
|
||||
async getDeployerByLRN(deployerLrn: string): Promise<Deployer | null> {
|
||||
const deployerRepository = this.dataSource.getRepository(Deployer)
|
||||
const deployer = await deployerRepository.findOne({
|
||||
where: { deployerLrn }
|
||||
})
|
||||
|
||||
return deployer
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Column, Entity, ManyToMany, PrimaryColumn } from 'typeorm'
|
||||
import { Project } from './Project'
|
||||
|
||||
@Entity()
|
||||
export class Deployer {
|
||||
@PrimaryColumn('varchar')
|
||||
deployerLrn!: string
|
||||
|
||||
@Column('varchar')
|
||||
deployerId!: string
|
||||
|
||||
@Column('varchar')
|
||||
deployerApiUrl!: string
|
||||
|
||||
@Column('varchar')
|
||||
baseDomain!: string
|
||||
|
||||
@Column('varchar', { nullable: true })
|
||||
publicKey!: string | null
|
||||
|
||||
@Column('varchar', { nullable: true })
|
||||
minimumPayment!: string | null
|
||||
|
||||
@Column('varchar', { nullable: true })
|
||||
paymentAddress!: string | null
|
||||
|
||||
@ManyToMany(
|
||||
() => Project,
|
||||
(project) => project.deployers
|
||||
)
|
||||
projects!: Project[]
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
DeleteDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryColumn,
|
||||
UpdateDateColumn
|
||||
} from 'typeorm'
|
||||
|
||||
import type {
|
||||
AppDeploymentRecordAttributes,
|
||||
AppDeploymentRemovalRecordAttributes,
|
||||
DNSRecordAttributes
|
||||
} from '../types'
|
||||
import { Deployer } from './Deployer'
|
||||
import { Project } from './Project'
|
||||
import { User } from './User'
|
||||
|
||||
export enum Environment {
|
||||
Production = 'Production',
|
||||
Preview = 'Preview',
|
||||
Development = 'Development'
|
||||
}
|
||||
|
||||
export enum DeploymentStatus {
|
||||
Building = 'Building',
|
||||
Ready = 'Ready',
|
||||
Error = 'Error',
|
||||
Deleting = 'Deleting'
|
||||
}
|
||||
|
||||
export interface ApplicationDeploymentRequest {
|
||||
type: string
|
||||
version: string
|
||||
name: string
|
||||
application: string
|
||||
lrn?: string
|
||||
auction?: string
|
||||
config: string
|
||||
meta: string
|
||||
payment?: string
|
||||
dns?: string
|
||||
}
|
||||
|
||||
export interface ApplicationDeploymentRemovalRequest {
|
||||
type: string
|
||||
version: string
|
||||
deployment: string
|
||||
auction?: string
|
||||
payment?: string
|
||||
}
|
||||
|
||||
export interface ApplicationRecord {
|
||||
type: string
|
||||
version: string
|
||||
name: string
|
||||
description?: string
|
||||
homepage?: string
|
||||
license?: string
|
||||
author?: string
|
||||
repository?: string[]
|
||||
app_version?: string
|
||||
repository_ref: string
|
||||
app_type: string
|
||||
}
|
||||
|
||||
@Entity()
|
||||
export class Deployment {
|
||||
// TODO: set custom generated id
|
||||
@PrimaryColumn('varchar')
|
||||
id!: string
|
||||
|
||||
@Column()
|
||||
projectId!: string
|
||||
|
||||
@ManyToOne(() => Project, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'projectId' })
|
||||
project!: Project
|
||||
|
||||
@Column('varchar')
|
||||
branch!: string
|
||||
|
||||
@Column('varchar')
|
||||
commitHash!: string
|
||||
|
||||
@Column('varchar')
|
||||
commitMessage!: string
|
||||
|
||||
@Column('varchar', { nullable: true })
|
||||
url!: string | null
|
||||
|
||||
@Column('varchar')
|
||||
applicationRecordId!: string
|
||||
|
||||
@Column('simple-json')
|
||||
applicationRecordData!: ApplicationRecord
|
||||
|
||||
@Column('varchar', { nullable: true })
|
||||
applicationDeploymentRequestId!: string | null
|
||||
|
||||
@Column('simple-json', { nullable: true })
|
||||
applicationDeploymentRequestData!: ApplicationDeploymentRequest | null
|
||||
|
||||
@Column('varchar', { nullable: true })
|
||||
applicationDeploymentRecordId!: string | null
|
||||
|
||||
@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('simple-json', { nullable: true })
|
||||
dnsRecordData!: DNSRecordAttributes | null
|
||||
|
||||
@ManyToOne(() => Deployer)
|
||||
@JoinColumn({ name: 'deployerLrn' })
|
||||
deployer!: Deployer
|
||||
|
||||
@Column({
|
||||
enum: Environment
|
||||
})
|
||||
environment!: Environment
|
||||
|
||||
@Column('boolean', { default: false })
|
||||
isCurrent!: boolean
|
||||
|
||||
@Column('boolean', { default: false })
|
||||
isCanonical!: boolean
|
||||
|
||||
@Column({
|
||||
enum: DeploymentStatus
|
||||
})
|
||||
status!: DeploymentStatus
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'createdBy' })
|
||||
createdBy!: User
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Date
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt!: Date
|
||||
|
||||
@DeleteDateColumn()
|
||||
deletedAt!: Date | null
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
DeleteDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn
|
||||
} from 'typeorm'
|
||||
|
||||
import { Project } from './Project'
|
||||
|
||||
export enum Status {
|
||||
Live = 'Live',
|
||||
Pending = 'Pending'
|
||||
}
|
||||
|
||||
@Entity()
|
||||
export class Domain {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string
|
||||
|
||||
@Column('varchar')
|
||||
projectId!: string
|
||||
|
||||
@ManyToOne(() => Project, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'projectId' })
|
||||
project!: Project
|
||||
|
||||
@Column('varchar', { length: 255, default: 'main' })
|
||||
branch!: string
|
||||
|
||||
@Column('varchar', { length: 255 })
|
||||
name!: string
|
||||
|
||||
@Column('string', { nullable: true })
|
||||
redirectToId!: string | null
|
||||
|
||||
@ManyToOne(() => Domain)
|
||||
@JoinColumn({ name: 'redirectToId' })
|
||||
// eslint-disable-next-line no-use-before-define
|
||||
redirectTo!: Domain | null
|
||||
|
||||
@Column({
|
||||
enum: Status,
|
||||
default: Status.Pending
|
||||
})
|
||||
status!: Status
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Date
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt!: Date
|
||||
|
||||
@DeleteDateColumn()
|
||||
deletedAt!: Date | null
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn
|
||||
} from 'typeorm'
|
||||
|
||||
import { Project } from './Project'
|
||||
|
||||
enum Environment {
|
||||
Production = 'Production',
|
||||
Preview = 'Preview',
|
||||
Development = 'Development'
|
||||
}
|
||||
|
||||
@Entity()
|
||||
export class EnvironmentVariable {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string
|
||||
|
||||
@ManyToOne(() => Project, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'projectId' })
|
||||
project!: Project
|
||||
|
||||
@Column({
|
||||
enum: Environment
|
||||
})
|
||||
environment!: Environment
|
||||
|
||||
@Column('varchar')
|
||||
key!: string
|
||||
|
||||
@Column('varchar')
|
||||
value!: string
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Date
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt!: Date
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
OneToMany,
|
||||
PrimaryGeneratedColumn,
|
||||
Unique,
|
||||
UpdateDateColumn
|
||||
} from 'typeorm'
|
||||
import { UserOrganization } from './UserOrganization'
|
||||
|
||||
@Entity()
|
||||
@Unique(['slug'])
|
||||
export class Organization {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string
|
||||
|
||||
@Column('varchar', { length: 255 })
|
||||
name!: string
|
||||
|
||||
@Column('varchar')
|
||||
slug!: string
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Date
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt!: Date
|
||||
|
||||
@OneToMany(
|
||||
() => UserOrganization,
|
||||
(userOrganization) => userOrganization.organization,
|
||||
{
|
||||
cascade: ['soft-remove']
|
||||
}
|
||||
)
|
||||
userOrganizations!: UserOrganization[]
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
DeleteDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
JoinTable,
|
||||
ManyToMany,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn
|
||||
} from 'typeorm'
|
||||
|
||||
import { Deployer } from './Deployer'
|
||||
import { Deployment } from './Deployment'
|
||||
import { Organization } from './Organization'
|
||||
import { ProjectMember } from './ProjectMember'
|
||||
import { User } from './User'
|
||||
|
||||
@Entity()
|
||||
export class Project {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'ownerId' })
|
||||
owner!: User
|
||||
|
||||
@Column({ nullable: false })
|
||||
ownerId!: string
|
||||
|
||||
@ManyToOne(() => Organization, { nullable: true })
|
||||
@JoinColumn({ name: 'organizationId' })
|
||||
organization!: Organization | null
|
||||
|
||||
@Column('varchar')
|
||||
organizationId!: string
|
||||
|
||||
@Column('varchar')
|
||||
name!: string
|
||||
|
||||
@Column('varchar')
|
||||
repository!: string
|
||||
|
||||
@Column('varchar', { length: 255, default: 'main' })
|
||||
prodBranch!: string
|
||||
|
||||
@Column('text', { default: '' })
|
||||
description!: string
|
||||
|
||||
@Column('varchar', { nullable: true })
|
||||
auctionId!: string | null
|
||||
|
||||
// Tx hash for sending coins from snowball to deployer
|
||||
@Column('varchar', { nullable: true })
|
||||
txHash!: string | null
|
||||
|
||||
@ManyToMany(
|
||||
() => Deployer,
|
||||
(deployer) => deployer.projects
|
||||
)
|
||||
@JoinTable()
|
||||
deployers!: Deployer[]
|
||||
|
||||
@Column('boolean', { default: false, nullable: true })
|
||||
fundsReleased!: boolean
|
||||
|
||||
// TODO: Compute template & framework in import repository
|
||||
@Column('varchar', { nullable: true })
|
||||
template!: string | null
|
||||
|
||||
@Column('varchar', { nullable: true })
|
||||
framework!: string | null
|
||||
|
||||
// Address of the user who created the project i.e. requested deployments
|
||||
@Column('varchar')
|
||||
paymentAddress!: string
|
||||
|
||||
@Column({
|
||||
type: 'simple-array'
|
||||
})
|
||||
webhooks!: string[]
|
||||
|
||||
@Column('varchar')
|
||||
icon!: string
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Date
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt!: Date
|
||||
|
||||
@DeleteDateColumn()
|
||||
deletedAt!: Date | null
|
||||
|
||||
@OneToMany(
|
||||
() => Deployment,
|
||||
(deployment) => deployment.project
|
||||
)
|
||||
deployments!: Deployment[]
|
||||
|
||||
@OneToMany(
|
||||
() => ProjectMember,
|
||||
(projectMember) => projectMember.project,
|
||||
{
|
||||
cascade: ['soft-remove']
|
||||
}
|
||||
)
|
||||
projectMembers!: ProjectMember[]
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
DeleteDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
Unique,
|
||||
UpdateDateColumn
|
||||
} from 'typeorm'
|
||||
|
||||
import { Project } from './Project'
|
||||
import { User } from './User'
|
||||
|
||||
export enum Permission {
|
||||
View = 'View',
|
||||
Edit = 'Edit'
|
||||
}
|
||||
|
||||
@Entity()
|
||||
@Unique(['project', 'member'])
|
||||
export class ProjectMember {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string
|
||||
|
||||
@ManyToOne(
|
||||
() => User,
|
||||
(user) => user.projectMembers
|
||||
)
|
||||
@JoinColumn({ name: 'userId' })
|
||||
member!: User
|
||||
|
||||
@ManyToOne(
|
||||
() => Project,
|
||||
(project) => project.projectMembers
|
||||
)
|
||||
@JoinColumn({ name: 'projectId' })
|
||||
project!: Project
|
||||
|
||||
@Column({
|
||||
type: 'simple-array'
|
||||
})
|
||||
permissions!: Permission[]
|
||||
|
||||
@Column('boolean', { default: false })
|
||||
isPending!: boolean
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Date
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt!: Date
|
||||
|
||||
@DeleteDateColumn()
|
||||
deletedAt!: Date | null
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
OneToMany,
|
||||
PrimaryGeneratedColumn,
|
||||
Unique
|
||||
} from 'typeorm'
|
||||
|
||||
import { ProjectMember } from './ProjectMember'
|
||||
import { UserOrganization } from './UserOrganization'
|
||||
|
||||
@Entity()
|
||||
@Unique(['email'])
|
||||
@Unique(['ethAddress'])
|
||||
export class User {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string
|
||||
|
||||
// TODO: Set ethAddress as ID
|
||||
@Column()
|
||||
ethAddress!: string
|
||||
|
||||
@Column('varchar', { length: 255, nullable: true })
|
||||
name!: string | null
|
||||
|
||||
@Column()
|
||||
email!: string
|
||||
|
||||
@Column('varchar', { nullable: true })
|
||||
gitHubToken!: string | null
|
||||
|
||||
@Column('boolean', { default: false })
|
||||
isVerified!: boolean
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Date
|
||||
|
||||
@CreateDateColumn()
|
||||
updatedAt!: Date
|
||||
|
||||
@Column()
|
||||
subOrgId!: string
|
||||
|
||||
@Column()
|
||||
turnkeyWalletId!: string
|
||||
|
||||
@OneToMany(
|
||||
() => ProjectMember,
|
||||
(projectMember) => projectMember.project,
|
||||
{
|
||||
cascade: ['soft-remove']
|
||||
}
|
||||
)
|
||||
projectMembers!: ProjectMember[]
|
||||
|
||||
@OneToMany(
|
||||
() => UserOrganization,
|
||||
(UserOrganization) => UserOrganization.member,
|
||||
{
|
||||
cascade: ['soft-remove']
|
||||
}
|
||||
)
|
||||
userOrganizations!: UserOrganization[]
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
DeleteDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn
|
||||
} from 'typeorm'
|
||||
|
||||
import { Organization } from './Organization'
|
||||
import { User } from './User'
|
||||
|
||||
export enum Role {
|
||||
Owner = 'Owner',
|
||||
Maintainer = 'Maintainer',
|
||||
Reader = 'Reader'
|
||||
}
|
||||
|
||||
@Entity()
|
||||
export class UserOrganization {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'userId' })
|
||||
member!: User
|
||||
|
||||
@ManyToOne(() => Organization)
|
||||
@JoinColumn({ name: 'organizationId' })
|
||||
organization!: Organization
|
||||
|
||||
@Column({
|
||||
enum: Role
|
||||
})
|
||||
role!: Role
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Date
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt!: Date
|
||||
|
||||
@DeleteDateColumn()
|
||||
deletedAt!: Date | null
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'express-async-errors'
|
||||
import 'reflect-metadata'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import debug from 'debug'
|
||||
|
||||
import { OAuthApp } from '@octokit/oauth-app'
|
||||
|
||||
import { Database } from './database'
|
||||
import { Registry } from './registry'
|
||||
import { createResolvers } from './resolvers'
|
||||
import { createAndStartServer } from './server'
|
||||
import { Service } from './service'
|
||||
import { getConfig } from './utils'
|
||||
|
||||
const log = debug('snowball:server')
|
||||
const OAUTH_CLIENT_TYPE = 'oauth-app'
|
||||
|
||||
export const main = async (): Promise<void> => {
|
||||
const { server, database, gitHub, registryConfig } = await getConfig()
|
||||
|
||||
const app = new OAuthApp({
|
||||
clientType: OAUTH_CLIENT_TYPE,
|
||||
clientId: gitHub.oAuth.clientId,
|
||||
clientSecret: gitHub.oAuth.clientSecret
|
||||
})
|
||||
|
||||
const db = new Database(database)
|
||||
await db.init()
|
||||
|
||||
const registry = new Registry(registryConfig)
|
||||
const service = new Service(
|
||||
{ gitHubConfig: gitHub, registryConfig },
|
||||
db,
|
||||
app,
|
||||
registry
|
||||
)
|
||||
|
||||
const typeDefs = fs
|
||||
.readFileSync(path.join(__dirname, 'schema.gql'))
|
||||
.toString()
|
||||
const resolvers = await createResolvers(service)
|
||||
|
||||
await createAndStartServer(server, typeDefs, resolvers, service)
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => {
|
||||
log('Starting server...')
|
||||
})
|
||||
.catch((err) => {
|
||||
log(err)
|
||||
})
|
||||
@@ -0,0 +1,624 @@
|
||||
import assert from 'node:assert'
|
||||
import debug from 'debug'
|
||||
import { DateTime } from 'luxon'
|
||||
import type { Octokit } from 'octokit'
|
||||
import * as openpgp from 'openpgp'
|
||||
import { inc as semverInc } from 'semver'
|
||||
import type { DeepPartial } from 'typeorm'
|
||||
|
||||
import {
|
||||
Account,
|
||||
DEFAULT_GAS_ESTIMATION_MULTIPLIER,
|
||||
Registry as LaconicRegistry,
|
||||
getGasPrice,
|
||||
parseGasAndFees
|
||||
} from '@cerc-io/registry-sdk'
|
||||
import type { DeliverTxResponse, IndexedTx } from '@cosmjs/stargate'
|
||||
|
||||
import type { RegistryConfig } from './config'
|
||||
import type {
|
||||
ApplicationDeploymentRemovalRequest,
|
||||
ApplicationDeploymentRequest,
|
||||
ApplicationRecord,
|
||||
Deployment
|
||||
} from './entity/Deployment'
|
||||
import type {
|
||||
AppDeploymentRecord,
|
||||
AppDeploymentRemovalRecord,
|
||||
AuctionParams,
|
||||
DeployerRecord,
|
||||
RegistryRecord
|
||||
} from './types'
|
||||
import {
|
||||
getConfig,
|
||||
getRepoDetails,
|
||||
registryTransactionWithRetry,
|
||||
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'
|
||||
const APP_DEPLOYMENT_REMOVAL_RECORD_TYPE = 'ApplicationDeploymentRemovalRecord'
|
||||
const WEBAPP_DEPLOYER_RECORD_TYPE = 'WebappDeployer'
|
||||
const SLEEP_DURATION = 1000
|
||||
|
||||
// TODO: Move registry code to registry-sdk/watcher-ts
|
||||
export class Registry {
|
||||
private registry: LaconicRegistry
|
||||
private 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, gasPrice }
|
||||
)
|
||||
}
|
||||
|
||||
async createApplicationRecord({
|
||||
octokit,
|
||||
repository,
|
||||
commitHash,
|
||||
appType
|
||||
}: {
|
||||
octokit: Octokit
|
||||
repository: string
|
||||
commitHash: string
|
||||
appType: 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
|
||||
const records = await this.registry.queryRecords(
|
||||
{
|
||||
type: APP_RECORD_TYPE,
|
||||
name: packageJSON.name
|
||||
},
|
||||
true
|
||||
)
|
||||
|
||||
// Get next version of record
|
||||
const bondRecords = records.filter(
|
||||
(record: any) => record.bondId === this.registryConfig.bondId
|
||||
)
|
||||
const [latestBondRecord] = bondRecords.sort(
|
||||
(a: any, b: any) =>
|
||||
new Date(b.createTime).getTime() - new Date(a.createTime).getTime()
|
||||
)
|
||||
const nextVersion = semverInc(
|
||||
latestBondRecord?.attributes.version ?? '0.0.0',
|
||||
'patch'
|
||||
)
|
||||
|
||||
assert(nextVersion, 'Application record version not valid')
|
||||
|
||||
// Create record of type ApplicationRecord and publish
|
||||
const applicationRecord = {
|
||||
type: APP_RECORD_TYPE,
|
||||
version: nextVersion,
|
||||
repository_ref: commitHash,
|
||||
repository: [repoUrl],
|
||||
app_type: appType,
|
||||
name: repo,
|
||||
...(packageJSON.description && { description: packageJSON.description }),
|
||||
...(packageJSON.homepage && { homepage: packageJSON.homepage }),
|
||||
...(packageJSON.license && { license: packageJSON.license }),
|
||||
...(packageJSON.author && {
|
||||
author:
|
||||
typeof packageJSON.author === 'object'
|
||||
? JSON.stringify(packageJSON.author)
|
||||
: packageJSON.author
|
||||
}),
|
||||
...(packageJSON.version && { app_version: packageJSON.version })
|
||||
}
|
||||
|
||||
const result = await this.publishRecord(applicationRecord)
|
||||
|
||||
log(`Published application record ${result.id}`)
|
||||
log('Application record data:', applicationRecord)
|
||||
|
||||
// TODO: Discuss computation of LRN
|
||||
const lrn = this.getLrn(repo)
|
||||
log(`Setting name: ${lrn} for record ID: ${result.id}`)
|
||||
|
||||
const fee = parseGasAndFees(
|
||||
this.registryConfig.fee.gas,
|
||||
this.registryConfig.fee.fees
|
||||
)
|
||||
|
||||
await sleep(SLEEP_DURATION)
|
||||
await registryTransactionWithRetry(() =>
|
||||
this.registry.setName(
|
||||
{
|
||||
cid: result.id,
|
||||
lrn
|
||||
},
|
||||
this.registryConfig.privateKey,
|
||||
fee
|
||||
)
|
||||
)
|
||||
|
||||
await sleep(SLEEP_DURATION)
|
||||
await registryTransactionWithRetry(() =>
|
||||
this.registry.setName(
|
||||
{
|
||||
cid: result.id,
|
||||
lrn: `${lrn}@${applicationRecord.app_version}`
|
||||
},
|
||||
this.registryConfig.privateKey,
|
||||
fee
|
||||
)
|
||||
)
|
||||
|
||||
await sleep(SLEEP_DURATION)
|
||||
await registryTransactionWithRetry(() =>
|
||||
this.registry.setName(
|
||||
{
|
||||
cid: result.id,
|
||||
lrn: `${lrn}@${applicationRecord.repository_ref}`
|
||||
},
|
||||
this.registryConfig.privateKey,
|
||||
fee
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
applicationRecordId: result.id,
|
||||
applicationRecordData: applicationRecord
|
||||
}
|
||||
}
|
||||
|
||||
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 registryTransactionWithRetry(() =>
|
||||
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.publishRecord(applicationDeploymentAuction)
|
||||
|
||||
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 | null
|
||||
lrn: string
|
||||
apiUrl: string
|
||||
environmentVariables: { [key: string]: string }
|
||||
dns: string
|
||||
requesterAddress: string
|
||||
publicKey: string
|
||||
payment?: string | null
|
||||
}): Promise<{
|
||||
applicationDeploymentRequestId: string
|
||||
applicationDeploymentRequestData: ApplicationDeploymentRequest
|
||||
}> {
|
||||
const lrn = this.getLrn(data.appName)
|
||||
const records = await this.registry.resolveNames([lrn])
|
||||
const applicationRecord = records[0]
|
||||
|
||||
if (!applicationRecord) {
|
||||
throw new Error(`No record found for ${lrn}`)
|
||||
}
|
||||
|
||||
let hash: string | undefined
|
||||
if (Object.keys(data.environmentVariables).length !== 0) {
|
||||
hash = await this.generateConfigHash(
|
||||
data.environmentVariables,
|
||||
data.requesterAddress,
|
||||
data.publicKey,
|
||||
data.apiUrl
|
||||
)
|
||||
}
|
||||
|
||||
// Create record of type ApplicationDeploymentRequest and publish
|
||||
const applicationDeploymentRequest = {
|
||||
type: APP_DEPLOYMENT_REQUEST_TYPE,
|
||||
version: '1.0.0',
|
||||
name: `${applicationRecord.attributes.name}@${applicationRecord.attributes.app_version}`,
|
||||
application: `${lrn}@${applicationRecord.attributes.app_version}`,
|
||||
dns: data.dns,
|
||||
|
||||
// https://git.vdb.to/cerc-io/laconic-registry-cli/commit/129019105dfb93bebcea02fde0ed64d0f8e5983b
|
||||
config: JSON.stringify(hash ? { ref: hash } : {}),
|
||||
meta: JSON.stringify({
|
||||
note: `Added by Snowball @ ${DateTime.utc().toFormat(
|
||||
"EEE LLL dd HH:mm:ss 'UTC' yyyy"
|
||||
)}`,
|
||||
repository: data.repository,
|
||||
repository_ref: data.deployment.commitHash
|
||||
}),
|
||||
deployer: data.lrn,
|
||||
...(data.auctionId && { auction: data.auctionId }),
|
||||
...(data.payment && { payment: data.payment })
|
||||
}
|
||||
|
||||
await sleep(SLEEP_DURATION)
|
||||
|
||||
const result = await this.publishRecord(applicationDeploymentRequest)
|
||||
|
||||
log(`Application deployment request record published: ${result.id}`)
|
||||
log('Application deployment request data:', applicationDeploymentRequest)
|
||||
|
||||
return {
|
||||
applicationDeploymentRequestId: result.id,
|
||||
applicationDeploymentRequestData: applicationDeploymentRequest
|
||||
}
|
||||
}
|
||||
|
||||
async getAuctionWinningDeployerRecords(
|
||||
auctionId: string
|
||||
): Promise<DeployerRecord[]> {
|
||||
const records = await this.registry.getAuctionsByIds([auctionId])
|
||||
const auctionResult = records[0]
|
||||
|
||||
const deployerRecords = []
|
||||
const { winnerAddresses } = auctionResult
|
||||
|
||||
for (const auctionWinner of winnerAddresses) {
|
||||
const records = await this.getDeployerRecordsByFilter({
|
||||
paymentAddress: auctionWinner
|
||||
})
|
||||
|
||||
const newRecords = records.filter((record) => {
|
||||
return record.names !== null && record.names.length > 0
|
||||
})
|
||||
|
||||
for (const record of newRecords) {
|
||||
if (record.id) {
|
||||
deployerRecords.push(record)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return deployerRecords
|
||||
}
|
||||
|
||||
async releaseDeployerFunds(auctionId: string): Promise<any> {
|
||||
const fee = parseGasAndFees(
|
||||
this.registryConfig.fee.gas,
|
||||
this.registryConfig.fee.fees
|
||||
)
|
||||
const auction = await registryTransactionWithRetry(() =>
|
||||
this.registry.releaseFunds(
|
||||
{
|
||||
auctionId
|
||||
},
|
||||
this.registryConfig.privateKey,
|
||||
fee
|
||||
)
|
||||
)
|
||||
|
||||
return auction
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch ApplicationDeploymentRecords for deployments
|
||||
*/
|
||||
async getDeploymentRecords(
|
||||
deployments: Deployment[]
|
||||
): Promise<AppDeploymentRecord[]> {
|
||||
// Fetch ApplicationDeploymentRecords for corresponding ApplicationRecord set in deployments
|
||||
// TODO: Implement Laconicd GQL query to filter records by multiple values for an attribute
|
||||
const records = await this.registry.queryRecords(
|
||||
{
|
||||
type: APP_DEPLOYMENT_RECORD_TYPE
|
||||
},
|
||||
true
|
||||
)
|
||||
|
||||
// Filter records with ApplicationDeploymentRequestId ID
|
||||
return records.filter((record: AppDeploymentRecord) =>
|
||||
deployments.some(
|
||||
(deployment) =>
|
||||
deployment.applicationDeploymentRequestId ===
|
||||
record.attributes.request
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch WebappDeployer Records by filter
|
||||
*/
|
||||
async getDeployerRecordsByFilter(filter: { [key: string]: any }): Promise<
|
||||
DeployerRecord[]
|
||||
> {
|
||||
return this.registry.queryRecords(
|
||||
{
|
||||
type: WEBAPP_DEPLOYER_RECORD_TYPE,
|
||||
...filter
|
||||
},
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch record by Id
|
||||
*/
|
||||
async getRecordById(id: string): Promise<RegistryRecord | null> {
|
||||
const [record] = await this.registry.getRecordsByIds([id])
|
||||
return record ?? null
|
||||
}
|
||||
|
||||
async createApplicationDeploymentRemovalRequest(data: {
|
||||
deploymentId: string
|
||||
deployerLrn: string
|
||||
auctionId?: string | null
|
||||
payment?: string | null
|
||||
}): Promise<{
|
||||
applicationDeploymentRemovalRequestId: string
|
||||
applicationDeploymentRemovalRequestData: ApplicationDeploymentRemovalRequest
|
||||
}> {
|
||||
const applicationDeploymentRemovalRequest = {
|
||||
type: APP_DEPLOYMENT_REMOVAL_REQUEST_TYPE,
|
||||
version: '1.0.0',
|
||||
deployment: data.deploymentId,
|
||||
deployer: data.deployerLrn,
|
||||
...(data.auctionId && { auction: data.auctionId }),
|
||||
...(data.payment && { payment: data.payment })
|
||||
}
|
||||
|
||||
const result = await this.publishRecord(applicationDeploymentRemovalRequest)
|
||||
|
||||
log(`Application deployment removal request record published: ${result.id}`)
|
||||
log(
|
||||
'Application deployment removal request data:',
|
||||
applicationDeploymentRemovalRequest
|
||||
)
|
||||
|
||||
return {
|
||||
applicationDeploymentRemovalRequestId: result.id,
|
||||
applicationDeploymentRemovalRequestData:
|
||||
applicationDeploymentRemovalRequest
|
||||
}
|
||||
}
|
||||
|
||||
async getCompletedAuctionIds(auctionIds: string[]): Promise<string[]> {
|
||||
if (auctionIds.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const auctions = await this.registry.getAuctionsByIds(auctionIds)
|
||||
|
||||
const completedAuctions = auctions
|
||||
.filter(
|
||||
(auction: { id: string; status: string }) =>
|
||||
auction.status === 'completed'
|
||||
)
|
||||
.map((auction: { id: string; status: string }) => auction.id)
|
||||
|
||||
return completedAuctions
|
||||
}
|
||||
|
||||
async publishRecord(recordData: any): Promise<any> {
|
||||
const fee = parseGasAndFees(
|
||||
this.registryConfig.fee.gas,
|
||||
this.registryConfig.fee.fees
|
||||
)
|
||||
|
||||
const result = await registryTransactionWithRetry(() =>
|
||||
this.registry.setRecord(
|
||||
{
|
||||
privateKey: this.registryConfig.privateKey,
|
||||
record: recordData,
|
||||
bondId: this.registryConfig.bondId
|
||||
},
|
||||
this.registryConfig.privateKey,
|
||||
fee
|
||||
)
|
||||
)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async getRecordsByName(name: string): Promise<any> {
|
||||
return this.registry.resolveNames([name])
|
||||
}
|
||||
|
||||
async getAuctionData(auctionId: string): Promise<any> {
|
||||
return this.registry.getAuctionsByIds([auctionId])
|
||||
}
|
||||
|
||||
async sendTokensToAccount(
|
||||
receiverAddress: string,
|
||||
amount: string
|
||||
): Promise<DeliverTxResponse> {
|
||||
const fee = parseGasAndFees(
|
||||
this.registryConfig.fee.gas,
|
||||
this.registryConfig.fee.fees
|
||||
)
|
||||
const account = await this.getAccount()
|
||||
const laconicClient = await this.registry.getLaconicClient(account)
|
||||
const txResponse: DeliverTxResponse = await registryTransactionWithRetry(
|
||||
() =>
|
||||
laconicClient.sendTokens(
|
||||
account.address,
|
||||
receiverAddress,
|
||||
[
|
||||
{
|
||||
denom: 'alnt',
|
||||
amount
|
||||
}
|
||||
],
|
||||
fee || DEFAULT_GAS_ESTIMATION_MULTIPLIER
|
||||
)
|
||||
)
|
||||
|
||||
return txResponse
|
||||
}
|
||||
|
||||
async getAccount(): Promise<Account> {
|
||||
const account = new Account(
|
||||
Buffer.from(this.registryConfig.privateKey, 'hex')
|
||||
)
|
||||
await account.init()
|
||||
|
||||
return account
|
||||
}
|
||||
|
||||
async getTxResponse(txHash: string): Promise<IndexedTx | null> {
|
||||
const account = await this.getAccount()
|
||||
const laconicClient = await this.registry.getLaconicClient(account)
|
||||
const txResponse: IndexedTx | null = await laconicClient.getTx(txHash)
|
||||
|
||||
return txResponse
|
||||
}
|
||||
|
||||
getLrn(appName: string): string {
|
||||
assert(this.registryConfig.authority, "Authority doesn't exist")
|
||||
return `lrn://${this.registryConfig.authority}/applications/${appName}`
|
||||
}
|
||||
|
||||
async generateConfigHash(
|
||||
environmentVariables: { [key: string]: string },
|
||||
requesterAddress: string,
|
||||
pubKey: string,
|
||||
url: string
|
||||
): Promise<string> {
|
||||
// Config to be encrypted
|
||||
const config = {
|
||||
authorized: [requesterAddress],
|
||||
config: { env: environmentVariables }
|
||||
}
|
||||
|
||||
// Serialize the config
|
||||
const serialized = JSON.stringify(config, null, 2)
|
||||
|
||||
const armoredKey = `-----BEGIN PGP PUBLIC KEY BLOCK-----\n\n${pubKey}\n\n-----END PGP PUBLIC KEY BLOCK-----`
|
||||
const publicKey = await openpgp.readKey({ armoredKey })
|
||||
|
||||
// Encrypt the config
|
||||
const encrypted = await openpgp.encrypt({
|
||||
message: await openpgp.createMessage({ text: serialized }),
|
||||
encryptionKeys: publicKey,
|
||||
format: 'binary'
|
||||
})
|
||||
|
||||
// Get the hash after uploading encrypted config
|
||||
const response = await fetch(`${url}/upload/config`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream'
|
||||
},
|
||||
body: encrypted
|
||||
})
|
||||
|
||||
const configHash = await response.json()
|
||||
|
||||
return configHash.id
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
import debug from 'debug'
|
||||
import type { DeepPartial, FindOptionsWhere } from 'typeorm'
|
||||
|
||||
import type { Domain } from './entity/Domain'
|
||||
import type { EnvironmentVariable } from './entity/EnvironmentVariable'
|
||||
import type { Project } from './entity/Project'
|
||||
import type { Permission } from './entity/ProjectMember'
|
||||
import type { Service } from './service'
|
||||
import type {
|
||||
AddProjectFromTemplateInput,
|
||||
AuctionParams,
|
||||
EnvironmentVariables
|
||||
} from './types'
|
||||
|
||||
const log = debug('snowball:resolver')
|
||||
|
||||
export const createResolvers = async (service: Service): Promise<any> => {
|
||||
return {
|
||||
Query: {
|
||||
// TODO: add custom type for context
|
||||
user: (_: any, __: any, context: any) => {
|
||||
return context.user
|
||||
},
|
||||
|
||||
organizations: async (_: any, __: any, context: any) => {
|
||||
return service.getOrganizationsByUserId(context.user)
|
||||
},
|
||||
|
||||
project: async (
|
||||
_: any,
|
||||
{ projectId }: { projectId: string },
|
||||
context: any
|
||||
) => {
|
||||
return service.getProjectById(context.user, projectId)
|
||||
},
|
||||
|
||||
projectsInOrganization: async (
|
||||
_: any,
|
||||
{ organizationSlug }: { organizationSlug: string },
|
||||
context: any
|
||||
) => {
|
||||
return service.getProjectsInOrganization(context.user, organizationSlug)
|
||||
},
|
||||
|
||||
deployments: async (_: any, { projectId }: { projectId: string }) => {
|
||||
return service.getNonCanonicalDeploymentsByProjectId(projectId)
|
||||
},
|
||||
|
||||
environmentVariables: async (
|
||||
_: any,
|
||||
{ projectId }: { projectId: string }
|
||||
) => {
|
||||
return service.getEnvironmentVariablesByProjectId(projectId)
|
||||
},
|
||||
|
||||
projectMembers: async (_: any, { projectId }: { projectId: string }) => {
|
||||
return service.getProjectMembersByProjectId(projectId)
|
||||
},
|
||||
|
||||
searchProjects: async (
|
||||
_: any,
|
||||
{ searchText }: { searchText: string },
|
||||
context: any
|
||||
) => {
|
||||
return service.searchProjects(context.user, searchText)
|
||||
},
|
||||
|
||||
domains: async (
|
||||
_: any,
|
||||
{
|
||||
projectId,
|
||||
filter
|
||||
}: { projectId: string; filter?: FindOptionsWhere<Domain> }
|
||||
) => {
|
||||
return service.getDomainsByProjectId(projectId, filter)
|
||||
},
|
||||
|
||||
getAuctionData: async (_: any, { auctionId }: { auctionId: string }) => {
|
||||
return service.getAuctionData(auctionId)
|
||||
},
|
||||
|
||||
deployers: async (_: any, __: any) => {
|
||||
return service.getDeployers()
|
||||
},
|
||||
|
||||
address: async (_: any, __: any) => {
|
||||
return service.getAddress()
|
||||
},
|
||||
|
||||
verifyTx: async (
|
||||
_: any,
|
||||
{
|
||||
txHash,
|
||||
amount,
|
||||
senderAddress
|
||||
}: { txHash: string; amount: string; senderAddress: string }
|
||||
) => {
|
||||
return service.verifyTx(txHash, amount, senderAddress)
|
||||
},
|
||||
|
||||
latestDNSRecord: async (_: any, { projectId }: { projectId: string }) => {
|
||||
return service.getLatestDNSRecordByProjectId(projectId)
|
||||
}
|
||||
},
|
||||
|
||||
// TODO: Return error in GQL response
|
||||
Mutation: {
|
||||
removeProjectMember: async (
|
||||
_: any,
|
||||
{ projectMemberId }: { projectMemberId: string },
|
||||
context: any
|
||||
) => {
|
||||
try {
|
||||
return await service.removeProjectMember(
|
||||
context.user,
|
||||
projectMemberId
|
||||
)
|
||||
} catch (err) {
|
||||
log(err)
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
updateProjectMember: async (
|
||||
_: any,
|
||||
{
|
||||
projectMemberId,
|
||||
data
|
||||
}: {
|
||||
projectMemberId: string
|
||||
data: {
|
||||
permissions: Permission[]
|
||||
}
|
||||
}
|
||||
) => {
|
||||
try {
|
||||
return await service.updateProjectMember(projectMemberId, data)
|
||||
} catch (err) {
|
||||
log(err)
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
addProjectMember: async (
|
||||
_: any,
|
||||
{
|
||||
projectId,
|
||||
data
|
||||
}: {
|
||||
projectId: string
|
||||
data: {
|
||||
email: string
|
||||
permissions: Permission[]
|
||||
}
|
||||
}
|
||||
) => {
|
||||
try {
|
||||
return Boolean(await service.addProjectMember(projectId, data))
|
||||
} catch (err) {
|
||||
log(err)
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
addEnvironmentVariables: async (
|
||||
_: any,
|
||||
{
|
||||
projectId,
|
||||
data
|
||||
}: {
|
||||
projectId: string
|
||||
data: { environments: string[]; key: string; value: string }[]
|
||||
}
|
||||
) => {
|
||||
try {
|
||||
return Boolean(await service.addEnvironmentVariables(projectId, data))
|
||||
} catch (err) {
|
||||
log(err)
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
updateEnvironmentVariable: async (
|
||||
_: any,
|
||||
{
|
||||
environmentVariableId,
|
||||
data
|
||||
}: {
|
||||
environmentVariableId: string
|
||||
data: DeepPartial<EnvironmentVariable>
|
||||
}
|
||||
) => {
|
||||
try {
|
||||
return await service.updateEnvironmentVariable(
|
||||
environmentVariableId,
|
||||
data
|
||||
)
|
||||
} catch (err) {
|
||||
log(err)
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
removeEnvironmentVariable: async (
|
||||
_: any,
|
||||
{ environmentVariableId }: { environmentVariableId: string }
|
||||
) => {
|
||||
try {
|
||||
return await service.removeEnvironmentVariable(environmentVariableId)
|
||||
} catch (err) {
|
||||
log(err)
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
updateDeploymentToProd: async (
|
||||
_: any,
|
||||
{ deploymentId }: { deploymentId: string },
|
||||
context: any
|
||||
) => {
|
||||
try {
|
||||
return Boolean(
|
||||
await service.updateDeploymentToProd(context.user, deploymentId)
|
||||
)
|
||||
} catch (err) {
|
||||
log(err)
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
addProjectFromTemplate: async (
|
||||
_: any,
|
||||
{
|
||||
organizationSlug,
|
||||
data,
|
||||
lrn,
|
||||
auctionParams,
|
||||
environmentVariables
|
||||
}: {
|
||||
organizationSlug: string
|
||||
data: AddProjectFromTemplateInput
|
||||
lrn: string
|
||||
auctionParams: AuctionParams
|
||||
environmentVariables: EnvironmentVariables[]
|
||||
},
|
||||
context: any
|
||||
) => {
|
||||
try {
|
||||
return await service.addProjectFromTemplate(
|
||||
context.user,
|
||||
organizationSlug,
|
||||
data,
|
||||
lrn,
|
||||
auctionParams,
|
||||
environmentVariables
|
||||
)
|
||||
} catch (err) {
|
||||
log(err)
|
||||
throw err
|
||||
}
|
||||
},
|
||||
|
||||
addProject: async (
|
||||
_: any,
|
||||
{
|
||||
organizationSlug,
|
||||
data,
|
||||
lrn,
|
||||
auctionParams,
|
||||
environmentVariables
|
||||
}: {
|
||||
organizationSlug: string
|
||||
data: DeepPartial<Project>
|
||||
lrn: string
|
||||
auctionParams: AuctionParams
|
||||
environmentVariables: EnvironmentVariables[]
|
||||
},
|
||||
context: any
|
||||
) => {
|
||||
try {
|
||||
return await service.addProject(
|
||||
context.user,
|
||||
organizationSlug,
|
||||
data,
|
||||
lrn,
|
||||
auctionParams,
|
||||
environmentVariables
|
||||
)
|
||||
} catch (err) {
|
||||
log(err)
|
||||
throw err
|
||||
}
|
||||
},
|
||||
|
||||
updateProject: async (
|
||||
_: any,
|
||||
{ projectId, data }: { projectId: string; data: DeepPartial<Project> }
|
||||
) => {
|
||||
try {
|
||||
return await service.updateProject(projectId, data)
|
||||
} catch (err) {
|
||||
log(err)
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
redeployToProd: async (
|
||||
_: any,
|
||||
{ deploymentId }: { deploymentId: string },
|
||||
context: any
|
||||
) => {
|
||||
try {
|
||||
return Boolean(
|
||||
await service.redeployToProd(context.user, deploymentId)
|
||||
)
|
||||
} catch (err) {
|
||||
log(err)
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
deleteProject: async (_: any, { projectId }: { projectId: string }) => {
|
||||
try {
|
||||
return await service.deleteProject(projectId)
|
||||
} catch (err) {
|
||||
log(err)
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
deleteDomain: async (_: any, { domainId }: { domainId: string }) => {
|
||||
try {
|
||||
return await service.deleteDomain(domainId)
|
||||
} catch (err) {
|
||||
log(err)
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
rollbackDeployment: async (
|
||||
_: any,
|
||||
{ projectId, deploymentId }: { deploymentId: string; projectId: string }
|
||||
) => {
|
||||
try {
|
||||
return await service.rollbackDeployment(projectId, deploymentId)
|
||||
} catch (err) {
|
||||
log(err)
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
deleteDeployment: async (
|
||||
_: any,
|
||||
{ deploymentId }: { deploymentId: string }
|
||||
) => {
|
||||
try {
|
||||
return await service.deleteDeployment(deploymentId)
|
||||
} catch (err) {
|
||||
log(err)
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
addDomain: async (
|
||||
_: any,
|
||||
{ projectId, data }: { projectId: string; data: { name: string } }
|
||||
) => {
|
||||
try {
|
||||
return Boolean(await service.addDomain(projectId, data))
|
||||
} catch (err) {
|
||||
log(err)
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
updateDomain: async (
|
||||
_: any,
|
||||
{ domainId, data }: { domainId: string; data: DeepPartial<Domain> }
|
||||
) => {
|
||||
try {
|
||||
return await service.updateDomain(domainId, data)
|
||||
} catch (err) {
|
||||
log(err)
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
authenticateGitHub: async (
|
||||
_: any,
|
||||
{ code }: { code: string },
|
||||
context: any
|
||||
) => {
|
||||
try {
|
||||
return await service.authenticateGitHub(code, context.user)
|
||||
} catch (err) {
|
||||
log(err)
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
unauthenticateGitHub: async (_: any, __: object, context: any) => {
|
||||
try {
|
||||
return service.unauthenticateGitHub(context.user, {
|
||||
gitHubToken: null
|
||||
})
|
||||
} catch (err) {
|
||||
log(err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Router } from 'express'
|
||||
import { SiweMessage } from 'siwe'
|
||||
import type { Service } from '../service'
|
||||
import { authenticateUser, createUser } from '../turnkey-backend'
|
||||
|
||||
const router: Router = Router()
|
||||
|
||||
//
|
||||
// Turnkey
|
||||
//
|
||||
router.get('/registration/:email', async (req, res) => {
|
||||
const service: Service = req.app.get('service')
|
||||
const user = await service.getUserByEmail(req.params.email)
|
||||
if (user) {
|
||||
return res.send({ subOrganizationId: user?.subOrgId })
|
||||
}
|
||||
|
||||
return res.sendStatus(204)
|
||||
})
|
||||
|
||||
router.post('/register', async (req, res) => {
|
||||
console.log('Register', req.body)
|
||||
const { email, challenge, attestation } = req.body
|
||||
const user = await createUser(req.app.get('service'), {
|
||||
challenge,
|
||||
attestation,
|
||||
userEmail: email,
|
||||
userName: email.split('@')[0]
|
||||
})
|
||||
req.session.address = user.id
|
||||
res.sendStatus(200)
|
||||
})
|
||||
|
||||
router.post('/authenticate', async (req, res) => {
|
||||
console.log('Authenticate', req.body)
|
||||
const { signedWhoamiRequest } = req.body
|
||||
const user = await authenticateUser(
|
||||
req.app.get('service'),
|
||||
signedWhoamiRequest
|
||||
)
|
||||
if (user) {
|
||||
req.session.address = user.id
|
||||
res.sendStatus(200)
|
||||
} else {
|
||||
res.sendStatus(401)
|
||||
}
|
||||
})
|
||||
|
||||
//
|
||||
// SIWE Auth
|
||||
//
|
||||
router.post('/validate', async (req, res) => {
|
||||
const { message, signature } = req.body
|
||||
const { success, data } = await new SiweMessage(message).verify({
|
||||
signature
|
||||
})
|
||||
|
||||
if (!success) {
|
||||
return res.send({ success })
|
||||
}
|
||||
const service: Service = req.app.get('service')
|
||||
const user = await service.getUserByEthAddress(data.address)
|
||||
|
||||
if (!user) {
|
||||
const newUser = await service.createUser({
|
||||
ethAddress: data.address,
|
||||
email: `${data.address}@example.com`,
|
||||
subOrgId: '',
|
||||
turnkeyWalletId: ''
|
||||
})
|
||||
|
||||
// SIWESession from the web3modal library requires both address and chain ID
|
||||
req.session.address = newUser.id
|
||||
req.session.chainId = data.chainId
|
||||
} else {
|
||||
req.session.address = user.id
|
||||
req.session.chainId = data.chainId
|
||||
}
|
||||
|
||||
res.send({ success })
|
||||
})
|
||||
|
||||
//
|
||||
// General
|
||||
//
|
||||
router.get('/session', (req, res) => {
|
||||
if (req.session.address && req.session.chainId) {
|
||||
res.send({
|
||||
address: req.session.address,
|
||||
chainId: req.session.chainId
|
||||
})
|
||||
} else {
|
||||
res.status(401).send({ error: 'Unauthorized: No active session' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,26 @@
|
||||
import debug from 'debug'
|
||||
import { Router } from 'express'
|
||||
|
||||
import type { Service } from '../service'
|
||||
|
||||
const log = debug('snowball:routes-github')
|
||||
const router: 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
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Router } from 'express'
|
||||
|
||||
const router: Router = Router()
|
||||
|
||||
router.get('/version', async (_req, res) => {
|
||||
return res.send({ version: '0.0.9' })
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,337 @@
|
||||
enum Role {
|
||||
Owner
|
||||
Maintainer
|
||||
Reader
|
||||
}
|
||||
|
||||
enum Permission {
|
||||
View
|
||||
Edit
|
||||
}
|
||||
|
||||
enum Environment {
|
||||
Production
|
||||
Preview
|
||||
Development
|
||||
}
|
||||
|
||||
enum DeploymentStatus {
|
||||
Building
|
||||
Ready
|
||||
Error
|
||||
Deleting
|
||||
}
|
||||
|
||||
enum AuctionStatus {
|
||||
completed
|
||||
reveal
|
||||
commit
|
||||
expired
|
||||
}
|
||||
|
||||
enum DomainStatus {
|
||||
Live
|
||||
Pending
|
||||
}
|
||||
|
||||
type User {
|
||||
id: String!
|
||||
name: String
|
||||
email: String!
|
||||
organizations: [Organization!]
|
||||
projects: [Project!]
|
||||
isVerified: Boolean!
|
||||
createdAt: String!
|
||||
updatedAt: String!
|
||||
gitHubToken: String
|
||||
}
|
||||
|
||||
type Organization {
|
||||
id: String!
|
||||
name: String!
|
||||
slug: String!
|
||||
projects: [Project!]
|
||||
createdAt: String!
|
||||
updatedAt: String!
|
||||
members: [OrganizationMember!]
|
||||
}
|
||||
|
||||
type OrganizationMember {
|
||||
id: String!
|
||||
member: User!
|
||||
role: Role!
|
||||
createdAt: String!
|
||||
updatedAt: String!
|
||||
}
|
||||
|
||||
type Project {
|
||||
id: String!
|
||||
owner: User!
|
||||
deployments: [Deployment!]
|
||||
name: String!
|
||||
repository: String!
|
||||
prodBranch: String!
|
||||
description: String
|
||||
deployers: [Deployer!]
|
||||
auctionId: String
|
||||
fundsReleased: Boolean
|
||||
template: String
|
||||
framework: String
|
||||
paymentAddress: String!
|
||||
txHash: String!
|
||||
webhooks: [String!]
|
||||
members: [ProjectMember!]
|
||||
environmentVariables: [EnvironmentVariable!]
|
||||
createdAt: String!
|
||||
updatedAt: String!
|
||||
organization: Organization!
|
||||
icon: String
|
||||
baseDomains: [String!]
|
||||
}
|
||||
|
||||
type ProjectMember {
|
||||
id: String!
|
||||
member: User!
|
||||
permissions: [Permission!]!
|
||||
isPending: Boolean!
|
||||
createdAt: String!
|
||||
updatedAt: String!
|
||||
}
|
||||
|
||||
type Deployment {
|
||||
id: String!
|
||||
branch: String!
|
||||
commitHash: String!
|
||||
commitMessage: String!
|
||||
url: String
|
||||
environment: Environment!
|
||||
deployer: Deployer
|
||||
applicationDeploymentRequestId: String
|
||||
applicationDeploymentRecordData: AppDeploymentRecordAttributes
|
||||
isCurrent: Boolean!
|
||||
baseDomain: String
|
||||
status: DeploymentStatus!
|
||||
createdAt: String!
|
||||
updatedAt: String!
|
||||
createdBy: User!
|
||||
}
|
||||
|
||||
type Domain {
|
||||
id: String!
|
||||
branch: String!
|
||||
name: String!
|
||||
redirectTo: Domain
|
||||
status: DomainStatus!
|
||||
createdAt: String!
|
||||
updatedAt: String!
|
||||
}
|
||||
|
||||
type EnvironmentVariable {
|
||||
id: String!
|
||||
environment: Environment!
|
||||
key: String!
|
||||
value: String!
|
||||
createdAt: String!
|
||||
updatedAt: String!
|
||||
}
|
||||
|
||||
type Deployer {
|
||||
deployerLrn: String!
|
||||
deployerId: String!
|
||||
deployerApiUrl: String!
|
||||
minimumPayment: String
|
||||
paymentAddress: String
|
||||
createdAt: String!
|
||||
updatedAt: String!
|
||||
baseDomain: String
|
||||
}
|
||||
|
||||
type AuthResult {
|
||||
token: String!
|
||||
}
|
||||
|
||||
input AddEnvironmentVariableInput {
|
||||
environments: [Environment!]!
|
||||
key: String!
|
||||
value: String!
|
||||
}
|
||||
|
||||
input AddProjectFromTemplateInput {
|
||||
templateOwner: String!
|
||||
templateRepo: String!
|
||||
owner: String!
|
||||
name: String!
|
||||
isPrivate: Boolean!
|
||||
paymentAddress: String!
|
||||
txHash: String!
|
||||
}
|
||||
|
||||
input AddProjectInput {
|
||||
name: String!
|
||||
repository: String!
|
||||
prodBranch: String!
|
||||
template: String
|
||||
paymentAddress: String!
|
||||
txHash: String!
|
||||
}
|
||||
|
||||
input UpdateProjectInput {
|
||||
name: String
|
||||
description: String
|
||||
prodBranch: String
|
||||
organizationId: String
|
||||
webhooks: [String!]
|
||||
}
|
||||
|
||||
input AddDomainInput {
|
||||
name: String!
|
||||
}
|
||||
|
||||
input UpdateDomainInput {
|
||||
name: String
|
||||
branch: String
|
||||
redirectToId: String
|
||||
}
|
||||
|
||||
input UpdateEnvironmentVariableInput {
|
||||
key: String
|
||||
value: String
|
||||
}
|
||||
|
||||
input AddProjectMemberInput {
|
||||
email: String!
|
||||
permissions: [Permission!]
|
||||
}
|
||||
|
||||
input UpdateProjectMemberInput {
|
||||
permissions: [Permission]
|
||||
}
|
||||
|
||||
input FilterDomainsInput {
|
||||
branch: String
|
||||
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!]!
|
||||
}
|
||||
|
||||
type DNSRecordAttributes {
|
||||
name: String
|
||||
value: String
|
||||
request: String
|
||||
resourceType: String
|
||||
version: String
|
||||
}
|
||||
|
||||
type AppDeploymentRecordAttributes {
|
||||
application: String
|
||||
auction: String
|
||||
deployer: String
|
||||
dns: String
|
||||
meta: String
|
||||
name: String
|
||||
request: String
|
||||
type: String
|
||||
url: String
|
||||
version: String
|
||||
}
|
||||
|
||||
input AuctionParams {
|
||||
maxPrice: String
|
||||
numProviders: Int
|
||||
}
|
||||
|
||||
type Query {
|
||||
user: User!
|
||||
organizations: [Organization!]
|
||||
projects: [Project!]
|
||||
projectsInOrganization(organizationSlug: String!): [Project!]
|
||||
project(projectId: String!): Project
|
||||
deployments(projectId: String!): [Deployment!]
|
||||
environmentVariables(projectId: String!): [EnvironmentVariable!]
|
||||
projectMembers(projectId: String!): [ProjectMember!]
|
||||
searchProjects(searchText: String!): [Project!]
|
||||
getAuctionData(auctionId: String!): Auction!
|
||||
latestDNSRecord(projectId: String!): DNSRecordAttributes
|
||||
domains(projectId: String!, filter: FilterDomainsInput): [Domain]
|
||||
deployers: [Deployer]
|
||||
address: String!
|
||||
verifyTx(txHash: String!, amount: String!, senderAddress: String!): Boolean!
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
addProjectMember(projectId: String!, data: AddProjectMemberInput): Boolean!
|
||||
updateProjectMember(
|
||||
projectMemberId: String!
|
||||
data: UpdateProjectMemberInput
|
||||
): Boolean!
|
||||
removeProjectMember(projectMemberId: String!): Boolean!
|
||||
addEnvironmentVariables(
|
||||
projectId: String!
|
||||
data: [AddEnvironmentVariableInput!]
|
||||
): Boolean!
|
||||
updateEnvironmentVariable(
|
||||
environmentVariableId: String!
|
||||
data: UpdateEnvironmentVariableInput!
|
||||
): Boolean!
|
||||
removeEnvironmentVariable(environmentVariableId: String!): Boolean!
|
||||
updateDeploymentToProd(deploymentId: String!): Boolean!
|
||||
addProjectFromTemplate(
|
||||
organizationSlug: String!
|
||||
data: AddProjectFromTemplateInput
|
||||
lrn: String
|
||||
auctionParams: AuctionParams
|
||||
environmentVariables: [AddEnvironmentVariableInput!]
|
||||
): Project!
|
||||
addProject(
|
||||
organizationSlug: String!
|
||||
data: AddProjectInput!
|
||||
lrn: String
|
||||
auctionParams: AuctionParams
|
||||
environmentVariables: [AddEnvironmentVariableInput!]
|
||||
): Project!
|
||||
updateProject(projectId: String!, data: UpdateProjectInput): Boolean!
|
||||
redeployToProd(deploymentId: String!): Boolean!
|
||||
deleteProject(projectId: String!): Boolean!
|
||||
deleteDomain(domainId: String!): Boolean!
|
||||
rollbackDeployment(projectId: String!, deploymentId: String!): Boolean!
|
||||
deleteDeployment(deploymentId: String!): Boolean!
|
||||
addDomain(projectId: String!, data: AddDomainInput!): Boolean!
|
||||
updateDomain(domainId: String!, data: UpdateDomainInput!): Boolean!
|
||||
authenticateGitHub(code: String!): AuthResult!
|
||||
unauthenticateGitHub: Boolean!
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import {
|
||||
ApolloServerPluginDrainHttpServer,
|
||||
ApolloServerPluginLandingPageLocalDefault,
|
||||
AuthenticationError
|
||||
} from 'apollo-server-core'
|
||||
import { ApolloServer } from 'apollo-server-express'
|
||||
import cors from 'cors'
|
||||
import debug from 'debug'
|
||||
import express from 'express'
|
||||
import session from 'express-session'
|
||||
import { createServer } from 'node:http'
|
||||
|
||||
import { makeExecutableSchema } from '@graphql-tools/schema'
|
||||
import type { TypeSource } from '@graphql-tools/utils'
|
||||
|
||||
import type { ServerConfig } from './config'
|
||||
import authRouter from './routes/auth'
|
||||
import githubRouter from './routes/github'
|
||||
import stagingRouter from './routes/staging'
|
||||
import type { Service } from './service'
|
||||
|
||||
const log = debug('snowball:server')
|
||||
|
||||
// Set cookie expiration to 1 month in milliseconds
|
||||
const COOKIE_MAX_AGE = 30 * 24 * 60 * 60 * 1000
|
||||
|
||||
declare module 'express-session' {
|
||||
interface SessionData {
|
||||
address: string
|
||||
chainId: number
|
||||
}
|
||||
}
|
||||
|
||||
export const createAndStartServer = async (
|
||||
serverConfig: ServerConfig,
|
||||
typeDefs: TypeSource,
|
||||
resolvers: any,
|
||||
service: Service
|
||||
): Promise<ApolloServer> => {
|
||||
const { host, port, gqlPath = '/graphql' } = serverConfig
|
||||
const { appOriginUrl, secret, domain, trustProxy } = serverConfig.session
|
||||
|
||||
const app = express()
|
||||
|
||||
// Create HTTP server
|
||||
const httpServer = createServer(app)
|
||||
|
||||
// Create the schema
|
||||
const schema = makeExecutableSchema({
|
||||
typeDefs,
|
||||
resolvers
|
||||
})
|
||||
|
||||
const server = new ApolloServer({
|
||||
schema,
|
||||
csrfPrevention: true,
|
||||
context: async ({ req }) => {
|
||||
// https://www.apollographql.com/docs/apollo-server/v3/security/authentication#api-wide-authorization
|
||||
|
||||
const { address } = req.session
|
||||
|
||||
if (!address) {
|
||||
throw new AuthenticationError('Unauthorized: No active session')
|
||||
}
|
||||
|
||||
const user = await service.getUser(address)
|
||||
return { user }
|
||||
},
|
||||
plugins: [
|
||||
// Proper shutdown for the HTTP server
|
||||
ApolloServerPluginDrainHttpServer({ httpServer }),
|
||||
ApolloServerPluginLandingPageLocalDefault({ embed: true })
|
||||
]
|
||||
})
|
||||
|
||||
await server.start()
|
||||
|
||||
app.use(
|
||||
cors({
|
||||
origin: appOriginUrl,
|
||||
credentials: true
|
||||
})
|
||||
)
|
||||
|
||||
const sessionOptions: session.SessionOptions = {
|
||||
secret: secret,
|
||||
resave: false,
|
||||
saveUninitialized: true,
|
||||
cookie: {
|
||||
secure: new URL(appOriginUrl).protocol === 'https:',
|
||||
maxAge: COOKIE_MAX_AGE,
|
||||
domain: domain || undefined,
|
||||
sameSite: new URL(appOriginUrl).protocol === 'https:' ? 'none' : 'lax'
|
||||
}
|
||||
}
|
||||
|
||||
if (trustProxy) {
|
||||
// trust first proxy
|
||||
app.set('trust proxy', 1)
|
||||
}
|
||||
|
||||
app.use(session(sessionOptions))
|
||||
|
||||
server.applyMiddleware({
|
||||
app: app as any,
|
||||
path: gqlPath,
|
||||
cors: {
|
||||
origin: [appOriginUrl],
|
||||
credentials: true
|
||||
}
|
||||
})
|
||||
|
||||
app.use(express.json())
|
||||
|
||||
app.set('service', service)
|
||||
app.use('/auth', authRouter)
|
||||
app.use('/api/github', githubRouter)
|
||||
app.use('/staging', stagingRouter)
|
||||
|
||||
app.use((err: any, _req: any, res: any, _next: any) => {
|
||||
console.error(err)
|
||||
res.status(500).json({ error: err.message })
|
||||
})
|
||||
|
||||
httpServer.listen(port, host, () => {
|
||||
log(`Server is listening on ${host}:${port}${server.graphqlPath}`)
|
||||
})
|
||||
|
||||
return server
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,130 @@
|
||||
import { Turnkey, type TurnkeyApiTypes } from '@turnkey/sdk-server'
|
||||
|
||||
// Default path for the first Ethereum address in a new HD wallet.
|
||||
// See https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki, paths are in the form:
|
||||
// m / purpose' / coin_type' / account' / change / address_index
|
||||
// - Purpose is a constant set to 44' following the BIP43 recommendation.
|
||||
// - Coin type is set to 60 (ETH) -- see https://github.com/satoshilabs/slips/blob/master/slip-0044.md
|
||||
// - Account, Change, and Address Index are set to 0
|
||||
import { DEFAULT_ETHEREUM_ACCOUNTS } from '@turnkey/sdk-server'
|
||||
import type { Service } from './service'
|
||||
import { getConfig } from './utils'
|
||||
|
||||
type TAttestation = TurnkeyApiTypes['v1Attestation']
|
||||
|
||||
type CreateUserParams = {
|
||||
userName: string
|
||||
userEmail: string
|
||||
challenge: string
|
||||
attestation: TAttestation
|
||||
}
|
||||
|
||||
export async function createUser(
|
||||
service: Service,
|
||||
{ userName, userEmail, challenge, attestation }: CreateUserParams
|
||||
) {
|
||||
try {
|
||||
if (await service.getUserByEmail(userEmail)) {
|
||||
throw new Error(`User already exists: ${userEmail}`)
|
||||
}
|
||||
|
||||
const config = await getConfig()
|
||||
const turnkey = new Turnkey(config.turnkey)
|
||||
|
||||
const apiClient = turnkey.api()
|
||||
|
||||
const walletName = 'Default ETH Wallet'
|
||||
|
||||
const createSubOrgResponse = await apiClient.createSubOrganization({
|
||||
subOrganizationName: `Default SubOrg for ${userEmail}`,
|
||||
rootQuorumThreshold: 1,
|
||||
rootUsers: [
|
||||
{
|
||||
userName,
|
||||
userEmail,
|
||||
apiKeys: [],
|
||||
authenticators: [
|
||||
{
|
||||
authenticatorName: 'Passkey',
|
||||
challenge,
|
||||
attestation
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
wallet: {
|
||||
walletName: walletName,
|
||||
accounts: DEFAULT_ETHEREUM_ACCOUNTS
|
||||
}
|
||||
})
|
||||
|
||||
const subOrgId = refineNonNull(createSubOrgResponse.subOrganizationId)
|
||||
const wallet = refineNonNull(createSubOrgResponse.wallet)
|
||||
|
||||
const result = {
|
||||
id: wallet.walletId,
|
||||
address: wallet.addresses[0],
|
||||
subOrgId: subOrgId
|
||||
}
|
||||
console.log('Turnkey success', result)
|
||||
|
||||
const user = await service.createUser({
|
||||
name: userName,
|
||||
email: userEmail,
|
||||
subOrgId,
|
||||
ethAddress: wallet.addresses[0],
|
||||
turnkeyWalletId: wallet.walletId
|
||||
})
|
||||
console.log('New user', user)
|
||||
|
||||
return user
|
||||
} catch (e) {
|
||||
console.error('Failed to create user:', e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
export async function authenticateUser(
|
||||
service: Service,
|
||||
signedWhoamiRequest: {
|
||||
url: string
|
||||
body: any
|
||||
stamp: {
|
||||
stampHeaderName: string
|
||||
stampHeaderValue: string
|
||||
}
|
||||
}
|
||||
) {
|
||||
try {
|
||||
const tkRes = await fetch(signedWhoamiRequest.url, {
|
||||
method: 'POST',
|
||||
body: signedWhoamiRequest.body,
|
||||
headers: {
|
||||
[signedWhoamiRequest.stamp.stampHeaderName]:
|
||||
signedWhoamiRequest.stamp.stampHeaderValue
|
||||
}
|
||||
})
|
||||
console.log('AUTH RESULT', tkRes.status)
|
||||
if (tkRes.status !== 200) {
|
||||
console.log(await tkRes.text())
|
||||
return null
|
||||
}
|
||||
const orgId = (await tkRes.json()).organizationId
|
||||
const user = await service.getUserBySubOrgId(orgId)
|
||||
return user
|
||||
} catch (e) {
|
||||
console.error('Failed to authenticate:', e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
function refineNonNull<T>(
|
||||
input: T | null | undefined,
|
||||
errorMessage?: string
|
||||
): T {
|
||||
if (input == null) {
|
||||
throw new Error(errorMessage ?? `Unexpected ${JSON.stringify(input)}`)
|
||||
}
|
||||
|
||||
return input
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
export interface PackageJSON {
|
||||
name: string
|
||||
version: string
|
||||
author?: string
|
||||
description?: string
|
||||
homepage?: string
|
||||
license?: 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
|
||||
}
|
||||
deleted: boolean
|
||||
}
|
||||
|
||||
export interface AppDeploymentRecordAttributes {
|
||||
application: string
|
||||
auction: string
|
||||
deployer: string
|
||||
dns: string
|
||||
meta: string
|
||||
name: string
|
||||
request: string
|
||||
type: string
|
||||
url: string
|
||||
version: string
|
||||
}
|
||||
|
||||
export interface DNSRecordAttributes {
|
||||
name: string
|
||||
value: string
|
||||
request: string
|
||||
resourceType: string
|
||||
version: string
|
||||
}
|
||||
|
||||
export interface RegistryDNSRecordAttributes {
|
||||
name: string
|
||||
value: string
|
||||
request: string
|
||||
resource_type: string
|
||||
version: string
|
||||
}
|
||||
|
||||
export interface AppDeploymentRemovalRecordAttributes {
|
||||
deployment: string
|
||||
request: string
|
||||
type: 'ApplicationDeploymentRemovalRecord'
|
||||
version: string
|
||||
}
|
||||
|
||||
export interface RegistryRecord {
|
||||
id: string
|
||||
names: string[] | null
|
||||
owners: string[]
|
||||
bondId: string
|
||||
createTime: string
|
||||
expiryTime: string
|
||||
}
|
||||
|
||||
export interface AppDeploymentRecord extends RegistryRecord {
|
||||
attributes: AppDeploymentRecordAttributes
|
||||
}
|
||||
|
||||
export interface AppDeploymentRemovalRecord extends RegistryRecord {
|
||||
attributes: AppDeploymentRemovalRecordAttributes
|
||||
}
|
||||
|
||||
export interface DNSRecord extends RegistryRecord {
|
||||
attributes: RegistryDNSRecordAttributes
|
||||
}
|
||||
|
||||
export interface AddProjectFromTemplateInput {
|
||||
templateOwner: string
|
||||
templateRepo: string
|
||||
owner: string
|
||||
name: string
|
||||
isPrivate: boolean
|
||||
paymentAddress: string
|
||||
txHash: string
|
||||
}
|
||||
|
||||
export interface AuctionParams {
|
||||
maxPrice: string
|
||||
numProviders: number
|
||||
}
|
||||
|
||||
export interface EnvironmentVariables {
|
||||
environments: string[]
|
||||
key: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface DeployerRecord {
|
||||
id: string
|
||||
names: string[]
|
||||
owners: string[]
|
||||
bondId: string
|
||||
createTime: string
|
||||
expiryTime: string
|
||||
attributes: {
|
||||
apiUrl: string
|
||||
minimumPayment: string | null
|
||||
name: string
|
||||
paymentAddress: string
|
||||
publicKey: string
|
||||
type: string
|
||||
version: string
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import debug from 'debug'
|
||||
import fs from 'fs-extra'
|
||||
import assert from 'node:assert'
|
||||
import path from 'node:path'
|
||||
import type { Octokit } from 'octokit'
|
||||
import toml from 'toml'
|
||||
import type {
|
||||
DataSource,
|
||||
DeepPartial,
|
||||
EntityTarget,
|
||||
ObjectLiteral
|
||||
} from 'typeorm'
|
||||
|
||||
import type { Config } from './config'
|
||||
|
||||
interface PackageJSON {
|
||||
name: string
|
||||
description?: string
|
||||
homepage?: string
|
||||
license?: string
|
||||
author?: string | { [key: string]: unknown }
|
||||
version?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
const log = debug('snowball:utils')
|
||||
|
||||
export async function getConfig() {
|
||||
return await _getConfig<Config>(
|
||||
path.join(__dirname, '../environments/local.toml')
|
||||
)
|
||||
}
|
||||
|
||||
const _getConfig = async <ConfigType>(
|
||||
configFile: string
|
||||
): Promise<ConfigType> => {
|
||||
const fileExists = await fs.pathExists(configFile)
|
||||
if (!fileExists) {
|
||||
throw new Error(`Config file not found: ${configFile}`)
|
||||
}
|
||||
|
||||
const config = toml.parse(await fs.readFile(configFile, 'utf8'))
|
||||
log('config', JSON.stringify(config, null, 2))
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
export const checkFileExists = async (filePath: string): Promise<boolean> => {
|
||||
try {
|
||||
await fs.access(filePath, fs.constants.F_OK)
|
||||
return true
|
||||
} catch (err) {
|
||||
log(err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const getEntities = async (filePath: string): Promise<any> => {
|
||||
const entitiesData = await fs.readFile(filePath, 'utf-8')
|
||||
const entities = JSON.parse(entitiesData)
|
||||
return entities
|
||||
}
|
||||
|
||||
export const loadAndSaveData = async <Entity extends ObjectLiteral>(
|
||||
entityType: EntityTarget<Entity>,
|
||||
dataSource: DataSource,
|
||||
entities: any,
|
||||
relations?: any | undefined
|
||||
): Promise<Entity[]> => {
|
||||
const entityRepository = dataSource.getRepository(entityType)
|
||||
|
||||
const savedEntity: Entity[] = []
|
||||
|
||||
for (const entityData of entities) {
|
||||
let entity = entityRepository.create(entityData as DeepPartial<Entity>)
|
||||
|
||||
if (relations) {
|
||||
for (const field in relations) {
|
||||
const valueIndex = `${field}Index`
|
||||
|
||||
entity = {
|
||||
...entity,
|
||||
[field]: relations[field][entityData[valueIndex]]
|
||||
}
|
||||
}
|
||||
}
|
||||
const dbEntity = await entityRepository.save(entity)
|
||||
savedEntity.push(dbEntity)
|
||||
}
|
||||
|
||||
return savedEntity
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// Wrapper method for registry txs to retry once if 'account sequence mismatch' occurs
|
||||
export const registryTransactionWithRetry = async (
|
||||
txMethod: () => Promise<any>
|
||||
): Promise<any> => {
|
||||
try {
|
||||
return await txMethod()
|
||||
} catch (error: any) {
|
||||
if (!error.message.includes('account sequence mismatch')) {
|
||||
throw error
|
||||
}
|
||||
|
||||
console.error(
|
||||
'Transaction failed due to account sequence mismatch. Retrying...'
|
||||
)
|
||||
|
||||
try {
|
||||
return await txMethod()
|
||||
} catch (retryError: any) {
|
||||
throw new Error(
|
||||
`Transaction failed again after retry: ${retryError.message}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as fs from 'node:fs/promises'
|
||||
import debug from 'debug'
|
||||
|
||||
import { getConfig } from '../src/utils'
|
||||
|
||||
const log = debug('snowball:delete-database')
|
||||
|
||||
const deleteFile = async (filePath: string) => {
|
||||
await fs.unlink(filePath)
|
||||
log(`File ${filePath} has been deleted.`)
|
||||
}
|
||||
|
||||
const main = async () => {
|
||||
const config = await getConfig()
|
||||
|
||||
deleteFile(config.database.dbPath)
|
||||
}
|
||||
|
||||
main().catch((err) => log(err))
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
[
|
||||
{
|
||||
"projectIndex": 0,
|
||||
"domainIndex": 0,
|
||||
"createdByIndex": 0,
|
||||
"id": "ffhae3zq",
|
||||
"status": "Ready",
|
||||
"environment": "Production",
|
||||
"isCurrent": true,
|
||||
"applicationRecordId": "qbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
|
||||
"applicationRecordData": {},
|
||||
"applicationDeploymentRequestId": "xqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
|
||||
"applicationDeploymentRequestData": {},
|
||||
"branch": "main",
|
||||
"commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00",
|
||||
"commitMessage": "subscription added",
|
||||
"url": "testProject-ffhae3zq.snowball.xyz"
|
||||
},
|
||||
{
|
||||
"projectIndex": 0,
|
||||
"domainIndex": 1,
|
||||
"createdByIndex": 0,
|
||||
"id": "vehagei8",
|
||||
"status": "Ready",
|
||||
"environment": "Preview",
|
||||
"isCurrent": false,
|
||||
"applicationRecordId": "wbafyreihvzya6ovp4yfpkqnddkui2iw7thbhwq74lbqs7bhobvmfhrowoi",
|
||||
"applicationRecordData": {},
|
||||
"applicationDeploymentRequestId": "wqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
|
||||
"applicationDeploymentRequestData": {},
|
||||
"branch": "test",
|
||||
"commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00",
|
||||
"commitMessage": "subscription added",
|
||||
"url": "testProject-vehagei8.snowball.xyz"
|
||||
},
|
||||
{
|
||||
"projectIndex": 0,
|
||||
"domainIndex": 2,
|
||||
"createdByIndex": 0,
|
||||
"id": "qmgekyte",
|
||||
"status": "Ready",
|
||||
"environment": "Development",
|
||||
"isCurrent": false,
|
||||
"applicationRecordId": "ebafyreihvzya6ovp4yfpkqnddkui2iw7t6bhwq74lbqs7bhobvmfhrowoi",
|
||||
"applicationRecordData": {},
|
||||
"applicationDeploymentRequestId": "kqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
|
||||
"applicationDeploymentRequestData": {},
|
||||
"branch": "test",
|
||||
"commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00",
|
||||
"commitMessage": "subscription added",
|
||||
"url": "testProject-qmgekyte.snowball.xyz"
|
||||
},
|
||||
{
|
||||
"projectIndex": 0,
|
||||
"domainIndex": null,
|
||||
"createdByIndex": 0,
|
||||
"id": "f8wsyim6",
|
||||
"status": "Ready",
|
||||
"environment": "Production",
|
||||
"isCurrent": false,
|
||||
"applicationRecordId": "rbafyreihvzya6ovp4yfpkqnddkui2iw7t6hbhw74lbqs7bhobvmfhrowoi",
|
||||
"applicationRecordData": {},
|
||||
"applicationDeploymentRequestId": "yqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
|
||||
"applicationDeploymentRequestData": {},
|
||||
"branch": "prod",
|
||||
"commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00",
|
||||
"commitMessage": "subscription added",
|
||||
"url": "testProject-f8wsyim6.snowball.xyz"
|
||||
},
|
||||
{
|
||||
"projectIndex": 1,
|
||||
"domainIndex": 3,
|
||||
"createdByIndex": 1,
|
||||
"id": "eO8cckxk",
|
||||
"status": "Ready",
|
||||
"environment": "Production",
|
||||
"isCurrent": true,
|
||||
"applicationRecordId": "tbafyreihvzya6ovp4yfpqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
|
||||
"applicationRecordData": {},
|
||||
"applicationDeploymentRequestId": "pqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
|
||||
"applicationDeploymentRequestData": {},
|
||||
"branch": "main",
|
||||
"commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00",
|
||||
"commitMessage": "subscription added",
|
||||
"url": "testProject-2-eO8cckxk.snowball.xyz"
|
||||
},
|
||||
{
|
||||
"projectIndex": 1,
|
||||
"domainIndex": 4,
|
||||
"createdByIndex": 1,
|
||||
"id": "yaq0t5yw",
|
||||
"status": "Ready",
|
||||
"environment": "Preview",
|
||||
"isCurrent": false,
|
||||
"applicationRecordId": "ybafyreihvzya6ovp4yfpkqnddkui2iw7t6bhwq74lbqs7bhobvmfhrowoi",
|
||||
"applicationRecordData": {},
|
||||
"applicationDeploymentRequestId": "tqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
|
||||
"applicationDeploymentRequestData": {},
|
||||
"branch": "test",
|
||||
"commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00",
|
||||
"commitMessage": "subscription added",
|
||||
"url": "testProject-2-yaq0t5yw.snowball.xyz"
|
||||
},
|
||||
{
|
||||
"projectIndex": 1,
|
||||
"domainIndex": 5,
|
||||
"createdByIndex": 1,
|
||||
"id": "hwwr6sbx",
|
||||
"status": "Ready",
|
||||
"environment": "Development",
|
||||
"isCurrent": false,
|
||||
"applicationRecordId": "ubafyreihvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvfhrowoi",
|
||||
"applicationRecordData": {},
|
||||
"applicationDeploymentRequestId": "eqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
|
||||
"applicationDeploymentRequestData": {},
|
||||
"branch": "test",
|
||||
"commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00",
|
||||
"commitMessage": "subscription added",
|
||||
"url": "testProject-2-hwwr6sbx.snowball.xyz"
|
||||
},
|
||||
{
|
||||
"projectIndex": 2,
|
||||
"domainIndex": 9,
|
||||
"createdByIndex": 2,
|
||||
"id": "ndxje48a",
|
||||
"status": "Ready",
|
||||
"environment": "Production",
|
||||
"isCurrent": true,
|
||||
"applicationRecordId": "ibayreihvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
|
||||
"applicationRecordData": {},
|
||||
"applicationDeploymentRequestId": "dqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
|
||||
"applicationDeploymentRequestData": {},
|
||||
"branch": "main",
|
||||
"commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00",
|
||||
"commitMessage": "subscription added",
|
||||
"url": "iglootools-ndxje48a.snowball.xyz"
|
||||
},
|
||||
{
|
||||
"projectIndex": 2,
|
||||
"domainIndex": 7,
|
||||
"createdByIndex": 2,
|
||||
"id": "gtgpgvei",
|
||||
"status": "Ready",
|
||||
"environment": "Preview",
|
||||
"isCurrent": false,
|
||||
"applicationRecordId": "obafyreihvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
|
||||
"applicationRecordData": {},
|
||||
"applicationDeploymentRequestId": "aqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
|
||||
"applicationDeploymentRequestData": {},
|
||||
"branch": "test",
|
||||
"commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00",
|
||||
"commitMessage": "subscription added",
|
||||
"url": "iglootools-gtgpgvei.snowball.xyz"
|
||||
},
|
||||
{
|
||||
"projectIndex": 2,
|
||||
"domainIndex": 8,
|
||||
"createdByIndex": 2,
|
||||
"id": "b4bpthjr",
|
||||
"status": "Ready",
|
||||
"environment": "Development",
|
||||
"isCurrent": false,
|
||||
"applicationRecordId": "pbafyreihvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowo",
|
||||
"applicationRecordData": {},
|
||||
"applicationDeploymentRequestId": "uqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
|
||||
"applicationDeploymentRequestData": {},
|
||||
"branch": "test",
|
||||
"commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00",
|
||||
"commitMessage": "subscription added",
|
||||
"url": "iglootools-b4bpthjr.snowball.xyz"
|
||||
},
|
||||
{
|
||||
"projectIndex": 3,
|
||||
"domainIndex": 6,
|
||||
"createdByIndex": 2,
|
||||
"id": "b4bpthjr",
|
||||
"status": "Ready",
|
||||
"environment": "Production",
|
||||
"isCurrent": true,
|
||||
"applicationRecordId": "pbafyreihvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowo",
|
||||
"applicationRecordData": {},
|
||||
"applicationDeploymentRequestId": "pqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
|
||||
"applicationDeploymentRequestData": {},
|
||||
"branch": "test",
|
||||
"commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00",
|
||||
"commitMessage": "subscription added",
|
||||
"url": "iglootools-b4bpthjr.snowball.xyz"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,92 @@
|
||||
[
|
||||
{
|
||||
"projectIndex": 0,
|
||||
"key": "ABC",
|
||||
"value": "ABC",
|
||||
"environment": "Production"
|
||||
},
|
||||
{
|
||||
"projectIndex": 0,
|
||||
"key": "ABC",
|
||||
"value": "ABC",
|
||||
"environment": "Preview"
|
||||
},
|
||||
{
|
||||
"projectIndex": 0,
|
||||
"key": "XYZ",
|
||||
"value": "abc3",
|
||||
"environment": "Preview"
|
||||
},
|
||||
{
|
||||
"projectIndex": 1,
|
||||
"key": "ABC",
|
||||
"value": "ABC",
|
||||
"environment": "Production"
|
||||
},
|
||||
{
|
||||
"projectIndex": 1,
|
||||
"key": "ABC",
|
||||
"value": "ABC",
|
||||
"environment": "Preview"
|
||||
},
|
||||
{
|
||||
"projectIndex": 1,
|
||||
"key": "XYZ",
|
||||
"value": "abc3",
|
||||
"environment": "Preview"
|
||||
},
|
||||
{
|
||||
"projectIndex": 2,
|
||||
"key": "ABC",
|
||||
"value": "ABC",
|
||||
"environment": "Production"
|
||||
},
|
||||
{
|
||||
"projectIndex": 2,
|
||||
"key": "ABC",
|
||||
"value": "ABC",
|
||||
"environment": "Preview"
|
||||
},
|
||||
{
|
||||
"projectIndex": 2,
|
||||
"key": "XYZ",
|
||||
"value": "abc3",
|
||||
"environment": "Preview"
|
||||
},
|
||||
{
|
||||
"projectIndex": 3,
|
||||
"key": "ABC",
|
||||
"value": "ABC",
|
||||
"environment": "Production"
|
||||
},
|
||||
{
|
||||
"projectIndex": 3,
|
||||
"key": "ABC",
|
||||
"value": "ABC",
|
||||
"environment": "Preview"
|
||||
},
|
||||
{
|
||||
"projectIndex": 3,
|
||||
"key": "XYZ",
|
||||
"value": "abc3",
|
||||
"environment": "Preview"
|
||||
},
|
||||
{
|
||||
"projectIndex": 4,
|
||||
"key": "ABC",
|
||||
"value": "ABC",
|
||||
"environment": "Production"
|
||||
},
|
||||
{
|
||||
"projectIndex": 4,
|
||||
"key": "ABC",
|
||||
"value": "ABC",
|
||||
"environment": "Preview"
|
||||
},
|
||||
{
|
||||
"projectIndex": 4,
|
||||
"key": "XYZ",
|
||||
"value": "abc3",
|
||||
"environment": "Preview"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,7 @@
|
||||
[
|
||||
{
|
||||
"id": "2379cf1f-a232-4ad2-ae14-4d881131cc26",
|
||||
"name": "Deploy Tools",
|
||||
"slug": "deploy-tools"
|
||||
}
|
||||
]
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
[
|
||||
{
|
||||
"projectIndex": 0,
|
||||
"name": "example.snowballtools.xyz",
|
||||
"status": "Live",
|
||||
"branch": "main"
|
||||
},
|
||||
{
|
||||
"projectIndex": 0,
|
||||
"name": "example.org",
|
||||
"status": "Pending",
|
||||
"branch": "test"
|
||||
},
|
||||
{
|
||||
"projectIndex": 1,
|
||||
"name": "example.snowballtools.xyz",
|
||||
"status": "Live",
|
||||
"branch": "main"
|
||||
},
|
||||
{
|
||||
"projectIndex": 1,
|
||||
"name": "example.org",
|
||||
"status": "Pending",
|
||||
"branch": "test"
|
||||
},
|
||||
{
|
||||
"projectIndex": 2,
|
||||
"name": "example.snowballtools.xyz",
|
||||
"status": "Live",
|
||||
"branch": "main"
|
||||
},
|
||||
{
|
||||
"projectIndex": 2,
|
||||
"name": "example.org",
|
||||
"status": "Pending",
|
||||
"branch": "test"
|
||||
},
|
||||
{
|
||||
"projectIndex": 3,
|
||||
"name": "iglootools-2.com",
|
||||
"status": "Pending",
|
||||
"branch": "test"
|
||||
}
|
||||
]
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
[
|
||||
{
|
||||
"memberIndex": 1,
|
||||
"projectIndex": 0,
|
||||
"permissions": ["View"],
|
||||
"isPending": false
|
||||
},
|
||||
{
|
||||
"memberIndex": 2,
|
||||
"projectIndex": 0,
|
||||
"permissions": ["View", "Edit"],
|
||||
"isPending": false
|
||||
},
|
||||
{
|
||||
"memberIndex": 2,
|
||||
"projectIndex": 1,
|
||||
"permissions": ["View"],
|
||||
"isPending": false
|
||||
},
|
||||
{
|
||||
"memberIndex": 0,
|
||||
"projectIndex": 2,
|
||||
"permissions": ["View"],
|
||||
"isPending": false
|
||||
},
|
||||
{
|
||||
"memberIndex": 1,
|
||||
"projectIndex": 2,
|
||||
"permissions": ["View", "Edit"],
|
||||
"isPending": false
|
||||
},
|
||||
{
|
||||
"memberIndex": 0,
|
||||
"projectIndex": 3,
|
||||
"permissions": ["View"],
|
||||
"isPending": false
|
||||
},
|
||||
{
|
||||
"memberIndex": 2,
|
||||
"projectIndex": 3,
|
||||
"permissions": ["View", "Edit"],
|
||||
"isPending": false
|
||||
},
|
||||
{
|
||||
"memberIndex": 1,
|
||||
"projectIndex": 4,
|
||||
"permissions": ["View"],
|
||||
"isPending": false
|
||||
},
|
||||
{
|
||||
"memberIndex": 2,
|
||||
"projectIndex": 4,
|
||||
"permissions": ["View", "Edit"],
|
||||
"isPending": false
|
||||
}
|
||||
]
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
[
|
||||
{
|
||||
"ownerIndex": 0,
|
||||
"organizationIndex": 0,
|
||||
"name": "testProject",
|
||||
"repository": "snowball-tools/snowball-ts-framework-template",
|
||||
"prodBranch": "main",
|
||||
"description": "test",
|
||||
"template": "webapp",
|
||||
"framework": "test",
|
||||
"webhooks": [],
|
||||
"icon": "",
|
||||
"subDomain": "testProject.snowball.xyz"
|
||||
},
|
||||
{
|
||||
"ownerIndex": 1,
|
||||
"organizationIndex": 0,
|
||||
"name": "testProject-2",
|
||||
"repository": "snowball-tools/snowball-ts-framework-template",
|
||||
"prodBranch": "main",
|
||||
"description": "test-2",
|
||||
"template": "webapp",
|
||||
"framework": "test-2",
|
||||
"webhooks": [],
|
||||
"icon": "",
|
||||
"subDomain": "testProject-2.snowball.xyz"
|
||||
},
|
||||
{
|
||||
"ownerIndex": 2,
|
||||
"organizationIndex": 0,
|
||||
"name": "iglootools",
|
||||
"repository": "snowball-tools/snowball-ts-framework-template",
|
||||
"prodBranch": "main",
|
||||
"description": "test-3",
|
||||
"template": "webapp",
|
||||
"framework": "test-3",
|
||||
"webhooks": [],
|
||||
"icon": "",
|
||||
"subDomain": "iglootools.snowball.xyz"
|
||||
},
|
||||
{
|
||||
"ownerIndex": 1,
|
||||
"organizationIndex": 0,
|
||||
"name": "iglootools-2",
|
||||
"repository": "snowball-tools/snowball-ts-framework-template",
|
||||
"prodBranch": "main",
|
||||
"description": "test-4",
|
||||
"template": "webapp",
|
||||
"framework": "test-4",
|
||||
"webhooks": [],
|
||||
"icon": "",
|
||||
"subDomain": "iglootools-2.snowball.xyz"
|
||||
},
|
||||
{
|
||||
"ownerIndex": 0,
|
||||
"organizationIndex": 1,
|
||||
"name": "snowball-2",
|
||||
"repository": "snowball-tools/snowball-ts-framework-template",
|
||||
"prodBranch": "main",
|
||||
"description": "test-5",
|
||||
"template": "webapp",
|
||||
"framework": "test-5",
|
||||
"webhooks": [],
|
||||
"icon": "",
|
||||
"subDomain": "snowball-2.snowball.xyz"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
[
|
||||
{
|
||||
"projectIndex": 0,
|
||||
"name": "www.example.org",
|
||||
"status": "Pending",
|
||||
"redirectToIndex": 1,
|
||||
"branch": "test"
|
||||
},
|
||||
{
|
||||
"projectIndex": 1,
|
||||
"name": "www.example.org",
|
||||
"status": "Pending",
|
||||
"redirectToIndex": 3,
|
||||
"branch": "test"
|
||||
},
|
||||
{
|
||||
"projectIndex": 2,
|
||||
"name": "www.example.org",
|
||||
"status": "Pending",
|
||||
"redirectToIndex": 5,
|
||||
"branch": "test"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
[
|
||||
{
|
||||
"role": "Owner",
|
||||
"memberIndex": 0,
|
||||
"organizationIndex": 0
|
||||
},
|
||||
{
|
||||
"role": "Maintainer",
|
||||
"memberIndex": 1,
|
||||
"organizationIndex": 0
|
||||
},
|
||||
{
|
||||
"role": "Owner",
|
||||
"memberIndex": 2,
|
||||
"organizationIndex": 0
|
||||
},
|
||||
{
|
||||
"role": "Owner",
|
||||
"memberIndex": 0,
|
||||
"organizationIndex": 1
|
||||
}
|
||||
]
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
[
|
||||
{
|
||||
"id": "59f4355d-9549-4aac-9b54-eeefceeabef0",
|
||||
"name": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2",
|
||||
"email": "snowball@snowballtools.xyz",
|
||||
"isVerified": true,
|
||||
"ethAddress": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2"
|
||||
},
|
||||
{
|
||||
"id": "e505b212-8da6-48b2-9614-098225dab34b",
|
||||
"name": "0xbe0eb53f46cd790cd13851d5eff43d12404d33e8",
|
||||
"email": "alice@snowballtools.xyz",
|
||||
"isVerified": true,
|
||||
"ethAddress": "0xbe0eb53f46cd790cd13851d5eff43d12404d33e8"
|
||||
},
|
||||
{
|
||||
"id": "cd892fad-9138-4aa2-a62c-414a32776ea7",
|
||||
"name": "0x8315177ab297ba92a06054ce80a67ed4dbd7ed3a",
|
||||
"email": "bob@snowballtools.xyz",
|
||||
"isVerified": true,
|
||||
"ethAddress": "0x8315177ab297ba92a06054ce80a67ed4dbd7ed3a"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,176 @@
|
||||
import path from 'node:path'
|
||||
import debug from 'debug'
|
||||
import { DataSource } from 'typeorm'
|
||||
|
||||
import { Deployment } from '../src/entity/Deployment'
|
||||
import { Domain } from '../src/entity/Domain'
|
||||
import { EnvironmentVariable } from '../src/entity/EnvironmentVariable'
|
||||
import { Organization } from '../src/entity/Organization'
|
||||
import { Project } from '../src/entity/Project'
|
||||
import { ProjectMember } from '../src/entity/ProjectMember'
|
||||
import { User } from '../src/entity/User'
|
||||
import { UserOrganization } from '../src/entity/UserOrganization'
|
||||
import {
|
||||
checkFileExists,
|
||||
getConfig,
|
||||
getEntities,
|
||||
loadAndSaveData
|
||||
} from '../src/utils'
|
||||
|
||||
const log = debug('snowball:initialize-database')
|
||||
|
||||
const USER_DATA_PATH = './fixtures/users.json'
|
||||
const PROJECT_DATA_PATH = './fixtures/projects.json'
|
||||
const ORGANIZATION_DATA_PATH = './fixtures/organizations.json'
|
||||
const USER_ORGANIZATION_DATA_PATH = './fixtures/user-organizations.json'
|
||||
const PROJECT_MEMBER_DATA_PATH = './fixtures/project-members.json'
|
||||
const PRIMARY_DOMAIN_DATA_PATH = './fixtures/primary-domains.json'
|
||||
const DEPLOYMENT_DATA_PATH = './fixtures/deployments.json'
|
||||
const ENVIRONMENT_VARIABLE_DATA_PATH = './fixtures/environment-variables.json'
|
||||
const REDIRECTED_DOMAIN_DATA_PATH = './fixtures/redirected-domains.json'
|
||||
|
||||
const generateTestData = async (dataSource: DataSource) => {
|
||||
const userEntities = await getEntities(
|
||||
path.resolve(__dirname, USER_DATA_PATH)
|
||||
)
|
||||
const savedUsers = await loadAndSaveData(User, dataSource, userEntities)
|
||||
|
||||
const orgEntities = await getEntities(
|
||||
path.resolve(__dirname, ORGANIZATION_DATA_PATH)
|
||||
)
|
||||
const savedOrgs = await loadAndSaveData(Organization, dataSource, orgEntities)
|
||||
|
||||
const projectRelations = {
|
||||
owner: savedUsers,
|
||||
organization: savedOrgs
|
||||
}
|
||||
|
||||
const projectEntities = await getEntities(
|
||||
path.resolve(__dirname, PROJECT_DATA_PATH)
|
||||
)
|
||||
const savedProjects = await loadAndSaveData(
|
||||
Project,
|
||||
dataSource,
|
||||
projectEntities,
|
||||
projectRelations
|
||||
)
|
||||
|
||||
const domainRepository = dataSource.getRepository(Domain)
|
||||
|
||||
const domainPrimaryRelations = {
|
||||
project: savedProjects
|
||||
}
|
||||
|
||||
const primaryDomainsEntities = await getEntities(
|
||||
path.resolve(__dirname, PRIMARY_DOMAIN_DATA_PATH)
|
||||
)
|
||||
const savedPrimaryDomains = await loadAndSaveData(
|
||||
Domain,
|
||||
dataSource,
|
||||
primaryDomainsEntities,
|
||||
domainPrimaryRelations
|
||||
)
|
||||
|
||||
const domainRedirectedRelations = {
|
||||
project: savedProjects,
|
||||
redirectTo: savedPrimaryDomains
|
||||
}
|
||||
|
||||
const redirectDomainsEntities = await getEntities(
|
||||
path.resolve(__dirname, REDIRECTED_DOMAIN_DATA_PATH)
|
||||
)
|
||||
await loadAndSaveData(
|
||||
Domain,
|
||||
dataSource,
|
||||
redirectDomainsEntities,
|
||||
domainRedirectedRelations
|
||||
)
|
||||
|
||||
const savedDomains = await domainRepository.find()
|
||||
|
||||
const userOrganizationRelations = {
|
||||
member: savedUsers,
|
||||
organization: savedOrgs
|
||||
}
|
||||
|
||||
const userOrganizationsEntities = await getEntities(
|
||||
path.resolve(__dirname, USER_ORGANIZATION_DATA_PATH)
|
||||
)
|
||||
await loadAndSaveData(
|
||||
UserOrganization,
|
||||
dataSource,
|
||||
userOrganizationsEntities,
|
||||
userOrganizationRelations
|
||||
)
|
||||
|
||||
const projectMemberRelations = {
|
||||
member: savedUsers,
|
||||
project: savedProjects
|
||||
}
|
||||
|
||||
const projectMembersEntities = await getEntities(
|
||||
path.resolve(__dirname, PROJECT_MEMBER_DATA_PATH)
|
||||
)
|
||||
await loadAndSaveData(
|
||||
ProjectMember,
|
||||
dataSource,
|
||||
projectMembersEntities,
|
||||
projectMemberRelations
|
||||
)
|
||||
|
||||
const deploymentRelations = {
|
||||
project: savedProjects,
|
||||
domain: savedDomains,
|
||||
createdBy: savedUsers
|
||||
}
|
||||
|
||||
const deploymentsEntities = await getEntities(
|
||||
path.resolve(__dirname, DEPLOYMENT_DATA_PATH)
|
||||
)
|
||||
await loadAndSaveData(
|
||||
Deployment,
|
||||
dataSource,
|
||||
deploymentsEntities,
|
||||
deploymentRelations
|
||||
)
|
||||
|
||||
const environmentVariableRelations = {
|
||||
project: savedProjects
|
||||
}
|
||||
|
||||
const environmentVariablesEntities = await getEntities(
|
||||
path.resolve(__dirname, ENVIRONMENT_VARIABLE_DATA_PATH)
|
||||
)
|
||||
await loadAndSaveData(
|
||||
EnvironmentVariable,
|
||||
dataSource,
|
||||
environmentVariablesEntities,
|
||||
environmentVariableRelations
|
||||
)
|
||||
}
|
||||
|
||||
const main = async () => {
|
||||
const config = await getConfig()
|
||||
const isDbPresent = await checkFileExists(config.database.dbPath)
|
||||
|
||||
if (!isDbPresent) {
|
||||
const dataSource = new DataSource({
|
||||
type: 'better-sqlite3',
|
||||
database: config.database.dbPath,
|
||||
synchronize: true,
|
||||
logging: true,
|
||||
entities: [path.join(__dirname, '../src/entity/*')]
|
||||
})
|
||||
|
||||
await dataSource.initialize()
|
||||
|
||||
await generateTestData(dataSource)
|
||||
log('Data loaded successfully')
|
||||
} else {
|
||||
log('WARNING: Database already exists')
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
log(err)
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
import debug from 'debug'
|
||||
|
||||
import { Registry, parseGasAndFees } from '@cerc-io/registry-sdk'
|
||||
|
||||
import { getConfig } from '../src/utils'
|
||||
|
||||
const log = debug('snowball:initialize-registry')
|
||||
|
||||
const DENOM = 'alnt'
|
||||
const BOND_AMOUNT = '1000000000'
|
||||
|
||||
async function main() {
|
||||
const { registryConfig } = await getConfig()
|
||||
|
||||
// TODO: Get authority names from args
|
||||
const authorityNames = ['snowballtools', registryConfig.authority]
|
||||
|
||||
const registry = new Registry(
|
||||
registryConfig.gqlEndpoint,
|
||||
registryConfig.restEndpoint,
|
||||
{ chainId: registryConfig.chainId }
|
||||
)
|
||||
|
||||
const bondId = await registry.getNextBondId(registryConfig.privateKey)
|
||||
log('bondId:', bondId)
|
||||
|
||||
const fee = parseGasAndFees(registryConfig.fee.gas, registryConfig.fee.fees)
|
||||
|
||||
await registry.createBond(
|
||||
{ denom: DENOM, amount: BOND_AMOUNT },
|
||||
registryConfig.privateKey,
|
||||
fee
|
||||
)
|
||||
|
||||
for await (const name of authorityNames) {
|
||||
await registry.reserveAuthority({ name }, registryConfig.privateKey, fee)
|
||||
log('Reserved authority name:', name)
|
||||
await registry.setAuthorityBond(
|
||||
{ name, bondId },
|
||||
registryConfig.privateKey,
|
||||
fee
|
||||
)
|
||||
log(`Bond ${bondId} set for authority ${name}`)
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
log(err)
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
import path from 'node:path'
|
||||
import debug from 'debug'
|
||||
import { DataSource } from 'typeorm'
|
||||
|
||||
import { Registry, parseGasAndFees } from '@cerc-io/registry-sdk'
|
||||
|
||||
import {
|
||||
Deployment,
|
||||
DeploymentStatus,
|
||||
Environment
|
||||
} from '../src/entity/Deployment'
|
||||
import { getConfig } from '../src/utils'
|
||||
|
||||
const log = debug('snowball:publish-deploy-records')
|
||||
|
||||
async function main() {
|
||||
const { registryConfig, database } = await getConfig()
|
||||
|
||||
const registry = new Registry(
|
||||
registryConfig.gqlEndpoint,
|
||||
registryConfig.restEndpoint,
|
||||
{ chainId: 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.Building
|
||||
}
|
||||
})
|
||||
|
||||
for await (const deployment of deployments) {
|
||||
const url = `https://${(deployment.project.name).toLowerCase()}-${deployment.id}.${deployment.deployer.baseDomain}`
|
||||
|
||||
const applicationDeploymentRecord = {
|
||||
type: 'ApplicationDeploymentRecord',
|
||||
version: '0.0.1',
|
||||
name: deployment.applicationRecordData.name,
|
||||
application: deployment.applicationRecordId,
|
||||
|
||||
// TODO: Create DNS record
|
||||
dns: 'bafyreihlymqggsgqiqawvehkpr2imt4l3u6q7um7xzjrux5rhsvwnuyewm',
|
||||
|
||||
// Using dummy values
|
||||
meta: JSON.stringify({
|
||||
config: 'da39a3ee5e6b4b0d3255bfef95601890afd80709',
|
||||
so: '66fcfa49a1664d4cb4ce4f72c1c0e151'
|
||||
}),
|
||||
|
||||
request: deployment.applicationDeploymentRequestId,
|
||||
url
|
||||
}
|
||||
|
||||
const fee = parseGasAndFees(registryConfig.fee.gas, registryConfig.fee.fees)
|
||||
|
||||
const result = await registry.setRecord(
|
||||
{
|
||||
privateKey: registryConfig.privateKey,
|
||||
record: applicationDeploymentRecord,
|
||||
bondId: registryConfig.bondId
|
||||
},
|
||||
'',
|
||||
fee
|
||||
)
|
||||
|
||||
// Remove deployment for project subdomain if deployment is for production environment
|
||||
if (deployment.environment === Environment.Production) {
|
||||
applicationDeploymentRecord.url = `https://${deployment.project.name}.${deployment.deployer.baseDomain}`
|
||||
|
||||
await registry.setRecord(
|
||||
{
|
||||
privateKey: registryConfig.privateKey,
|
||||
record: applicationDeploymentRecord,
|
||||
bondId: registryConfig.bondId
|
||||
},
|
||||
'',
|
||||
fee
|
||||
)
|
||||
}
|
||||
|
||||
log('Application deployment record data:', applicationDeploymentRecord)
|
||||
log(`Application deployment record published: ${result.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
log(err)
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import path from 'node:path'
|
||||
import debug from 'debug'
|
||||
import { DataSource } from 'typeorm'
|
||||
|
||||
import { Registry, parseGasAndFees } from '@cerc-io/registry-sdk'
|
||||
|
||||
import { Deployment, DeploymentStatus } from '../src/entity/Deployment'
|
||||
import { getConfig } from '../src/utils'
|
||||
|
||||
const log = debug('snowball:publish-deployment-removal-records')
|
||||
|
||||
async function main() {
|
||||
const { registryConfig, database } = await getConfig()
|
||||
|
||||
const registry = new Registry(
|
||||
registryConfig.gqlEndpoint,
|
||||
registryConfig.restEndpoint,
|
||||
{ chainId: 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 fee = parseGasAndFees(registryConfig.fee.gas, registryConfig.fee.fees)
|
||||
|
||||
const result = await registry.setRecord(
|
||||
{
|
||||
privateKey: registryConfig.privateKey,
|
||||
record: applicationDeploymentRemovalRecord,
|
||||
bondId: registryConfig.bondId
|
||||
},
|
||||
'',
|
||||
fee
|
||||
)
|
||||
|
||||
log(
|
||||
'Application deployment removal record data:',
|
||||
applicationDeploymentRemovalRecord
|
||||
)
|
||||
log(`Application deployment removal record published: ${result.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
log(err)
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"baseUrl": ".",
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["dist", "src/**/*.test.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user