625 lines
17 KiB
TypeScript
625 lines
17 KiB
TypeScript
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
|
|
}
|
|
}
|