From 1105623bc08f0416c80d83eff73887adbf028ce4 Mon Sep 17 00:00:00 2001 From: Haseeb Rabbani Date: Thu, 28 Sep 2023 10:08:01 -0400 Subject: [PATCH] add support for publishing external bots --- cli/commands/publish/index.spec.ts | 117 ++++++---- cli/commands/publish/index.ts | 41 ++-- cli/commands/publish/upload.manifest.spec.ts | 211 +++++++++++-------- cli/commands/publish/upload.manifest.ts | 17 +- cli/di.container.ts | 1 + 5 files changed, 236 insertions(+), 151 deletions(-) diff --git a/cli/commands/publish/index.spec.ts b/cli/commands/publish/index.spec.ts index 44f6e7e5..64acd480 100644 --- a/cli/commands/publish/index.spec.ts +++ b/cli/commands/publish/index.spec.ts @@ -1,41 +1,84 @@ -import { Wallet } from "ethers" -import providePublish from "." -import { CommandHandler } from "../.." +import { Wallet } from "ethers"; +import providePublish from "."; +import { CommandHandler } from "../.."; describe("publish", () => { - let publish: CommandHandler - const mockGetCredentials = jest.fn() - const mockUploadImage = jest.fn() - const mockUploadManifest = jest.fn() - const mockPushToRegistry = jest.fn() + let publish: CommandHandler; + const mockGetCredentials = jest.fn(); + const mockUploadImage = jest.fn(); + const mockUploadManifest = jest.fn(); + const mockPushToRegistry = jest.fn(); + const mockPrivateKey = "0x4567"; + const mockImageRef = "abc123"; + const mockManifestRef = "def456"; + let mockExternal = false; + + beforeEach(() => { + mockUploadImage.mockReset(); + mockGetCredentials.mockReset(); + mockUploadManifest.mockReset(); + mockPushToRegistry.mockReset(); + + mockGetCredentials.mockReturnValueOnce({ privateKey: mockPrivateKey }); + mockUploadImage.mockReturnValueOnce(mockImageRef); + mockUploadManifest.mockReturnValueOnce(mockManifestRef); + }); beforeAll(() => { - publish = providePublish(mockGetCredentials, mockUploadImage, mockUploadManifest, mockPushToRegistry) - }) - - it("publishes the agent correctly", async () => { - const mockPrivateKey = "0x4567" - mockGetCredentials.mockReturnValueOnce({ privateKey: mockPrivateKey}) - const mockImageRef = "abc123" - mockUploadImage.mockReturnValueOnce(mockImageRef) - const mockManifestRef = "def456" - mockUploadManifest.mockReturnValueOnce(mockManifestRef) - - await publish() - - expect(mockUploadImage).toHaveBeenCalledTimes(1) - expect(mockUploadImage).toHaveBeenCalledWith() - expect(mockUploadImage).toHaveBeenCalledBefore(mockGetCredentials) - expect(mockGetCredentials).toHaveBeenCalledTimes(1) - expect(mockGetCredentials).toHaveBeenCalledWith() - expect(mockGetCredentials).toHaveBeenCalledBefore(mockUploadManifest) - expect(mockUploadManifest).toHaveBeenCalledTimes(1) - expect(mockUploadManifest).toHaveBeenCalledWith(mockImageRef, mockPrivateKey) - expect(mockUploadManifest).toHaveBeenCalledBefore(mockPushToRegistry) - expect(mockPushToRegistry).toHaveBeenCalledTimes(1) - const [manifestRef, fromWallet] = mockPushToRegistry.mock.calls[0] - expect(manifestRef).toEqual(mockManifestRef) - expect(fromWallet).toBeInstanceOf(Wallet) - expect(fromWallet.address).toEqual(new Wallet(mockPrivateKey).address) - }) -}) \ No newline at end of file + publish = providePublish( + mockGetCredentials, + mockUploadImage, + mockUploadManifest, + mockPushToRegistry, + mockExternal + ); + }); + + it("publishes the image and manifest correctly", async () => { + await publish(); + + expect(mockUploadImage).toHaveBeenCalledTimes(1); + expect(mockUploadImage).toHaveBeenCalledWith(); + expect(mockUploadImage).toHaveBeenCalledBefore(mockGetCredentials); + expect(mockGetCredentials).toHaveBeenCalledTimes(1); + expect(mockGetCredentials).toHaveBeenCalledWith(); + expect(mockGetCredentials).toHaveBeenCalledBefore(mockUploadManifest); + expect(mockUploadManifest).toHaveBeenCalledTimes(1); + expect(mockUploadManifest).toHaveBeenCalledWith( + mockImageRef, + mockPrivateKey + ); + expect(mockUploadManifest).toHaveBeenCalledBefore(mockPushToRegistry); + expect(mockPushToRegistry).toHaveBeenCalledTimes(1); + const [manifestRef, fromWallet] = mockPushToRegistry.mock.calls[0]; + expect(manifestRef).toEqual(mockManifestRef); + expect(fromWallet).toBeInstanceOf(Wallet); + expect(fromWallet.address).toEqual(new Wallet(mockPrivateKey).address); + }); + + it("does not publish an image for external bots", async () => { + mockExternal = true; + publish = providePublish( + mockGetCredentials, + mockUploadImage, + mockUploadManifest, + mockPushToRegistry, + mockExternal + ); + + await publish(); + + expect(mockUploadImage).toHaveBeenCalledTimes(0); + expect(mockGetCredentials).toHaveBeenCalledTimes(1); + expect(mockGetCredentials).toHaveBeenCalledWith(); + expect(mockGetCredentials).toHaveBeenCalledBefore(mockUploadManifest); + expect(mockUploadManifest).toHaveBeenCalledTimes(1); + expect(mockUploadManifest).toHaveBeenCalledWith(undefined, mockPrivateKey); + expect(mockUploadManifest).toHaveBeenCalledBefore(mockPushToRegistry); + expect(mockPushToRegistry).toHaveBeenCalledTimes(1); + const [manifestRef, fromWallet] = mockPushToRegistry.mock.calls[0]; + expect(manifestRef).toEqual(mockManifestRef); + expect(fromWallet).toBeInstanceOf(Wallet); + expect(fromWallet.address).toEqual(new Wallet(mockPrivateKey).address); + }); +}); diff --git a/cli/commands/publish/index.ts b/cli/commands/publish/index.ts index b9372e73..f97f3216 100644 --- a/cli/commands/publish/index.ts +++ b/cli/commands/publish/index.ts @@ -1,26 +1,31 @@ -import { CommandHandler } from '../..' -import { assertExists } from '../../utils' -import { GetCredentials } from '../../utils/get.credentials' -import { UploadImage } from './upload.image' -import { UploadManifest } from './upload.manifest' -import { PushToRegistry } from './push.to.registry' -import { Wallet } from 'ethers' +import { CommandHandler } from "../.."; +import { assertExists } from "../../utils"; +import { GetCredentials } from "../../utils/get.credentials"; +import { UploadImage } from "./upload.image"; +import { UploadManifest } from "./upload.manifest"; +import { PushToRegistry } from "./push.to.registry"; +import { Wallet } from "ethers"; export default function providePublish( getCredentials: GetCredentials, uploadImage: UploadImage, uploadManifest: UploadManifest, - pushToRegistry: PushToRegistry + pushToRegistry: PushToRegistry, + external: boolean ): CommandHandler { - assertExists(getCredentials, 'getCredentials') - assertExists(uploadImage, 'uploadImage') - assertExists(uploadManifest, 'uploadManifest') - assertExists(pushToRegistry, 'pushToRegistry') + assertExists(getCredentials, "getCredentials"); + assertExists(uploadImage, "uploadImage"); + assertExists(uploadManifest, "uploadManifest"); + assertExists(pushToRegistry, "pushToRegistry"); return async function publish() { - const imageReference = await uploadImage() - const { privateKey } = await getCredentials() - const manifestReference = await uploadManifest(imageReference, privateKey) - await pushToRegistry(manifestReference, new Wallet(privateKey)) - } -} \ No newline at end of file + let imageReference; + // external bots do not have docker images so no need to upload one + if (!external) { + imageReference = await uploadImage(); + } + const { privateKey } = await getCredentials(); + const manifestReference = await uploadManifest(imageReference, privateKey); + await pushToRegistry(manifestReference, new Wallet(privateKey)); + }; +} diff --git a/cli/commands/publish/upload.manifest.spec.ts b/cli/commands/publish/upload.manifest.spec.ts index 3aee63ec..217bf825 100644 --- a/cli/commands/publish/upload.manifest.spec.ts +++ b/cli/commands/publish/upload.manifest.spec.ts @@ -1,110 +1,134 @@ -import { ethers, Wallet } from "ethers" -import { keccak256 } from "../../utils" -import provideUploadManifest, { ChainSettings, UploadManifest } from "./upload.manifest" +import { ethers, Wallet } from "ethers"; +import { keccak256 } from "../../utils"; +import provideUploadManifest, { + ChainSettings, + UploadManifest, +} from "./upload.manifest"; describe("uploadManifest", () => { - let uploadManifest: UploadManifest + let uploadManifest: UploadManifest; const mockFilesystem = { existsSync: jest.fn(), readFileSync: jest.fn(), - statSync: jest.fn() - } as any - const mockAddToIpfs = jest.fn() - const mockAgentName = "agent name" - const mockAgentDisplayName = "agent display name" - const mockDescription = "some description" - const mockLongDescription = "some long description" - const mockAgentId = "0xagentId" - const mockVersion = "0.1" - const mockDocumentation = "README.md" - const mockRepository = "github.com/myrepository" - const mockLicenseUrl = "github.com/myrepository" - const mockPromoUrl = "github.com/myrepository" - const mockImageRef = "123abc" - const mockPrivateKey = "0xabcd" - const mockCliVersion = "0.2" + statSync: jest.fn(), + } as any; + const mockAddToIpfs = jest.fn(); + const mockAgentName = "agent name"; + const mockAgentDisplayName = "agent display name"; + const mockDescription = "some description"; + const mockLongDescription = "some long description"; + const mockAgentId = "0xagentId"; + const mockVersion = "0.1"; + const mockDocumentation = "README.md"; + const mockRepository = "github.com/myrepository"; + const mockLicenseUrl = "github.com/myrepository"; + const mockPromoUrl = "github.com/myrepository"; + const mockImageRef = "123abc"; + const mockPrivateKey = "0xabcd"; + const mockCliVersion = "0.2"; const mockChainIds = [1, 1337]; + const mockExternal = false; const mockChainSettings = { - "default": { - "shards": 1, - "target": 1 + default: { + shards: 1, + target: 1, }, 1: { - "shards": "5", - "target": "10" + shards: "5", + target: "10", }, "137": { - "shards": "2", - "targets": 3 - } - } as any + shards: "2", + targets: 3, + }, + } as any; const formattedMockChainSettings = { - "default": { - "shards": 1, - "target": 1 + default: { + shards: 1, + target: 1, }, "1": { - "shards": 5, - "target": 10 + shards: 5, + target: 10, }, "137": { - "shards": 2, - "targets": 3 - } - } + shards: 2, + targets: 3, + }, + }; const resetMocks = () => { - mockFilesystem.existsSync.mockReset() - mockFilesystem.statSync.mockReset() - } + mockFilesystem.existsSync.mockReset(); + mockFilesystem.statSync.mockReset(); + }; beforeAll(() => { uploadManifest = provideUploadManifest( - mockFilesystem, mockAddToIpfs, mockAgentName, mockAgentDisplayName, - mockDescription, mockLongDescription, mockAgentId, mockVersion, mockDocumentation, mockRepository, mockLicenseUrl, mockPromoUrl, mockCliVersion, mockChainIds, mockChainSettings - ) - }) + mockFilesystem, + mockAddToIpfs, + mockAgentName, + mockAgentDisplayName, + mockDescription, + mockLongDescription, + mockAgentId, + mockVersion, + mockDocumentation, + mockRepository, + mockLicenseUrl, + mockPromoUrl, + mockCliVersion, + mockChainIds, + mockExternal, + mockChainSettings + ); + }); - beforeEach(() => { resetMocks() }) + beforeEach(() => { + resetMocks(); + }); it("throws error if documentation not found", async () => { - mockFilesystem.existsSync.mockReturnValueOnce(false) + mockFilesystem.existsSync.mockReturnValueOnce(false); try { - await uploadManifest(mockImageRef, mockPrivateKey) + await uploadManifest(mockImageRef, mockPrivateKey); } catch (e) { - expect(e.message).toBe(`documentation file ${mockDocumentation} not found`) + expect(e.message).toBe( + `documentation file ${mockDocumentation} not found` + ); } - expect(mockFilesystem.existsSync).toHaveBeenCalledTimes(1) - expect(mockFilesystem.existsSync).toHaveBeenCalledWith(mockDocumentation) - }) + expect(mockFilesystem.existsSync).toHaveBeenCalledTimes(1); + expect(mockFilesystem.existsSync).toHaveBeenCalledWith(mockDocumentation); + }); it("throws error if documentation file is empty", async () => { - mockFilesystem.existsSync.mockReturnValueOnce(true) - mockFilesystem.statSync.mockReturnValueOnce({size: 0}) + mockFilesystem.existsSync.mockReturnValueOnce(true); + mockFilesystem.statSync.mockReturnValueOnce({ size: 0 }); try { - await uploadManifest(mockImageRef, mockPrivateKey) + await uploadManifest(mockImageRef, mockPrivateKey); } catch (e) { - expect(e.message).toBe(`documentation file ${mockDocumentation} cannot be empty`) + expect(e.message).toBe( + `documentation file ${mockDocumentation} cannot be empty` + ); } - expect(mockFilesystem.existsSync).toHaveBeenCalledTimes(1) - expect(mockFilesystem.existsSync).toHaveBeenCalledWith(mockDocumentation) - expect(mockFilesystem.statSync).toHaveBeenCalledTimes(1) - expect(mockFilesystem.statSync).toHaveBeenCalledWith(mockDocumentation) - }) + expect(mockFilesystem.existsSync).toHaveBeenCalledTimes(1); + expect(mockFilesystem.existsSync).toHaveBeenCalledWith(mockDocumentation); + expect(mockFilesystem.statSync).toHaveBeenCalledTimes(1); + expect(mockFilesystem.statSync).toHaveBeenCalledWith(mockDocumentation); + }); it("uploads signed manifest to ipfs and returns ipfs reference", async () => { - const systemTime = new Date() - jest.useFakeTimers('modern').setSystemTime(systemTime) - mockFilesystem.existsSync.mockReturnValueOnce(true) - mockFilesystem.statSync.mockReturnValueOnce({size: 1}) - const mockDocumentationFile = JSON.stringify({ some: 'documentation' }) - mockFilesystem.readFileSync.mockReturnValueOnce(mockDocumentationFile) - const mockDocumentationRef = "docRef" - mockAddToIpfs.mockReturnValueOnce(mockDocumentationRef) + const systemTime = new Date(); + jest.useFakeTimers("modern").setSystemTime(systemTime); + mockFilesystem.existsSync.mockReturnValueOnce(true); + mockFilesystem.statSync.mockReturnValueOnce({ size: 1 }); + const mockDocumentationFile = JSON.stringify({ some: "documentation" }); + mockFilesystem.readFileSync.mockReturnValueOnce(mockDocumentationFile); + const mockDocumentationRef = "docRef"; + mockAddToIpfs.mockReturnValueOnce(mockDocumentationRef); const mockManifest = { from: new Wallet(mockPrivateKey).address, name: mockAgentDisplayName, @@ -121,25 +145,34 @@ describe("uploadManifest", () => { promoUrl: mockPromoUrl, chainIds: mockChainIds, publishedFrom: `Forta CLI ${mockCliVersion}`, - chainSettings: formattedMockChainSettings - } - const mockManifestRef = "manifestRef" - mockAddToIpfs.mockReturnValueOnce(mockManifestRef) + external: mockExternal, + chainSettings: formattedMockChainSettings, + }; + const mockManifestRef = "manifestRef"; + mockAddToIpfs.mockReturnValueOnce(mockManifestRef); - const manifestRef = await uploadManifest(mockImageRef, mockPrivateKey) + const manifestRef = await uploadManifest(mockImageRef, mockPrivateKey); - expect(manifestRef).toBe(mockManifestRef) - expect(mockFilesystem.existsSync).toHaveBeenCalledTimes(1) - expect(mockFilesystem.existsSync).toHaveBeenCalledWith(mockDocumentation) - expect(mockFilesystem.statSync).toHaveBeenCalledTimes(1) - expect(mockFilesystem.statSync).toHaveBeenCalledWith(mockDocumentation) - expect(mockFilesystem.readFileSync).toHaveBeenCalledTimes(1) - expect(mockFilesystem.readFileSync).toHaveBeenCalledWith(mockDocumentation, 'utf8') - expect(mockAddToIpfs).toHaveBeenCalledTimes(2) - expect(mockAddToIpfs).toHaveBeenNthCalledWith(1, mockDocumentationFile) - const signingKey = new ethers.utils.SigningKey(mockPrivateKey) - const signature = ethers.utils.joinSignature(signingKey.signDigest(keccak256(JSON.stringify(mockManifest)))) - expect(mockAddToIpfs).toHaveBeenNthCalledWith(2, JSON.stringify({ manifest: mockManifest, signature })) - jest.useRealTimers() - }) -}) \ No newline at end of file + expect(manifestRef).toBe(mockManifestRef); + expect(mockFilesystem.existsSync).toHaveBeenCalledTimes(1); + expect(mockFilesystem.existsSync).toHaveBeenCalledWith(mockDocumentation); + expect(mockFilesystem.statSync).toHaveBeenCalledTimes(1); + expect(mockFilesystem.statSync).toHaveBeenCalledWith(mockDocumentation); + expect(mockFilesystem.readFileSync).toHaveBeenCalledTimes(1); + expect(mockFilesystem.readFileSync).toHaveBeenCalledWith( + mockDocumentation, + "utf8" + ); + expect(mockAddToIpfs).toHaveBeenCalledTimes(2); + expect(mockAddToIpfs).toHaveBeenNthCalledWith(1, mockDocumentationFile); + const signingKey = new ethers.utils.SigningKey(mockPrivateKey); + const signature = ethers.utils.joinSignature( + signingKey.signDigest(keccak256(JSON.stringify(mockManifest))) + ); + expect(mockAddToIpfs).toHaveBeenNthCalledWith( + 2, + JSON.stringify({ manifest: mockManifest, signature }) + ); + jest.useRealTimers(); + }); +}); diff --git a/cli/commands/publish/upload.manifest.ts b/cli/commands/publish/upload.manifest.ts index 89239db4..1d3fadea 100644 --- a/cli/commands/publish/upload.manifest.ts +++ b/cli/commands/publish/upload.manifest.ts @@ -4,7 +4,7 @@ import { assertExists, assertIsNonEmptyString, assertIsValidChainSettings, kecca import { AddToIpfs } from "../../utils/add.to.ipfs" // uploads signed agent manifest to ipfs and returns ipfs reference -export type UploadManifest = (imageReference: string, privateKey: string) => Promise +export type UploadManifest = (imageReference: string | undefined, privateKey: string) => Promise export type ChainSetting = { shards: number; @@ -22,14 +22,15 @@ type Manifest = { agentIdHash: string, version: string, timestamp: string, - imageReference: string, + imageReference?: string, documentation: string, repository?: string, licenseUrl?: string, promoUrl?: string, chainIds: number[], publishedFrom: string, - chainSettings?: ChainSettings + chainSettings?: ChainSettings, + external?: boolean } export default function provideUploadManifest( @@ -47,7 +48,8 @@ export default function provideUploadManifest( promoUrl: string, cliVersion: string, chainIds: number[], - chainSettings?: ChainSettings + external: boolean, + chainSettings?: ChainSettings, ): UploadManifest { assertExists(filesystem, 'filesystem') assertExists(addToIpfs, 'addToIpfs') @@ -60,7 +62,7 @@ export default function provideUploadManifest( assertExists(chainIds, 'chainIds') assertIsValidChainSettings(chainSettings) - return async function uploadManifest(imageReference: string, privateKey: string) { + return async function uploadManifest(imageReference: string | undefined, privateKey: string) { // upload documentation to ipfs if (!filesystem.existsSync(documentation)) { throw new Error(`documentation file ${documentation} not found`) @@ -85,10 +87,11 @@ export default function provideUploadManifest( imageReference, documentation: documentationReference, repository, - licenseUrl: licenseUrl, - promoUrl: promoUrl, + licenseUrl, + promoUrl, chainIds, publishedFrom: `Forta CLI ${cliVersion}`, + external, chainSettings: formatChainSettings(chainSettings), } diff --git a/cli/di.container.ts b/cli/di.container.ts index 82e43335..d4cfd6a6 100644 --- a/cli/di.container.ts +++ b/cli/di.container.ts @@ -171,6 +171,7 @@ export default function configureContainer(args: any = {}) { }).singleton(), licenseUrl: asFunction((packageJson: any) => packageJson.licenseUrl).singleton(), promoUrl: asFunction((packageJson: any) => packageJson.promoUrl).singleton(), + external: asFunction((packageJson: any) => packageJson.external === true).singleton(), keyfileName: asFunction((fortaConfig: FortaConfig) => { return fortaConfig.keyfile }),