From d820a4605f5180e7edb5d139a86eee9c3a6e97a0 Mon Sep 17 00:00:00 2001 From: hopeyen Date: Wed, 9 Nov 2022 01:33:46 +0100 Subject: [PATCH] indexer-common,cli: Add `actions update ...` command to CLI --- packages/indexer-cli/package.json | 2 +- .../src/__tests__/indexer/actions.test.ts | 4 +- .../references/indexer-actions.stdout | 1 + .../__tests__/references/indexer-help.stdout | 1 + packages/indexer-cli/src/actions.ts | 129 +++++++++++++++++- packages/indexer-cli/src/command-helpers.ts | 6 +- .../src/commands/indexer/actions.ts | 2 +- .../src/commands/indexer/actions/approve.ts | 2 +- .../src/commands/indexer/actions/get.ts | 2 +- .../src/commands/indexer/actions/queue.ts | 12 +- .../src/commands/indexer/actions/update.ts | 121 ++++++++++++++++ .../indexer/allocations/reallocate.ts | 22 +-- packages/indexer-common/src/actions.ts | 15 ++ .../__tests__/resolvers/actions.ts | 86 +++++++++++- .../src/indexer-management/actions.ts | 21 +++ .../src/indexer-management/client.ts | 14 ++ .../indexer-management/resolvers/actions.ts | 30 ++++ 17 files changed, 431 insertions(+), 39 deletions(-) create mode 100644 packages/indexer-cli/src/commands/indexer/actions/update.ts diff --git a/packages/indexer-cli/package.json b/packages/indexer-cli/package.json index 403e8fbfb..a440c8627 100644 --- a/packages/indexer-cli/package.json +++ b/packages/indexer-cli/package.json @@ -16,7 +16,7 @@ "scripts": { "format": "prettier --write 'src/**/*.ts'", "lint": "eslint . --ext .ts,.tsx --fix", - "compile": "tsc", + "compile": "tsc --build", "prepare": "yarn format && yarn lint && yarn compile", "disputes": "yarn prepare && ./dist/cli.js indexer disputes get", "clean": "rm -rf ./node_modules ./dist ./tsconfig.tsbuildinfo", diff --git a/packages/indexer-cli/src/__tests__/indexer/actions.test.ts b/packages/indexer-cli/src/__tests__/indexer/actions.test.ts index 64a3b76e4..516b0da1c 100644 --- a/packages/indexer-cli/src/__tests__/indexer/actions.test.ts +++ b/packages/indexer-cli/src/__tests__/indexer/actions.test.ts @@ -9,7 +9,7 @@ describe('Indexer actions tests', () => { afterAll(teardown) describe('Actions help', () => { cliTest('Indexer actions', ['indexer', 'actions'], 'references/indexer-actions', { - expectedExitCode: 1, + expectedExitCode: 255, cwd: baseDir, timeout: 10000, }) @@ -18,7 +18,7 @@ describe('Indexer actions tests', () => { ['indexer', 'actions', '--help'], 'references/indexer-actions', { - expectedExitCode: 1, + expectedExitCode: 255, cwd: baseDir, timeout: 10000, }, diff --git a/packages/indexer-cli/src/__tests__/references/indexer-actions.stdout b/packages/indexer-cli/src/__tests__/references/indexer-actions.stdout index 350144569..dac4ea9e4 100644 --- a/packages/indexer-cli/src/__tests__/references/indexer-actions.stdout +++ b/packages/indexer-cli/src/__tests__/references/indexer-actions.stdout @@ -1,5 +1,6 @@ Manage indexer actions + indexer actions update Update one or more actions indexer actions queue Queue an action item indexer actions get List one or more actions indexer actions execute Execute approved items in the action queue diff --git a/packages/indexer-cli/src/__tests__/references/indexer-help.stdout b/packages/indexer-cli/src/__tests__/references/indexer-help.stdout index 268048e5f..ce21053b7 100644 --- a/packages/indexer-cli/src/__tests__/references/indexer-help.stdout +++ b/packages/indexer-cli/src/__tests__/references/indexer-help.stdout @@ -22,6 +22,7 @@ Manage indexer configuration indexer allocations create Create an allocation indexer allocations close Close an allocation indexer allocations Manage indexer allocations + indexer actions update Update one or more actions indexer actions queue Queue an action item indexer actions get List one or more actions indexer actions execute Execute approved items in the action queue diff --git a/packages/indexer-cli/src/actions.ts b/packages/indexer-cli/src/actions.ts index e654f9a77..7da3624c2 100644 --- a/packages/indexer-cli/src/actions.ts +++ b/packages/indexer-cli/src/actions.ts @@ -5,12 +5,16 @@ import { ActionResult, ActionStatus, ActionType, + ActionUpdateInput, IndexerManagementClient, + nullPassThrough, OrderDirection, + parseBoolean, } from '@graphprotocol/indexer-common' -import { validateRequiredParams } from './command-helpers' +import { validatePOI, validateRequiredParams } from './command-helpers' import gql from 'graphql-tag' import { utils } from 'ethers' +import { parseGRT } from '@graphprotocol/common-ts' export interface GenericActionInputParams { targetDeployment: string @@ -101,6 +105,63 @@ export async function validateActionInput( ) } +export function validateActionType(input: string): ActionType { + const validVariants = Object.keys(ActionType).map(variant => + variant.toLocaleLowerCase(), + ) + if (!validVariants.includes(input.toLocaleLowerCase())) { + throw Error( + `Invalid 'ActionType' "${input}", must be one of ['${validVariants.join(`', '`)}']`, + ) + } + return ActionType[input.toUpperCase() as keyof typeof ActionType] +} + +export function validateActionStatus(input: string): ActionStatus { + const validVariants = Object.keys(ActionStatus).map(variant => + variant.toLocaleLowerCase(), + ) + if (!validVariants.includes(input.toLocaleLowerCase())) { + throw Error( + `Invalid 'ActionStatus' "${input}", must be one of ['${validVariants.join( + `', '`, + )}']`, + ) + } + return ActionStatus[input.toUpperCase() as keyof typeof ActionStatus] +} + +export function buildActionFilter( + id: string | undefined, + type: string | undefined, + status: string | undefined, + source: string | undefined, + reason: string | undefined, +): ActionFilter { + const filter: ActionFilter = {} + if (id) { + filter.id = +id + } + if (type) { + filter.type = validateActionType(type) + } + if (status) { + filter.status = validateActionStatus(status) + } + if (source) { + filter.source = source + } + if (reason) { + filter.reason = reason + } + if (Object.keys(filter).length === 0) { + throw Error( + `No action filter provided, please specify at least one filter using ['--id', '--type', '--status', '--source', '--reason']`, + ) + } + return filter +} + export async function queueActions( client: IndexerManagementClient, actions: ActionInput[], @@ -135,6 +196,35 @@ export async function queueActions( return result.data.queueActions } +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const ACTION_PARAMS_PARSERS: Record any> = { + deploymentID: x => nullPassThrough(x), + allocationID: x => x, + amount: nullPassThrough(parseGRT), + poi: nullPassThrough((x: string) => validatePOI(x)), + force: x => parseBoolean(x), + type: x => validateActionType(x), + status: x => validateActionStatus(x), + reason: nullPassThrough, +} + +/** + * Parses a user-provided action update input into a normalized form. + */ +export const parseActionUpdateInput = (input: object): ActionUpdateInput => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const obj = {} as any + for (const [key, value] of Object.entries(input)) { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + obj[key] = (ACTION_PARAMS_PARSERS as any)[key](value) + } catch { + throw new Error(key) + } + } + return obj as ActionUpdateInput +} + export async function executeApprovedActions( client: IndexerManagementClient, ): Promise { @@ -357,3 +447,40 @@ export async function deleteActions( return result.data.deleteActions } + +export async function updateActions( + client: IndexerManagementClient, + filter: ActionFilter, + action: ActionUpdateInput, +): Promise { + const result = await client + .mutation( + gql` + mutation updateActions($filter: ActionFilter!, $action: ActionUpdateInput!) { + updateActions(filter: $filter, action: $action) { + id + type + allocationID + deploymentID + amount + poi + force + source + reason + priority + transaction + status + failureReason + } + } + `, + { filter, action }, + ) + .toPromise() + + if (result.error) { + throw result.error + } + + return result.data.updateActions +} diff --git a/packages/indexer-cli/src/command-helpers.ts b/packages/indexer-cli/src/command-helpers.ts index 5009c971d..a66b092b7 100644 --- a/packages/indexer-cli/src/command-helpers.ts +++ b/packages/indexer-cli/src/command-helpers.ts @@ -153,7 +153,7 @@ export async function validateRequiredParams( } } -export async function validatePOI(poi: string | undefined): Promise { +export function validatePOI(poi: string | undefined): string | undefined { if (poi !== undefined) { if (typeof poi == 'number' && poi == 0) { poi = utils.hexlify(Array(32).fill(0)) @@ -161,7 +161,9 @@ export async function validatePOI(poi: string | undefined): Promise action.id) if (numericActionIDs.length === 0) { - throw Error(`No 'queued' actions found.`) + throw Error(`No 'queued' actions found`) } } else { numericActionIDs = actionIDs.map(action => +action) diff --git a/packages/indexer-cli/src/commands/indexer/actions/get.ts b/packages/indexer-cli/src/commands/indexer/actions/get.ts index 41db0f108..dc805ca34 100644 --- a/packages/indexer-cli/src/commands/indexer/actions/get.ts +++ b/packages/indexer-cli/src/commands/indexer/actions/get.ts @@ -139,7 +139,7 @@ module.exports = { } if (!['undefined', 'number'].includes(typeof first)) { - throw Error(`Invalid value for '--first' option, must have a numeric value.`) + throw Error(`Invalid value for '--first' option, must have a numeric value`) } if (!['undefined', 'string'].includes(typeof fields)) { diff --git a/packages/indexer-cli/src/commands/indexer/actions/queue.ts b/packages/indexer-cli/src/commands/indexer/actions/queue.ts index bbaed775b..76ff9f07d 100644 --- a/packages/indexer-cli/src/commands/indexer/actions/queue.ts +++ b/packages/indexer-cli/src/commands/indexer/actions/queue.ts @@ -4,8 +4,8 @@ import chalk from 'chalk' import { loadValidatedConfig } from '../../../config' import { createIndexerManagementClient } from '../../../client' import { printObjectOrArray } from '../../../command-helpers' -import { buildActionInput, queueActions } from '../../../actions' -import { ActionInput, ActionStatus, ActionType } from '@graphprotocol/indexer-common' +import { buildActionInput, queueActions, validateActionType } from '../../../actions' +import { ActionInput, ActionStatus } from '@graphprotocol/indexer-common' const HELP = ` ${chalk.bold( @@ -65,14 +65,8 @@ module.exports = { ) } - if (!['allocate', 'unallocate', 'reallocate'].includes(type)) { - throw Error( - `Invalid 'ActionType' "${type}", must be one of ['allocate', 'unallocate', 'reallocate']`, - ) - } - actionInputParams = await buildActionInput( - ActionType[type.toUpperCase() as keyof typeof ActionType], + validateActionType(type), { targetDeployment, param1, param2, param3, param4 }, decisionSource, decisionReason, diff --git a/packages/indexer-cli/src/commands/indexer/actions/update.ts b/packages/indexer-cli/src/commands/indexer/actions/update.ts new file mode 100644 index 000000000..4beaaaaf2 --- /dev/null +++ b/packages/indexer-cli/src/commands/indexer/actions/update.ts @@ -0,0 +1,121 @@ +import { GluegunToolbox } from 'gluegun' +import chalk from 'chalk' + +import { Action, ActionFilter, ActionUpdateInput } from '@graphprotocol/indexer-common' +import { loadValidatedConfig } from '../../../config' +import { createIndexerManagementClient } from '../../../client' +import { fixParameters, printObjectOrArray } from '../../../command-helpers' +import { + buildActionFilter, + parseActionUpdateInput, + updateActions, +} from '../../../actions' +import { partition } from '@thi.ng/iterators' + +const HELP = ` +${chalk.bold('graph indexer actions update')} [options] [ ...] + +${chalk.dim('Options:')} + + -h, --help Show usage information + --id Filter by actionID + --type allocate|unallocate|reallocate Filter by type + --status queued|approved|pending|success|failed|canceled Filter by status + --source Filter by source + --reason Filter by reason string + -o, --output table|json|yaml Choose the output format: table (default), JSON, or YAML +` + +module.exports = { + name: 'update', + alias: [], + description: 'Update one or more actions', + run: async (toolbox: GluegunToolbox) => { + const { print, parameters } = toolbox + + const inputSpinner = toolbox.print.spin('Processing inputs') + + const { id, type, status, source, reason, h, help, o, output } = parameters.options + + const [...setValues] = fixParameters(parameters, { h, help }) || [] + let updateActionInput: ActionUpdateInput = {} + let actionFilter: ActionFilter = {} + + const outputFormat = o || output || 'table' + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (help || h) { + inputSpinner.stopAndPersist({ symbol: '💁', text: HELP }) + return + } + try { + if (!['json', 'yaml', 'table'].includes(outputFormat)) { + throw Error( + `Invalid output format "${outputFormat}" must be one of ['json', 'yaml' or 'table']`, + ) + } + + // 1. Convert all `null` strings to real nulls, and other values + // to regular JS strings (which for some reason they are not...) + const kvs = setValues.map(param => (param === 'null' ? null : param.toString())) + + // 2. Check that all key/value pairs are complete and + // there's no value missing at the end + if (kvs.length % 2 !== 0) { + throw Error(`An uneven number of key/value pairs was passed in: ${kvs.join(' ')}`) + } + + updateActionInput = parseActionUpdateInput({ + ...Object.fromEntries([...partition(2, 2, kvs)]), + }) + + actionFilter = buildActionFilter(id, type, status, source, reason) + + inputSpinner.succeed('Processed input parameters') + } catch (error) { + inputSpinner.fail(error.toString()) + print.info(HELP) + process.exitCode = 1 + return + } + + const actionSpinner = toolbox.print.spin('Updating actions') + + try { + const config = loadValidatedConfig() + const client = await createIndexerManagementClient({ url: config.api }) + + const actionsUpdated = await updateActions(client, actionFilter, updateActionInput) + + if (!actionsUpdated || actionsUpdated.length === 0) { + print.info('No actions found') + process.exitCode = 1 + return + } + + actionSpinner.succeed(`'${actionsUpdated.length}' actions updated`) + + const displayProperties: (keyof Action)[] = [ + 'id', + 'type', + 'deploymentID', + 'allocationID', + 'amount', + 'poi', + 'force', + 'priority', + 'status', + 'source', + 'failureReason', + 'transaction', + 'reason', + ] + + printObjectOrArray(print, outputFormat, actionsUpdated, displayProperties) + } catch (error) { + actionSpinner.fail(error.toString()) + process.exitCode = 1 + return + } + }, +} diff --git a/packages/indexer-cli/src/commands/indexer/allocations/reallocate.ts b/packages/indexer-cli/src/commands/indexer/allocations/reallocate.ts index df7452958..3f1c5c579 100644 --- a/packages/indexer-cli/src/commands/indexer/allocations/reallocate.ts +++ b/packages/indexer-cli/src/commands/indexer/allocations/reallocate.ts @@ -3,9 +3,9 @@ import chalk from 'chalk' import { loadValidatedConfig } from '../../../config' import { createIndexerManagementClient } from '../../../client' -import { BigNumber, utils } from 'ethers' +import { BigNumber } from 'ethers' import { reallocateAllocation } from '../../../allocations' -import { printObjectOrArray } from '../../../command-helpers' +import { printObjectOrArray, validatePOI } from '../../../command-helpers' const HELP = ` ${chalk.bold('graph indexer allocations reallocate')} [options] @@ -59,24 +59,8 @@ module.exports = { return } - if (poi !== undefined) { - if (typeof poi == 'number' && poi == 0) { - poi = utils.hexlify(Array(32).fill(0)) - } - try { - // Ensure user provided POI is formatted properly - '0x...' (32 bytes) - const isHex = utils.isHexString(poi, 32) - if (!isHex) { - throw new Error('Must be a 32 byte length hex string') - } - } catch (error) { - spinner.fail(`Invalid POI provided, '${poi}'. ` + error.toString()) - process.exitCode = 1 - return - } - } - try { + await validatePOI(poi) const allocationAmount = BigNumber.from(amount) const config = loadValidatedConfig() const client = await createIndexerManagementClient({ url: config.api }) diff --git a/packages/indexer-common/src/actions.ts b/packages/indexer-common/src/actions.ts index 0aa06593c..cf22f25dd 100644 --- a/packages/indexer-common/src/actions.ts +++ b/packages/indexer-common/src/actions.ts @@ -16,6 +16,18 @@ export interface ActionItem { params: ActionParamsInput type: ActionType reason: string + status?: ActionStatus +} + +export interface ActionUpdateInput { + deploymentID?: string + allocationID?: string + amount?: string + poi?: string + force?: boolean + type?: ActionType + status?: ActionStatus + reason?: string } export interface ActionInput { @@ -125,6 +137,7 @@ export const validateActionInputs = async ( } export interface ActionFilter { + id?: number | undefined type?: ActionType status?: ActionStatus source?: string @@ -158,6 +171,8 @@ export interface ActionResult { reason: string status: ActionStatus priority: number | undefined + failureReason: string | null + transaction: string | null } export enum ActionType { diff --git a/packages/indexer-common/src/indexer-management/__tests__/resolvers/actions.ts b/packages/indexer-common/src/indexer-management/__tests__/resolvers/actions.ts index c1fb2b585..d3fcd6c33 100644 --- a/packages/indexer-common/src/indexer-management/__tests__/resolvers/actions.ts +++ b/packages/indexer-common/src/indexer-management/__tests__/resolvers/actions.ts @@ -103,6 +103,26 @@ const CANCEL_ACTIONS_MUTATION = gql` } ` +const UPDATE_ACTIONS_MUTATION = gql` + mutation updateActions($filter: ActionFilter!, $action: ActionUpdateInput!) { + updateActions(filter: $filter, action: $action) { + id + type + allocationID + deploymentID + amount + poi + force + source + reason + priority + transaction + failureReason + status + } + } +` + const ACTIONS_QUERY = gql` query actions( $filter: ActionFilter! @@ -184,7 +204,7 @@ const allocateToNotPublishedDeployment = { priority: 0, } as ActionInput -const queuedUnallocateAction = { +const invalidUnallocateAction = { status: ActionStatus.QUEUED, type: ActionType.UNALLOCATE, allocationID: '0x8f63930129e585c69482b56390a09b6b176f4a4c', @@ -767,7 +787,7 @@ describe('Actions', () => { }) test('Reject unallocate action with inactive allocationID', async () => { - const inputActions = [queuedUnallocateAction] + const inputActions = [invalidUnallocateAction] await expect( client.mutation(QUEUE_ACTIONS_MUTATION, { actions: inputActions }).toPromise(), @@ -928,4 +948,66 @@ describe('Actions', () => { await actionInputToExpected(successfulAction, 1), ]) }) + + test('Update all queued unallocate actions', async () => { + const queuedUnallocateAction = { + status: ActionStatus.QUEUED, + type: ActionType.UNALLOCATE, + deploymentID: subgraphDeployment1, + amount: '10000', + force: false, + source: 'indexerAgent', + reason: 'indexingRule', + priority: 0, + } as ActionInput + + const queuedAllocateAction = { + status: ActionStatus.QUEUED, + type: ActionType.ALLOCATE, + deploymentID: subgraphDeployment1, + force: false, + amount: '10000', + source: 'indexerAgent', + reason: 'indexingRule', + priority: 0, + } as ActionInput + + await managementModels.Action.create(queuedUnallocateAction, { + validate: true, + returning: true, + }) + + const queuedAllocateAction1 = { ...queuedAllocateAction } + const queuedAllocateAction2 = { ...queuedAllocateAction } + queuedAllocateAction2.deploymentID = subgraphDeployment2 + + const inputActions = [queuedAllocateAction1, queuedAllocateAction2] + const expecteds = ( + await Promise.all( + inputActions.sort().map(async (action, key) => { + return await actionInputToExpected(action, key + 1) + }), + ) + ).sort((a, b) => a.id - b.id) + + await expect( + client.mutation(QUEUE_ACTIONS_MUTATION, { actions: inputActions }).toPromise(), + ).resolves.toHaveProperty('data.queueActions', expecteds) + + const updatedExpecteds = expecteds.map((value) => { + value.force = true + return value + }) + + await expect( + client + .mutation(UPDATE_ACTIONS_MUTATION, { + filter: { type: 'allocate' }, + action: { + force: true, + }, + }) + .toPromise(), + ).resolves.toHaveProperty('data.updateActions', updatedExpecteds) + }) }) diff --git a/packages/indexer-common/src/indexer-management/actions.ts b/packages/indexer-common/src/indexer-management/actions.ts index 6a8a7cd91..a6952affc 100644 --- a/packages/indexer-common/src/indexer-management/actions.ts +++ b/packages/indexer-common/src/indexer-management/actions.ts @@ -4,6 +4,7 @@ import { actionFilterToWhereOptions, ActionParams, ActionStatus, + ActionUpdateInput, AllocationManagementMode, AllocationResult, AllocationStatus, @@ -190,4 +191,24 @@ export class ActionManager { limit: first, }) } + + public static async updateActions( + models: IndexerManagementModels, + action: ActionUpdateInput, + filter: ActionFilter, + ): Promise<[number, Action[]]> { + if (Object.keys(filter).length === 0) { + throw Error( + 'Cannot bulk update actions without a filter, please provide a least 1 filter value', + ) + } + return await models.Action.update( + { ...action }, + { + where: actionFilterToWhereOptions(filter), + returning: true, + validate: true, + }, + ) + } } diff --git a/packages/indexer-common/src/indexer-management/client.ts b/packages/indexer-common/src/indexer-management/client.ts index 8f650990c..baf310bb0 100644 --- a/packages/indexer-common/src/indexer-management/client.ts +++ b/packages/indexer-common/src/indexer-management/client.ts @@ -171,6 +171,18 @@ const SCHEMA_SDL = gql` priority: Int } + input ActionUpdateInput { + id: Int + deploymentID: String + allocationID: String + amount: Int + poi: String + force: Boolean + type: ActionType + status: ActionStatus + reason: String + } + enum ActionParams { id status @@ -205,6 +217,7 @@ const SCHEMA_SDL = gql` } input ActionFilter { + id: Int type: ActionType status: String source: String @@ -404,6 +417,7 @@ const SCHEMA_SDL = gql` ): ReallocateAllocationResult! updateAction(action: ActionInput!): Action! + updateActions(filter: ActionFilter!, action: ActionUpdateInput!): [Action]! queueActions(actions: [ActionInput!]!): [Action]! cancelActions(actionIDs: [String!]!): [Action]! deleteActions(actionIDs: [String!]!): Int! diff --git a/packages/indexer-common/src/indexer-management/resolvers/actions.ts b/packages/indexer-common/src/indexer-management/resolvers/actions.ts index 729ab44ca..b67de0c22 100644 --- a/packages/indexer-common/src/indexer-management/resolvers/actions.ts +++ b/packages/indexer-common/src/indexer-management/resolvers/actions.ts @@ -10,6 +10,7 @@ import { ActionResult, ActionStatus, ActionType, + ActionUpdateInput, IndexerManagementModels, OrderDirection, validateActionInputs, @@ -263,6 +264,35 @@ export default { return updatedActions[0] }, + updateActions: async ( + { + filter, + action, + }: { + filter: ActionFilter + action: ActionUpdateInput + }, + { logger, models }: IndexerManagementResolverContext, + ): Promise => { + logger.debug(`Execute 'updateActions' mutation`, { + filter, + action, + }) + + const results = await ActionManager.updateActions(models, action, filter) + + if (results[0] === 0) { + const msg = `Actions update failed: No action was matched by the filter, '${JSON.stringify( + filter, + )}'` + logger.debug(msg) + throw Error(msg) + } + logger.info(`'${results[0]}' actions updated`) + + return results[1] + }, + approveActions: async ( { actionIDs }: { actionIDs: number[] }, { logger, models }: IndexerManagementResolverContext,