From eb42107ea7adb7338aeb3c790f19d60c0001a229 Mon Sep 17 00:00:00 2001 From: shamsartem Date: Fri, 12 Aug 2022 18:48:43 +0300 Subject: [PATCH] Add deploy strategies --- README.md | 4 +- package-lock.json | 1 + package.json | 1 + src/commands/deploy.ts | 282 +++++++++++++++++++---------- src/commands/remove.ts | 1 - src/commands/service/repl.ts | 14 +- src/lib/configs/project/fluence.ts | 32 +++- src/lib/configs/project/module.ts | 12 +- src/lib/configs/project/service.ts | 4 +- src/lib/helpers/downloadFile.ts | 3 +- src/lib/multiaddr.ts | 48 +++++ src/lib/typeHelpers.ts | 6 +- 12 files changed, 300 insertions(+), 108 deletions(-) diff --git a/README.md b/README.md index 0fe417f0e..f6213af24 100644 --- a/README.md +++ b/README.md @@ -469,7 +469,7 @@ EXAMPLES ## `fluence service repl [NAME | PATH | URL]` -Open service inside repl +Open service inside repl (downloads and builds modules if necessary) ``` USAGE @@ -482,7 +482,7 @@ FLAGS --no-input Don't interactively ask for any input from the user DESCRIPTION - Open service inside repl + Open service inside repl (downloads and builds modules if necessary) EXAMPLES $ fluence service repl diff --git a/package-lock.json b/package-lock.json index b8ecdead4..71e4c7063 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "decompress": "^4.2.1", "filenamify": "^4.3.0", "inquirer": "^8.2.4", + "lodash": "^4.17.21", "multiaddr": "^10.0.1", "node-fetch": "^2.6.7", "platform": "^1.3.6", diff --git a/package.json b/package.json index eba6f3966..b97523835 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "decompress": "^4.2.1", "filenamify": "^4.3.0", "inquirer": "^8.2.4", + "lodash": "^4.17.21", "multiaddr": "^10.0.1", "node-fetch": "^2.6.7", "platform": "^1.3.6", diff --git a/src/commands/deploy.ts b/src/commands/deploy.ts index 50c9dbe8e..c53c76136 100644 --- a/src/commands/deploy.ts +++ b/src/commands/deploy.ts @@ -29,6 +29,8 @@ import { ServicesV2, } from "../lib/configs/project/app"; import { + Distribution, + DISTRIBUTION_EVEN, FluenceConfigReadonly, initReadonlyFluenceConfig, OverrideModules, @@ -37,6 +39,7 @@ import { import { initReadonlyModuleConfig, ModuleConfigReadonly, + MODULE_TYPE_RUST, } from "../lib/configs/project/module"; import { FACADE_MODULE_NAME, @@ -76,7 +79,14 @@ import { getMessageWithKeyValuePairs } from "../lib/helpers/getMessageWithKeyVal import { replaceHomeDir } from "../lib/helpers/replaceHomeDir"; import { getKeyPairFromFlags } from "../lib/keypairs"; import { initMarineCli } from "../lib/marineCli"; -import { getRandomRelayAddr, getRandomRelayId } from "../lib/multiaddr"; +import { + getEvenlyDistributedIds, + getEvenlyDistributedIdsFromTheList, + getRandomRelayAddr, + getRandomRelayId, + getRandomRelayIdFromTheList, + Relays, +} from "../lib/multiaddr"; import { ensureFluenceAquaServicesDir, ensureFluenceTmpDeployJsonPath, @@ -169,42 +179,41 @@ export default class Deploy extends Command { : true; for (const { - count, deployJSON, deployId, peerId, serviceName, } of preparedForDeployItems) { - for (let i = 0; i < count; i = i + 1) { - // eslint-disable-next-line no-await-in-loop - const res = await deployService({ - deployJSON, - peerId: peerId ?? getRandomRelayId(fluenceConfig.relays), - serviceName, - deployId, - relay, - secretKey: keyPair.secretKey, - aquaCli, - timeout: flags[TIMEOUT_FLAG_NAME], - tmpDeployJSONPath, - commandObj: this, - doDeployAll, - isInteractive, - }); + // Here we don't deploy in parallel because it often fails if run in parallel + // And also when user requests, we interactively ask about each deploy + // eslint-disable-next-line no-await-in-loop + const res = await deployService({ + deployJSON, + peerId, + serviceName, + deployId, + relay, + secretKey: keyPair.secretKey, + aquaCli, + timeout: flags[TIMEOUT_FLAG_NAME], + tmpDeployJSONPath, + commandObj: this, + doDeployAll, + isInteractive, + }); - if (res !== null) { - const { deployedServiceConfig, deployId, serviceName } = res; + if (res !== null) { + const { deployedServiceConfig, deployId, serviceName } = res; - const successfullyDeployedServicesByName = - allServices[serviceName] ?? {}; + const successfullyDeployedServicesByName = + allServices[serviceName] ?? {}; - successfullyDeployedServicesByName[deployId] = [ - ...(successfullyDeployedServicesByName[deployId] ?? []), - deployedServiceConfig, - ]; + successfullyDeployedServicesByName[deployId] = [ + ...(successfullyDeployedServicesByName[deployId] ?? []), + deployedServiceConfig, + ]; - allServices[serviceName] = successfullyDeployedServicesByName; - } + allServices[serviceName] = successfullyDeployedServicesByName; } } @@ -219,12 +228,21 @@ export default class Deploy extends Command { aquaCli, }); + const logResults = (configPath: string): void => { + this.log( + `\nCurrently deployed services listed in ${color.yellow( + replaceHomeDir(configPath ?? "") + )}:\n\n${yamlDiffPatch("", {}, allServices)}\n` + ); + }; + if (appConfig !== null) { appConfig.services = allServices; - return appConfig.$commit(); + await appConfig.$commit(); + return logResults(appConfig.$getPath()); } - await initNewReadonlyAppConfig( + const newAppConfig = await initNewReadonlyAppConfig( { version: 2, services: allServices, @@ -234,6 +252,8 @@ export default class Deploy extends Command { }, this ); + + logResults(newAppConfig.$getPath()); } } @@ -246,8 +266,7 @@ type DeployInfo = { serviceName: string; serviceDirPath: string; deployId: string; - count: number; - peerId: string | undefined; + peerId: string; modules: Array; }; @@ -346,8 +365,15 @@ const prepareForDeploy = async ({ serviceConfig, serviceDirPath, }): Array => - deploy.map( - ({ deployId, count = 1, peerId, overrideModules }): DeployInfo => { + deploy.flatMap( + ({ + deployId, + count, + peerId, + peerIds, + overrideModules, + distribution, + }): Array => { const deployIdValidity = validateAquaName(deployId); if (deployIdValidity !== true) { @@ -356,60 +382,31 @@ const prepareForDeploy = async ({ ); } - return { - serviceName, - serviceDirPath, - deployId, + return getPeerIds({ + peerId: peerIds ?? peerId, + distribution, count, - peerId: fluenceConfig?.peerIds?.[peerId ?? ""] ?? peerId, - modules: ((): Array => { - const modulesNotFoundInServiceYaml = Object.keys( - overrideModules ?? {} - ).filter( - (moduleName): boolean => !(moduleName in serviceConfig.modules) - ); - - if (modulesNotFoundInServiceYaml.length > 0) { - commandObj.error( - `${color.yellow( - FLUENCE_CONFIG_FILE_NAME - )} has service ${color.yellow( - serviceName - )} with deployId ${color.yellow( - deployId - )} that has moduleOverrides for modules that don't exist in the service ${color.yellow( - serviceDirPath - )}. Please make sure ${color.yellow( - modulesNotFoundInServiceYaml.join(", ") - )} spelled correctly ` - ); - } - - const { [FACADE_MODULE_NAME]: facadeModule, ...otherModules } = - serviceConfig.modules; - - return [ - ...Object.entries(otherModules).map( - ([moduleName, mod]): DeployInfoModule => ({ - moduleConfig: overrideModule( - mod, - overrideModules, - moduleName - ), - moduleName, - }) - ), - { - moduleConfig: overrideModule( - facadeModule, - overrideModules, - FACADE_MODULE_NAME - ), - moduleName: FACADE_MODULE_NAME, - }, - ]; - })(), - }; + relays: fluenceConfig.relays, + namedPeerIds: fluenceConfig.peerIds, + }).map( + (peerId: string): DeployInfo => ({ + serviceName, + serviceDirPath, + deployId, + peerId: + typeof peerId === "string" + ? fluenceConfig?.peerIds?.[peerId ?? ""] ?? peerId + : peerId, + modules: getDeployInfoModules({ + commandObj, + deployId, + overrideModules, + serviceConfigModules: serviceConfig.modules, + serviceDirPath, + serviceName, + }), + }) + ); } ) ); @@ -471,7 +468,7 @@ const prepareForDeploy = async ({ ); } - if (moduleConfig.type === "rust") { + if (moduleConfig.type === MODULE_TYPE_RUST) { await marineCli({ command: "build", flags: { release: true }, @@ -525,6 +522,109 @@ const prepareForDeploy = async ({ ); }; +type GetPeerIdsArg = { + peerId: undefined | string | Array; + distribution: Distribution | undefined; + count: number | undefined; + relays: Relays; + namedPeerIds: Record | undefined; +}; + +const getNamedPeerIdsFn = + ( + namedPeerIds: Record + ): ((peerIds: Array) => string[]) => + (peerIds: Array): string[] => + peerIds.map((peerId): string => namedPeerIds[peerId] ?? peerId); + +const getPeerIds = ({ + peerId, + distribution = DISTRIBUTION_EVEN, + count, + relays, + namedPeerIds = {}, +}: GetPeerIdsArg): Array => { + const getNamedPeerIds = getNamedPeerIdsFn(namedPeerIds); + + if (distribution === DISTRIBUTION_EVEN) { + if (peerId === undefined) { + return getEvenlyDistributedIds(relays, count); + } + + return getEvenlyDistributedIdsFromTheList( + getNamedPeerIds(typeof peerId === "string" ? [peerId] : peerId), + count + ); + } + + if (peerId === undefined) { + return Array.from({ length: count ?? 1 }).map((): string => + getRandomRelayId(relays) + ); + } + + const peerIds = typeof peerId === "string" ? [peerId] : peerId; + return Array.from({ length: count ?? peerIds.length }).map((): string => + getRandomRelayIdFromTheList(peerIds) + ); +}; + +type GetDeployInfoModulesArg = { + commandObj: CommandObj; + overrideModules: OverrideModules | undefined; + serviceName: string; + deployId: string; + serviceDirPath: string; + serviceConfigModules: { facade: ModuleV0 } & Record; +}; + +const getDeployInfoModules = ({ + commandObj, + overrideModules, + serviceName, + deployId, + serviceDirPath, + serviceConfigModules, +}: GetDeployInfoModulesArg): Array => { + const modulesNotFoundInServiceYaml = Object.keys( + overrideModules ?? {} + ).filter((moduleName): boolean => !(moduleName in serviceConfigModules)); + + if (modulesNotFoundInServiceYaml.length > 0) { + commandObj.error( + `${color.yellow(FLUENCE_CONFIG_FILE_NAME)} has service ${color.yellow( + serviceName + )} with deployId ${color.yellow( + deployId + )} that has moduleOverrides for modules that don't exist in the service ${color.yellow( + serviceDirPath + )}. Please make sure ${color.yellow( + modulesNotFoundInServiceYaml.join(", ") + )} spelled correctly ` + ); + } + + const { [FACADE_MODULE_NAME]: facadeModule, ...otherModules } = + serviceConfigModules; + + return [ + ...Object.entries(otherModules).map( + ([moduleName, mod]): DeployInfoModule => ({ + moduleConfig: overrideModule(mod, overrideModules, moduleName), + moduleName, + }) + ), + { + moduleConfig: overrideModule( + facadeModule, + overrideModules, + FACADE_MODULE_NAME + ), + moduleName: FACADE_MODULE_NAME, + }, + ]; +}; + /* eslint-disable camelcase */ type JSONModuleConf = { name: string; @@ -617,9 +717,9 @@ type DeployServiceArg = Readonly<{ }>; /** - * Deploy by first uploading .wasm files and configs, possibly creating a new blueprint - * @param param0 DeployServiceOptions - * @returns Promise + * Deploy each service using `aqua remote deploy_service` + * @param param0 Everything that's needed to deploy a service + * @returns Promise of deployed service config with service name and id */ const deployService = async ({ deployJSON, diff --git a/src/commands/remove.ts b/src/commands/remove.ts index f67905bd3..34e106ad8 100644 --- a/src/commands/remove.ts +++ b/src/commands/remove.ts @@ -118,7 +118,6 @@ export const removeApp = async ( {}, appConfig.services )}\n\nDo you want to remove all of them?`, - default: false, }) : true; diff --git a/src/commands/service/repl.ts b/src/commands/service/repl.ts index 0352c6531..b846b4818 100644 --- a/src/commands/service/repl.ts +++ b/src/commands/service/repl.ts @@ -24,7 +24,10 @@ import color from "@oclif/color"; import { CliUx, Command } from "@oclif/core"; import { initReadonlyFluenceConfig } from "../../lib/configs/project/fluence"; -import { initReadonlyModuleConfig } from "../../lib/configs/project/module"; +import { + initReadonlyModuleConfig, + MODULE_TYPE_RUST, +} from "../../lib/configs/project/module"; import { FACADE_MODULE_NAME, initReadonlyServiceConfig, @@ -55,7 +58,8 @@ import { ensureCargoDependency } from "../../lib/rust"; const NAME_OR_PATH_OR_URL = "NAME | PATH | URL"; export default class REPL extends Command { - static override description = "Open service inside repl"; + static override description = + "Open service inside repl (downloads and builds modules if necessary)"; static override examples = ["<%= config.bin %> <%= command.id %>"]; static override flags = { ...NO_INPUT_FLAG, @@ -114,6 +118,10 @@ export default class REPL extends Command { FS_OPTIONS ); + if (!isInteractive) { + return; + } + spawn( await ensureCargoDependency({ name: MREPL_CARGO_DEPENDENCY, @@ -220,7 +228,7 @@ const ensureModuleConfigs = ({ loggingMask, } = overriddenModules; - if (type === "rust") { + if (type === MODULE_TYPE_RUST) { await marineCli({ command: "build", flags: { release: true }, diff --git a/src/lib/configs/project/fluence.ts b/src/lib/configs/project/fluence.ts index 6d6f6dfdb..e49be50fb 100644 --- a/src/lib/configs/project/fluence.ts +++ b/src/lib/configs/project/fluence.ts @@ -34,6 +34,7 @@ import { ConfigValidateFunction, } from "../initConfig"; +import { MODULE_TYPES } from "./module"; import type { ModuleV0 as ServiceModuleConfig } from "./service"; type ServiceV0 = { name: string; count?: number }; @@ -62,12 +63,19 @@ const configSchemaV0: JSONSchemaType = { required: ["version", "services"], }; +export const DISTRIBUTION_EVEN = "even"; +export const DISTRIBUTION_RANDOM = "random"; +export const DISTRIBUTIONS = [DISTRIBUTION_EVEN, DISTRIBUTION_RANDOM] as const; + export type OverrideModules = Record; +export type Distribution = typeof DISTRIBUTIONS[number]; export type ServiceDeployV1 = { deployId: string; count?: number; peerId?: string; + peerIds?: Array; overrideModules?: OverrideModules; + distribution?: Distribution; }; export type FluenceConfigModule = Partial; @@ -110,13 +118,29 @@ const configSchemaV1: JSONSchemaType = { type: "string", nullable: true, }, + peerIds: { + type: "array", + items: { + type: "string", + }, + nullable: true, + }, + distribution: { + type: "string", + enum: DISTRIBUTIONS, + nullable: true, + }, overrideModules: { type: "object", additionalProperties: { type: "object", properties: { get: { type: "string", nullable: true }, - type: { type: "string", nullable: true, enum: ["rust"] }, + type: { + type: "string", + nullable: true, + enum: MODULE_TYPES, + }, name: { type: "string", nullable: true }, maxHeapSize: { type: "string", nullable: true }, loggerEnabled: { type: "boolean", nullable: true }, @@ -230,8 +254,12 @@ services: # You can access deployment info in aqua like this: # services <- App.services() # on services.someService.default!.peerId: + distribution: even # Deploy strategy. Can also be 'random'. Default: 'even' peerId: MY_PEER # Peer id or peer id name to deploy on. Default: Random peer id is selected for each deploy - count: 1 # How many times to deploy. Default: 1 + peerIds: # Overrides peerId property. Can be used to deploy on multiple peers. + - 12D3KooWR4cv1a8tv7pps4HH6wePNaK6gf1Hww5wcCMzeWxyNw51 + - MY_PEER + count: 1 # How many times to deploy. Default: 1 or if peerIds is provided - exactly the number of peerIds # overrideModules: # Override modules from service.yaml # facade: # get: ./relative/path # Override facade module diff --git a/src/lib/configs/project/module.ts b/src/lib/configs/project/module.ts index c7d7f7116..b76547410 100644 --- a/src/lib/configs/project/module.ts +++ b/src/lib/configs/project/module.ts @@ -28,10 +28,16 @@ import { GetDefaultConfig, } from "../initConfig"; +export const MODULE_TYPE_RUST = "rust"; +export const MODULE_TYPE_COMPILED = "compiled"; +export const MODULE_TYPES = [MODULE_TYPE_RUST, MODULE_TYPE_COMPILED] as const; + +export type ModuleType = typeof MODULE_TYPES[number]; + export type ConfigV0 = { version: 0; name: string; - type?: "rust" | "compiled"; + type?: ModuleType; maxHeapSize?: string; loggerEnabled?: boolean; loggingMask?: number; @@ -45,7 +51,7 @@ const configSchemaV0: JSONSchemaType = { type: "object", properties: { version: { type: "number", enum: [0] }, - type: { type: "string", enum: ["rust", "compiled"], nullable: true }, + type: { type: "string", enum: MODULE_TYPES, nullable: true }, name: { type: "string" }, maxHeapSize: { type: "string", nullable: true }, loggerEnabled: { type: "boolean", nullable: true }, @@ -120,7 +126,7 @@ const getDefault: (name: string) => GetDefaultConfig = (name: string): GetDefaultConfig => (): LatestConfig => ({ version: 0, - type: "rust", + type: MODULE_TYPE_RUST, name, }); diff --git a/src/lib/configs/project/service.ts b/src/lib/configs/project/service.ts index d8001e735..b2e5e4daa 100644 --- a/src/lib/configs/project/service.ts +++ b/src/lib/configs/project/service.ts @@ -30,7 +30,7 @@ import { ConfigValidateFunction, } from "../initConfig"; -import type { ConfigV0 as ModuleConfig } from "./module"; +import { ConfigV0 as ModuleConfig, MODULE_TYPES } from "./module"; export type ModuleV0 = { get: string; @@ -42,7 +42,7 @@ const moduleSchema: JSONSchemaType = { type: "object", properties: { get: { type: "string" }, - type: { type: "string", nullable: true, enum: ["rust", "compiled"] }, + type: { type: "string", nullable: true, enum: MODULE_TYPES }, name: { type: "string", nullable: true }, maxHeapSize: { type: "string", nullable: true }, loggerEnabled: { type: "boolean", nullable: true }, diff --git a/src/lib/helpers/downloadFile.ts b/src/lib/helpers/downloadFile.ts index 06e7ed355..8d0992d34 100644 --- a/src/lib/helpers/downloadFile.ts +++ b/src/lib/helpers/downloadFile.ts @@ -23,6 +23,7 @@ import decompress from "decompress"; import filenamify from "filenamify"; import fetch from "node-fetch"; +import { MODULE_TYPE_RUST } from "../configs/project/module"; import { WASM_EXT } from "../const"; import { ensureFluenceModulesDir, ensureFluenceServicesDir } from "../paths"; import { input } from "../prompt"; @@ -135,7 +136,7 @@ export const getModuleWasmPath = (config: { }): string => { const fileName = `${config.name}.${WASM_EXT}`; const configDirName = path.dirname(config.$getPath()); - return config.type === "rust" + return config.type === MODULE_TYPE_RUST ? path.resolve(configDirName, "target", "wasm32-wasi", "release", fileName) : path.resolve(configDirName, fileName); }; diff --git a/src/lib/multiaddr.ts b/src/lib/multiaddr.ts index 97c4176de..34218defd 100644 --- a/src/lib/multiaddr.ts +++ b/src/lib/multiaddr.ts @@ -69,6 +69,10 @@ const getIds = (nodes: Array): Array => export const getRandomRelayId = (relays: Relays): string => { const addrs = resolveAddrs(relays); const ids = getIds(addrs); + return getRandomRelayIdFromTheList(ids); +}; + +export const getRandomRelayIdFromTheList = (ids: Array): string => { const largestIndex = ids.length - 1; const randomIndex = Math.round(Math.random() * largestIndex); @@ -77,3 +81,47 @@ export const getRandomRelayId = (relays: Relays): string => { return randomRelayId; }; + +export const getEvenlyDistributedIds = ( + relays: Relays, + count = 1 +): Array => { + const addrs = resolveAddrs(relays); + const ids = getIds(addrs); + return getEvenlyDistributedIdsFromTheList(ids, count); +}; + +const offsets = new Map(); + +/** + * + * @param ids List of ids from which a new list with the same ids is created + * @param count Amount of the ids to return + * @returns evenly distributed list of ids + * + * ALERT! This function is not pure because it uses `offsets` map to store + * offsets for each unique collection of ids. Each time the function is executed + * - offset changes by the `count` number but it never becomes larger then + * `ids.length`. It's implemented this way because it allows to have even + * distribution of ids across different deploys of different services + */ +export const getEvenlyDistributedIdsFromTheList = ( + ids: Array, + count = ids.length +): Array => { + const result: Array = []; + const sortedIds = ids.sort(); + const key = JSON.stringify(sortedIds); + let offset = offsets.get(key) ?? 0; + + for (let i = 0; i < count; i = i + 1) { + const id = sortedIds[(i + offset) % sortedIds.length]; + assert(typeof id === "string"); + result.push(id); + } + + offset = (offset + count) % sortedIds.length; + offsets.set(key, offset); + + return result; +}; diff --git a/src/lib/typeHelpers.ts b/src/lib/typeHelpers.ts index c9ad43292..d1880fe91 100644 --- a/src/lib/typeHelpers.ts +++ b/src/lib/typeHelpers.ts @@ -49,9 +49,9 @@ export const hasKey = ( * const unknown: unknown = { a: 1 } * assertHasKey('b', unknown, 'unknown must have "b" key') * // throws AssertionError({ message: 'unknown must have "b" key' }) - * @param key K extends string - * @param unknown unknown - * @param message string + * @param key any string key + * @param unknown any value + * @param message error message * @returns void */ export function assertHasKey(