diff --git a/CHANGELOG.md b/CHANGELOG.md index d59c78e63b9..a60e1b266a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - `@cumulus/deployment` deploys DynamoDB streams for the Collections, Providers and Rules tables as well as a new lambda function called `dbIndexer`. The `dbIndexer` lambda has an event source mapping which listens to each of the DynamoDB streams. The dbIndexer lambda receives events referencing operations on the DynamoDB table and updates the elasticsearch cluster accordingly. - The `@cumulus/api` endpoints for collections, providers and rules _only_ query DynamoDB, with the exception of LIST endpoints and the collections' GET endpoint. +- **CUMULUS-260: "PDR page on dashboard only shows zeros."** The PDR stats in LPDAAC are all 0s, even if the dashboard has been fixed to retrieve the correct fields. The current version of pdr-status-check has a few issues. + - pdr is not included in the input/output schema. It's available from the input event. So the pdr status and stats are not updated when the ParsePdr workflow is complete. Adding the pdr to the input/output of the task will fix this. + - pdr-status-check doesn't update pdr stats which prevent the real time pdr progress from showing up in the dashboard. To solve this, added lambda function sf-sns-report which is copied from @cumulus/api/lambdas/sf-sns-broadcast with modification, sf-sns-report can be used to report step function status anywhere inside a step function. So add step sf-sns-report after each pdr-status-check, we will get the PDR status progress at real time. + - It's possible an execution is still in the queue and doesn't exist in sfn yet. Added code to handle 'ExecutionDoesNotExist' error when checking the execution status. + ### Updated - Broke up `kes.override.js` of @cumulus/deployment to multiple modules and moved to a new location - Expanded @cumulus/deployment test coverage diff --git a/cumulus/tasks/pdr-status-check/index.js b/cumulus/tasks/pdr-status-check/index.js index 75f06f25815..74847119843 100644 --- a/cumulus/tasks/pdr-status-check/index.js +++ b/cumulus/tasks/pdr-status-check/index.js @@ -78,7 +78,8 @@ function logStatus(output) { * failed: [ * { arn: 'arn:456', reason: 'Workflow Aborted' } * ], - * completed: [] + * completed: [], + * pdr: {} * } * * @param {Object} event - the event that came into checkPdrStatuses @@ -117,7 +118,8 @@ function buildOutput(event, groupedExecutions) { isFinished: groupedExecutions.running.length === 0, running, failed, - completed + completed, + pdr: event.input.pdr }; if (!output.isFinished) { @@ -137,28 +139,36 @@ function buildOutput(event, groupedExecutions) { * @returns {Promise.} - an object describing the status of Step * Function executions related to a PDR */ -function checkPdrStatuses(event) { +async function checkPdrStatuses(event) { const runningExecutionArns = event.input.running || []; - const promisedExecutionDescriptions = runningExecutionArns.map((executionArn) => - aws.sfn().describeExecution({ executionArn }).promise()); - - return Promise.all(promisedExecutionDescriptions) - .then(groupExecutionsByStatus) - .then((groupedExecutions) => { - const counter = getCounterFromEvent(event) + 1; - const exceededLimit = counter >= getLimitFromEvent(event); + const executions = []; + for (const executionArn of runningExecutionArns) { + try { + const execution = await aws.sfn().describeExecution({ executionArn }).promise(); + executions.push(execution); + } + catch (e) { + // it's ok if a execution is still in the queue and has not be executed + if (e.code === 'ExecutionDoesNotExist') { + executions.push({ executionArn: executionArn, status: 'RUNNING' }); + } + else throw e; + } + } - const executionsAllDone = groupedExecutions.running.length === 0; + const groupedExecutions = groupExecutionsByStatus(executions); + const counter = getCounterFromEvent(event) + 1; + const exceededLimit = counter >= getLimitFromEvent(event); - if (!executionsAllDone && exceededLimit) { - throw new IncompleteError(`PDR didn't complete after ${counter} checks`); - } + const executionsAllDone = groupedExecutions.running.length === 0; + if (!executionsAllDone && exceededLimit) { + throw new IncompleteError(`PDR didn't complete after ${counter} checks`); + } - const output = buildOutput(event, groupedExecutions); - if (!output.isFinished) logStatus(output); - return output; - }); + const output = buildOutput(event, groupedExecutions); + if (!output.isFinished) logStatus(output); + return output; } exports.checkPdrStatuses = checkPdrStatuses; diff --git a/cumulus/tasks/pdr-status-check/schemas/input.json b/cumulus/tasks/pdr-status-check/schemas/input.json index 82fe1fe8e52..7c8d82d1ba9 100644 --- a/cumulus/tasks/pdr-status-check/schemas/input.json +++ b/cumulus/tasks/pdr-status-check/schemas/input.json @@ -2,6 +2,7 @@ "title": "PdrStatusCheckInput", "description": "Describes the input expected by the pdr-status-check task", "type": "object", + "required": ["running", "pdr"], "properties": { "running": { "type": "array", @@ -23,6 +24,18 @@ }, "counter": { "type": "integer" }, "limit": { "type": "integer" }, - "isFinished": { "type": "boolean" } + "isFinished": { + "description": "Indicates whether all the step function executions of the PDR are in terminal states", + "type": "boolean" + }, + "pdr": { + "description": "Product Delivery Record", + "type": "object", + "required": ["name", "path"], + "properties": { + "name": { "type": "string" }, + "path": { "type": "string" } + } + } } } diff --git a/cumulus/tasks/pdr-status-check/schemas/output.json b/cumulus/tasks/pdr-status-check/schemas/output.json index 7bcc74594a3..0a9d4bfd381 100644 --- a/cumulus/tasks/pdr-status-check/schemas/output.json +++ b/cumulus/tasks/pdr-status-check/schemas/output.json @@ -2,6 +2,7 @@ "title": "PdrStatusCheckOutput", "description": "Describes the output produced by the pdr-status-check task", "type": "object", + "required": ["running", "completed", "failed", "isFinished", "pdr"], "properties": { "running": { "type": "array", @@ -23,6 +24,18 @@ }, "counter": { "type": "integer" }, "limit": { "type": "integer" }, - "isFinished": { "type": "boolean" } + "isFinished": { + "description": "Indicates whether all the step function executions of the PDR are in terminal states", + "type": "boolean" + }, + "pdr": { + "description": "Product Delivery Record", + "type": "object", + "required": ["name", "path"], + "properties": { + "name": { "type": "string" }, + "path": { "type": "string" } + } + } } } diff --git a/cumulus/tasks/pdr-status-check/tests/index.js b/cumulus/tasks/pdr-status-check/tests/index.js index 9b3d5b11fc6..4c8edc8f416 100644 --- a/cumulus/tasks/pdr-status-check/tests/index.js +++ b/cumulus/tasks/pdr-status-check/tests/index.js @@ -9,7 +9,8 @@ const { checkPdrStatuses } = require('../index'); test('valid output when no running executions', (t) => { const event = { input: { - running: [] + running: [], + pdr: { name: 'test.PDR', path: 'test-path' } } }; @@ -19,7 +20,8 @@ test('valid output when no running executions', (t) => { isFinished: true, running: [], failed: [], - completed: [] + completed: [], + pdr: { name: 'test.PDR', path: 'test-path' } }; t.deepEqual(output, expectedOutput); @@ -41,7 +43,8 @@ test('error thrown when limit exceeded', (t) => { input: { running: ['arn:123'], counter: 2, - limit: 3 + limit: 3, + pdr: { name: 'test.PDR', path: 'test-path' } } }; @@ -61,26 +64,35 @@ test('returns the correct results in the nominal case', (t) => { 'arn:1': 'RUNNING', 'arn:2': 'SUCCEEDED', 'arn:3': 'FAILED', - 'arn:4': 'ABORTED' + 'arn:4': 'ABORTED', + 'arn:7': null }; const stubSfnClient = { describeExecution: ({ executionArn }) => ({ - promise: () => Promise.resolve({ - executionArn, - status: executionStatuses[executionArn] - }) + promise: () => { + if (!executionStatuses[executionArn]) { + const error = new Error(`Execution does not exist: ${executionArn}`); + error.code = 'ExecutionDoesNotExist'; + return Promise.reject(error); + } + return Promise.resolve({ + executionArn, + status: executionStatuses[executionArn] + }); + } }) }; const stub = sinon.stub(aws, 'sfn').returns(stubSfnClient); const event = { input: { - running: ['arn:1', 'arn:2', 'arn:3', 'arn:4'], + running: ['arn:1', 'arn:2', 'arn:3', 'arn:4', 'arn:7'], completed: ['arn:5'], failed: [{ arn: 'arn:6', reason: 'OutOfCheese' }], counter: 5, - limit: 10 + limit: 10, + pdr: { name: 'test.PDR', path: 'test-path' } } }; @@ -92,7 +104,7 @@ test('returns the correct results in the nominal case', (t) => { t.is(output.counter, 6); t.is(output.limit, 10); - t.deepEqual(output.running, ['arn:1']); + t.deepEqual(output.running, ['arn:1', 'arn:7']); t.deepEqual(output.completed.sort(), ['arn:2', 'arn:5'].sort()); t.is(output.failed.length, 3); diff --git a/cumulus/tasks/sf-sns-report/README.md b/cumulus/tasks/sf-sns-report/README.md new file mode 100644 index 00000000000..3461c4a83bf --- /dev/null +++ b/cumulus/tasks/sf-sns-report/README.md @@ -0,0 +1,52 @@ +# @cumulus/sf-sns-report + +[![CircleCI](https://circleci.com/gh/cumulus-nasa/cumulus.svg?style=svg)](https://circleci.com/gh/cumulus-nasa/cumulus) + +Broadcast an incoming Cumulus message to SNS. This lambda function works with Cumulus Message Adapter, and it can be used anywhere in a step function workflow to report granule and PDR status. + +To report the PDR's progress as it's being processed, add the following step after the pdr-status-check: + + PdrStatusReport: + CumulusConfig: + cumulus_message: + input: '{$}' + ResultPath: null + Type: Task + Resource: ${SfSnsReportLambdaFunction.Arn} + +To report the start status of the step function: + + StartAt: StatusReport + States: + StatusReport: + CumulusConfig: + cumulus_message: + input: '{$}' + ResultPath: null + Type: Task + Resource: ${SfSnsReportLambdaFunction.Arn} + +To report the final status of the step function: + + StopStatus: + CumulusConfig: + sfnEnd: true + stack: '{$.meta.stack}' + bucket: '{$.meta.buckets.internal}' + stateMachine: '{$.cumulus_meta.state_machine}' + executionName: '{$.cumulus_meta.execution_name}' + cumulus_message: + input: '{$}' + ResultPath: null + Type: Task + Resource: ${SfSnsReportLambdaFunction.Arn} + +## What is Cumulus? + +Cumulus is a cloud-based data ingest, archive, distribution and management prototype for NASA's future Earth science data streams. + +[Cumulus Documentation](https://cumulus-nasa.github.io/) + +## Contributing + +See [Cumulus README](https://github.com/cumulus-nasa/cumulus/blob/master/README.md#installing-and-deploying) diff --git a/cumulus/tasks/sf-sns-report/index.js b/cumulus/tasks/sf-sns-report/index.js new file mode 100644 index 00000000000..c30341e1102 --- /dev/null +++ b/cumulus/tasks/sf-sns-report/index.js @@ -0,0 +1,131 @@ +'use strict'; + +const get = require('lodash.get'); +const { setGranuleStatus, sns } = require('@cumulus/common/aws'); +const errors = require('@cumulus/common/errors'); +const cumulusMessageAdapter = require('@cumulus/cumulus-message-adapter-js'); + +/** + * Determines if there was a valid exception in the input message + * + * @param {Object} event - aws event object + * @returns {boolean} true if there was an exception, false otherwise + */ +function eventFailed(event) { + // event has exception + // and it is needed to avoid flagging cases like "exception: {}" or "exception: 'none'" + if ((event.exception) && (typeof event.exception === 'object') && + (Object.keys(event.exception).length > 0)) return true; + + // Error and error keys are not part of the cumulus message + // and if they appear in the message something is seriously wrong + else if (event.Error || event.error) return true; + + return false; +} + +/** + * Builds error object based on error type + * + * @param {string} type - error type + * @param {string} cause - error cause + * @returns {Object} the error object + */ +function buildError(type, cause) { + let ErrorClass; + + if (Object.keys(errors).includes(type)) ErrorClass = errors[type]; + else if (type === 'TypeError') ErrorClass = TypeError; + else ErrorClass = Error; + + return new ErrorClass(cause); +} + +/** + * If the cumulus message shows that a previous step failed, + * this function extracts the error message from the cumulus message + * and fails the function with that information. This ensures that the + * Step Function workflow fails with the correct error info + * + * @param {Object} event - aws event object + * @returns {undefined} throws an error and does not return anything + */ +function makeLambdaFunctionFail(event) { + const error = event.exception || event.error; + + if (error) throw buildError(error.Error, error.Cause); + + throw new Error('Step Function failed for an unknown reason.'); +} + +/** + * Publishes incoming Cumulus Message in its entirety to + * a given SNS topic + * + * @param {Object} event - a Cumulus Message that has been sent through the + * Cumulus Message Adapter. See schemas/input.json for detailed input schema. + * @param {Object} event.config - configuration object for the task + * @param {Object} event.config.sfnEnd - indicate if it's the last step of the step function + * @param {string} event.config.stack - the name of the deployment stack + * @param {string} event.config.bucket - S3 bucket + * @param {string} event.config.stateMachine - current state machine + * @param {string} event.config.executionName - execution name + * @returns {Promise.} - AWS SNS response or error in case of step function + * failure. + */ +async function publishSnsMessage(event) { + const config = get(event, 'config'); + const message = get(event, 'input'); + + const finished = get(config, 'sfnEnd', false); + const topicArn = get(message, 'meta.topic_arn', null); + const failed = eventFailed(message); + + let response = {}; + if (topicArn) { + // if this is the sns call at the end of the execution + if (finished) { + message.meta.status = failed ? 'failed' : 'completed'; + const granuleId = get(message, 'meta.granuleId', null); + if (granuleId) { + await setGranuleStatus( + granuleId, + config.stack, + config.bucket, + config.stateMachine, + config.executionName, + message.meta.status + ); + } + } + else { + message.meta.status = 'running'; + } + + response = await sns().publish({ + TopicArn: topicArn, + Message: JSON.stringify(message) + }).promise(); + } + + if (failed) { + makeLambdaFunctionFail(message); + } + + return response; +} + +exports.publishSnsMessage = publishSnsMessage; + +/** + * Lambda handler. It broadcasts an incoming Cumulus message to SNS + * + * @param {Object} event - a Cumulus Message + * @param {Object} context - an AWS Lambda context object + * @param {Function} callback - an AWS Lambda call back + * @returns {undefined} - does not return a value + */ +function handler(event, context, callback) { + cumulusMessageAdapter.runCumulusTask(publishSnsMessage, event, context, callback); +} +exports.handler = handler; diff --git a/cumulus/tasks/sf-sns-report/package.json b/cumulus/tasks/sf-sns-report/package.json new file mode 100644 index 00000000000..ccd80bfcd59 --- /dev/null +++ b/cumulus/tasks/sf-sns-report/package.json @@ -0,0 +1,55 @@ +{ + "name": "@cumulus/sf-sns-report", + "version": "1.1.1", + "description": "Broadcasts an incoming Cumulus message to SNS", + "main": "index.js", + "directories": { + "test": "tests" + }, + "repository": { + "type": "git", + "url": "https://github.com/cumulus-nasa/cumulus" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "test": "env TEST=true ava tests/*.js", + "build": "webpack --progress", + "watch": "webpack --progress -w", + "postinstall": "npm run build" + }, + "ava": { + "babel": "inherit", + "require": [ + "babel-polyfill", + "babel-register" + ] + }, + "babel": { + "presets": [ + "es2015" + ], + "plugins": [ + "transform-async-to-generator" + ] + }, + "author": "Cumulus Authors", + "license": "Apache-2.0", + "dependencies": { + "@cumulus/common": "^1.1.0", + "@cumulus/cumulus-message-adapter-js": "^1.0.1", + "@cumulus/ingest": "^1.1.1", + "babel-core": "^6.25.0", + "babel-loader": "^6.2.4", + "babel-plugin-transform-async-to-generator": "^6.24.1", + "babel-polyfill": "^6.23.0", + "babel-preset-es2015": "^6.24.1", + "json-loader": "~0.5.7", + "lodash.get": "^4.4.2", + "webpack": "~1.12.13" + }, + "devDependencies": { + "ava": "^0.21.0" + } +} diff --git a/cumulus/tasks/sf-sns-report/schemas/config.json b/cumulus/tasks/sf-sns-report/schemas/config.json new file mode 100644 index 00000000000..588d3df1dae --- /dev/null +++ b/cumulus/tasks/sf-sns-report/schemas/config.json @@ -0,0 +1,36 @@ +{ + "title": "SfSnsReportConfig", + "description": "Describes the config used by the sf-sns-report task", + "type": "object", + "additionalProperties": false, + "properties": { + "sfnEnd": { + "description": "indicate if it's the last step of the step function.", + "type": "boolean" + }, + "stack": { + "description": "the name of the deployment stack (from meta.stack). Required when sfnEnd is true and granule status is reported.", + "type": "string" + }, + "bucket": { + "description": "S3 bucket (from meta.buckets.internal). Required when sfnEnd is true and granule status is reported.", + "type": "string" + }, + "stateMachine": { + "description": "current state machine (from cumulus_meta.state_machine). Required when sfnEnd is true and granule status is reported.", + "type": "string" + }, + "executionName": { + "description": "execution name (from cumulus_meta.execution_name). Required when sfnEnd is true and granule status is reported.", + "type": "string" + } + }, + "oneOf": [ + { + "required": ["stack", "bucket", "stateMachine", "executionName"] + }, + { + "required": [] + } + ] +} diff --git a/cumulus/tasks/sf-sns-report/tests/.eslintrc.json b/cumulus/tasks/sf-sns-report/tests/.eslintrc.json new file mode 100644 index 00000000000..ada42bca77f --- /dev/null +++ b/cumulus/tasks/sf-sns-report/tests/.eslintrc.json @@ -0,0 +1,5 @@ +{ + "rules": { + "no-param-reassign": "off" + } +} diff --git a/cumulus/tasks/sf-sns-report/tests/index.js b/cumulus/tasks/sf-sns-report/tests/index.js new file mode 100644 index 00000000000..d34c7282b0a --- /dev/null +++ b/cumulus/tasks/sf-sns-report/tests/index.js @@ -0,0 +1,102 @@ +'use strict'; + +const test = require('ava'); +const { recursivelyDeleteS3Bucket, s3 } = require('@cumulus/common/aws'); +const { publishSnsMessage } = require('../index'); +const { cloneDeep, get } = require('lodash'); +const { randomString } = require('@cumulus/common/test-utils'); + +test('send report when sfn is running', (t) => { + const event = { + input: { + meta: { topic_arn: 'test_topic_arn' }, + anykey: 'anyvalue' + } + }; + + return publishSnsMessage(cloneDeep(event)) + .then((output) => { + t.not(get(output, 'MessageId', null)); + }); +}); + +test('send report when sfn is running with exception', (t) => { + const event = { + input: { + meta: { topic_arn: 'test_topic_arn' }, + exception: { + Error: 'TheError', + Cause: 'bucket not found' + }, + anykey: 'anyvalue' + } + }; + + return publishSnsMessage(cloneDeep(event)) + .catch((e) => { + t.is(e.message, event.input.exception.Cause); + }); +}); + +test('send report when sfn is running with TypeError', (t) => { + const event = { + input: { + meta: { topic_arn: 'test_topic_arn' }, + error: { + Error: 'TypeError', + Cause: 'resource not found' + }, + anykey: 'anyvalue' + } + }; + + return publishSnsMessage(cloneDeep(event)) + .catch((e) => { + t.is(e.message, event.input.error.Cause); + }); +}); + +test('send report when sfn is running with known error type', (t) => { + const event = { + input: { + meta: { topic_arn: 'test_topic_arn' }, + error: { + Error: 'PDRParsingError', + Cause: 'format error' + }, + anykey: 'anyvalue' + } + }; + + return publishSnsMessage(cloneDeep(event)) + .catch((e) => { + t.is(e.message, event.input.error.Cause); + }); +}); + +test('send report when sfn is finished and granule has succeeded', async (t) => { + const input = { + meta: { + topic_arn: 'test_topic_arn', + granuleId: randomString() + }, + anykey: 'anyvalue' + }; + const event = {}; + event.input = input; + event.config = {}; + event.config.sfnEnd = true; + event.config.stack = 'test_stack'; + event.config.bucket = randomString(); + event.config.stateMachine = + 'arn:aws:states:us-east-1:596205514787:stateMachine:TestCumulusParsePdrStateMach-K5Qk90fc8w4U'; + event.config.executionName = '7c543392-1da9-47f0-9c34-f43f6519412a'; + + await s3().createBucket({ Bucket: event.config.bucket }).promise(); + return publishSnsMessage(cloneDeep(event)) + .then((output) => { + t.not(get(output, 'MessageId', null)); + }) + .then(() => recursivelyDeleteS3Bucket(event.config.bucket)) + .catch(() => recursivelyDeleteS3Bucket(event.config.bucket).then(t.fail)); +}); diff --git a/cumulus/tasks/sf-sns-report/webpack.config.js b/cumulus/tasks/sf-sns-report/webpack.config.js new file mode 100644 index 00000000000..f0d512ee35d --- /dev/null +++ b/cumulus/tasks/sf-sns-report/webpack.config.js @@ -0,0 +1,19 @@ +module.exports = { + entry: ['babel-polyfill', './index.js'], + output: { + libraryTarget: 'commonjs2', + filename: 'dist/index.js' + }, + target: 'node', + devtool: 'sourcemap', + module: { + loaders: [{ + test: /\.js?$/, + exclude: /node_modules(?!\/@cumulus)/, + loader: 'babel' + }, { + test: /\.json$/, + loader: 'json' + }] + } +}; diff --git a/packages/common/aws.js b/packages/common/aws.js index c6fe7460e7c..9b5d25925e1 100644 --- a/packages/common/aws.js +++ b/packages/common/aws.js @@ -64,6 +64,7 @@ exports.dynamodbstreams = awsClient(AWS.DynamoDBStreams, '2012-08-10'); exports.dynamodbDocClient = awsClient(AWS.DynamoDB.DocumentClient, '2012-08-10'); exports.sfn = awsClient(AWS.StepFunctions, '2016-11-23'); exports.cf = awsClient(AWS.CloudFormation, '2010-05-15'); +exports.sns = awsClient(AWS.SNS, '2010-03-31'); /** * Describes the resources belonging to a given CloudFormation stack @@ -575,14 +576,15 @@ exports.getGranuleS3Params = (granuleId, stack, bucket) => { /** * Set the status of a granule +* * @name setGranuleStatus -* @param {string} granuleId -* @param {string} stack = the deployment stackname +* @param {string} granuleId - granule id +* @param {string} stack - the deployment stackname * @param {string} bucket - the deployment bucket name -* @param {string} stateMachineArn -* @param {string} executionName -* @param {string} status -* @return {promise} returns the response from `S3.put` as a promise +* @param {string} stateMachineArn - statemachine arn +* @param {string} executionName - execution name +* @param {string} status - granule status +* @returns {Promise} returns the response from `S3.put` as a promise **/ exports.setGranuleStatus = async ( granuleId, @@ -594,5 +596,7 @@ exports.setGranuleStatus = async ( ) => { const key = exports.getGranuleS3Params(granuleId, stack, bucket); const executionArn = exports.getExecutionArn(stateMachineArn, executionName); - await exports.s3().putObject(bucket, key, '', null, { executionArn, status }).promise(); + const params = { Bucket: bucket, Key: key }; + params.Metadata = { executionArn, status }; + await exports.s3().putObject(params).promise(); }; diff --git a/packages/test-data/cumulus_messages/pdr-status-check.json b/packages/test-data/cumulus_messages/pdr-status-check.json index fa0f578d9ea..55fdb8c90be 100644 --- a/packages/test-data/cumulus_messages/pdr-status-check.json +++ b/packages/test-data/cumulus_messages/pdr-status-check.json @@ -85,7 +85,11 @@ "running": [ "arn:aws:states:us-east-1:000000000000:execution:LpdaacCumulusIngestGranuleS-pOyNXh5jeR4h:d5b6344a-36eb-4c97-a5cf-3f6e83f0692a" ], - "limit": 30 + "limit": 30, + "pdr": { + "name": "MOD09GQ_1granule_v2.PDR", + "path": "/" + } }, "exception": "None", "workflow_config": {