diff --git a/docs/API-Reference.md b/docs/API-Reference.md index 9252f41..ef4fe32 100644 --- a/docs/API-Reference.md +++ b/docs/API-Reference.md @@ -1,12 +1,13 @@ # Overview -The Platform Apps API empowers developers to build, extend, and combine Akkeris functionality with other services, tooling, and systems. +The Platform Apps API empowers developers to build, extend, and combine Akkeris functionality with other services, tooling, and systems. You can use this API to programmatically create apps, provision add-ons, promote pipelines, and perform any other tasks you can complete with the CLI or UI. In fact, both the CLI and UI use this API for all of their interactions, commands, and operations. ## Table of Contents - [Authentication](Authentication.md) +- [Actions](Actions.md) - [Apps](Apps.md) - [App Setups](App-Setups.md) - [Formations](Formations.md) diff --git a/docs/Actions.md b/docs/Actions.md new file mode 100644 index 0000000..a6ba2a2 --- /dev/null +++ b/docs/Actions.md @@ -0,0 +1,251 @@ +## Actions + +Actions allow a user to run a script or other arbitrary code once and then exit. More specifically, an Action is created on an app to run its Docker image on a one-off basis. An action can be triggered manually or triggered automatically by an event. + +### Create an Action + +`POST /apps/{appname}/actions` + +Create a new action on an app. + +| Name | Type | Description | Example | +|---|---|---|---| +| name | required string | The name of the action. | testsuite | +| description | string | A description of the action. | End-to-end automated tests | +| size | string | The dyno size to use for the action | gp1 | +| command | string | The command to run inside the action container | ./start.sh | +| options | object | Override certain app configuration (see below) | {} | +| options.image | string | Run a different image than the one configured for the application | "hello-world:latest" | +| options.env | object | Add (or override) environment variables | { "foo": "bar", "merp": "derp" } + +**CURL Example** + +```bash +curl \ + -H 'Authorization: ...' \ + -X POST \ + https://apps.akkeris.io/apps/app-space/actions + -d '{ "name": "testsuite", "descripiton": "End-to-end automated tests", "size": "gp1", "command": "./start.sh", "options": { "image": "domain.io/project/image:tag-1", "env": { "foo": "bar" } } }' +``` + +**201 "Created" Response** + +```json +{ + "action": "5be7fc31-ac79-48df-b58d-4ce2d292ee91", + "app": "d5f42929-cddb-48be-8fd7-77d41d8c79be", + "formation": "5a4cde36-e290-45b8-a9b9-9579bdfd7a0d", + "name": "testsuite", + "description": "End-to-end automated tests", + "created_by": "Calaway", + "created": "2021-03-12T03:52:42.769Z", + "updated": "2021-03-12T03:52:42.769Z", + "deleted": false +} +``` + +### List Actions + +`GET /apps/{appname}/actions` + +**CURL Example** + +```bash +curl \ + -H 'Authorization: ...' \ + -X GET \ + https://apps.akkeris.io/apps/app-space/actions +``` + +**200 "OK" Response** + +```json +[ + { + "action": "5be7fc31-ac79-48df-b58d-4ce2d292ee91", + "app": "d5f42929-cddb-48be-8fd7-77d41d8c79be", + "name": "testsuite", + "description": "End-to-end automated tests", + "created_by": "Calaway", + "created": "2021-03-12T03:52:42.769Z", + "updated": "2021-03-12T03:52:42.769Z", + "deleted": false, + "formation": { + "id": "1aa5558e-4216-46bb-a15e-627a6fe62c22", + "type": "actionstestsuite", + "size": "gp1", + "command": "./start.sh", + "options": { + "image": "domain.io/project/image:tag-1", + "env": { + "foo": "bar" + } + } + } + } +] +``` + +### Get Action Info + +`GET /apps/{appname}/actions/{action_name}` + +**CURL Example** + +```bash +curl \ + -H 'Authorization: ...' \ + -X GET \ + https://apps.akkeris.io/apps/app-space/actions/testsuite +``` + +**200 "OK" Response** + +```json +{ + "action": "5be7fc31-ac79-48df-b58d-4ce2d292ee91", + "app": "d5f42929-cddb-48be-8fd7-77d41d8c79be", + "name": "testsuite", + "description": "End-to-end automated tests", + "created_by": "Calaway", + "created": "2021-03-12T03:52:42.769Z", + "updated": "2021-03-12T03:52:42.769Z", + "deleted": false, + "formation": { + "id": "1aa5558e-4216-46bb-a15e-627a6fe62c22", + "type": "actionstestsuite", + "size": "gp1", + "command": "./start.sh", + "options": { + "image": "domain.io/project/image:tag-1", + "env": { + "foo": "bar" + } + } + } +} +``` + +### Create an Action Run + +`POST /apps/{appname}/actions/{action_name}/runs` + +Manually trigger an action. + +**CURL Example** + +```bash +curl \ + -H 'Authorization: ...' \ + -X POST \ + https://apps.akkeris.io/apps/app-space/actions/testsuite/runs +``` + +**201 "Created" Response** + +```json +{ + "action_run": "8e3cabac-afb6-44d5-8c01-de2d463460cb", + "action": "5be7fc31-ac79-48df-b58d-4ce2d292ee91", + "runid": "b8902377-11b6-4149-8a9d-36a1d4cb3271", + "status": "running", + "exit_code": null, + "created_by": "Calaway", + "created": "2021-03-12T03:52:43.125Z" +} +``` + +### List Action Runs + +`GET /apps/{appname}/actions/{action_name}/runs` + +**CURL Example** + +```bash +curl \ + -H 'Authorization: ...' \ + -X GET \ + https://apps.akkeris.io/apps/app-space/actions/testsuite/runs +``` + +**200 "OK" Response** + +```json +[ + { + "action_run": "3e4fd725-bfb9-49c5-8393-6684fb5e3936", + "action": "9cad8674-d56f-4068-b293-5eb0f30ecbb4", + "status": "running", + "exit_code": null, + "created_by": "Calaway", + "created": "2021-10-14T16:35:35.069Z" + } +] +``` + +### Get Action Run Info + +`GET /apps/{appname}/actions/{action_name}/runs/{run_id}` + +**CURL Example** + +```bash +curl \ + -H 'Authorization: ...' \ + -X GET \ + https://apps.akkeris.io/apps/app-space/actions/testsuite/runs/3e4fd725-bfb9-49c5-8393-6684fb5e3936 +``` + +**200 "OK" Response** + +```json +{ + "action_run": "3e4fd725-bfb9-49c5-8393-6684fb5e3936", + "action": "9cad8674-d56f-4068-b293-5eb0f30ecbb4", + "status": "running", + "exit_code": null, + "created_by": "unknown", + "created": "2021-10-14T16:35:35.069Z" +} +``` + +### Delete an Action + +`DELETE /apps/{appname}/actions/{action_name}` + +**CURL Example** + +```bash +curl \ + -H 'Authorization: ...' \ + -X DELETE \ + https://apps.akkeris.io/apps/app-space/actions/testsuite +``` + +**200 "OK" Response** + +```json +{ + "action": "5be7fc31-ac79-48df-b58d-4ce2d292ee91", + "app": "d5f42929-cddb-48be-8fd7-77d41d8c79be", + "name": "testsuite", + "description": "End-to-end automated tests", + "created_by": "Calaway", + "created": "2021-03-12T03:52:42.769Z", + "updated": "2021-03-12T03:52:42.769Z", + "deleted": false, + "formation": { + "id": "1aa5558e-4216-46bb-a15e-627a6fe62c22", + "type": "actionstestsuite", + "size": "gp1", + "command": "./start.sh", + "options": { + "image": "domain.io/project/image:tag-1", + "env": { + "foo": "bar" + } + } + } +} +``` + diff --git a/index.js b/index.js index 1dbdbbd..2c8768a 100755 --- a/index.js +++ b/index.js @@ -18,6 +18,7 @@ const { simple_key, jwt_key } = require('./lib/auth.js')(config.simple_key, conf const common = require('./lib/common.js'); const alamo = { + actions: require('./lib/actions.js'), addon_attachments: require('./lib/addon-attachments.js'), addon_services: require('./lib/addon-services.js'), addons: require('./lib/addons.js'), @@ -841,6 +842,40 @@ routes.add.delete('/apps/([A-z0-9\\-\\_\\.]+)/recommendations(\\?[A-z0-9\\-\\_\\ .run(alamo.recommendations.http.delete.bind(alamo.recommendations.http.delete, pg_pool)) .and.authorization([simple_key, jwt_key]); +// -- actions +// Create Action +routes.add.post('/apps/([A-z0-9\\-\\_\\.]+)/actions$') + .run(alamo.actions.http.create.bind(alamo.actions.http.create, pg_pool)) + .and.authorization([simple_key, jwt_key]); +// Create Action Run +routes.add.post('/apps/([A-z0-9\\-\\_\\.]+)/actions/([A-z0-9\\-\\_\\.]+)/runs$') + .run(alamo.actions.http.runs.create.bind(alamo.actions.http.runs.create, pg_pool)) + .and.authorization([simple_key, jwt_key]); +// Get Specific Action +routes.add.get('/apps/([A-z0-9\\-\\_\\.]+)/actions/([A-z0-9\\-\\_\\.]+)$') + .run(alamo.actions.http.get.bind(alamo.actions.http.get, pg_pool)) + .and.authorization([simple_key, jwt_key]); +// List All Actions +routes.add.get('/apps/([A-z0-9\\-\\_\\.]+)/actions$') + .run(alamo.actions.http.list.bind(alamo.actions.http.list, pg_pool)) + .and.authorization([simple_key, jwt_key]); +// Get Specific Action Run +routes.add.get('/apps/([A-z0-9\\-\\_\\.]+)/actions/([A-z0-9\\-\\_\\.]+)/runs/([A-z0-9\\-\\_\\.]+)$') + .run(alamo.actions.http.runs.get.bind(alamo.actions.http.runs.get, pg_pool)) + .and.authorization([simple_key, jwt_key]); +// List All Action Runs +routes.add.get('/apps/([A-z0-9\\-\\_\\.]+)/actions/([A-z0-9\\-\\_\\.]+)/runs$') + .run(alamo.actions.http.runs.list.bind(alamo.actions.http.runs.list, pg_pool)) + .and.authorization([simple_key, jwt_key]); +// Update Action +routes.add.patch('/apps/([A-z0-9\\-\\_\\.]+)/actions/([A-z0-9\\-\\_\\.]+)$') + .run(alamo.actions.http.update.bind(alamo.actions.http.update, pg_pool)) + .and.authorization([simple_key, jwt_key]); +// Delete Action +routes.add.delete('/apps/([A-z0-9\\-\\_\\.]+)/actions/([A-z0-9\\-\\_\\.]+)$') + .run(alamo.actions.http.delete.bind(alamo.actions.http.delete, pg_pool)) + .and.authorization([simple_key, jwt_key]); + routes.add.default((req, res) => { res.writeHead(404, {}); res.end(); diff --git a/lib/actions.js b/lib/actions.js new file mode 100644 index 0000000..7a3a4b8 --- /dev/null +++ b/lib/actions.js @@ -0,0 +1,251 @@ +const fs = require('fs'); +const { isBoolean, isNumber } = require('util'); +const uuid = require('uuid'); + +const common = require('./common'); +const formations = require('./formations'); +const http_helper = require('./http_helper'); +const query = require('./query'); +const hooks = require('./hooks'); + +const asyncForEach = async (array, callback) => { + for (let index = 0; index < array.length; index++) { + // eslint-disable-next-line no-await-in-loop + await callback(array[index], index, array); + } +}; + +// Verify events are valid. Events should be a comma separated string of events +function check_events(events) { + if (events && typeof events !== 'string') { + throw new common.BadRequestError('Events should be a comma separated string of events'); + } + // Invalid events - action_*, destroy + if (events && events !== '') { + const valid_events = hooks.available_hooks; + const events_array = events.split(','); + events_array.forEach((event) => { + if (event.startsWith('action_') || event === 'destroy') { + // Don't trigger actions on top of other actions, don't trigger actions on destroy + // Might change in the future, but for now let's keep it simple + throw new common.BadRequestError(`Event ${event} is unable to trigger actions, please remove and try again`); + } else if (!valid_events.map((x) => x.type).includes(event)) { + throw new common.BadRequestError(`Event ${event} is not a valid event, please remove and try again`); + } + }); + } + return true; +} + +// private +const insert_action = query.bind(query, fs.readFileSync('sql/insert_action.sql').toString('utf8'), (result) => result); +async function http_create(pg_pool, req, res, regex) { + const app_key = http_helper.first_match(req.url, regex); + const app = await common.app_exists(pg_pool, app_key); + const payload = await http_helper.buffer_json(req); + + const key_exists = (key) => Object.keys(payload).findIndex((k) => k === key) !== -1; + + // Validate existence of required fields + ['name', 'command'].forEach((key) => { + if (!key_exists(key) || (payload[key] === '' || isBoolean(payload[key]) || isNumber(payload[key]))) { + throw new common.BadRequestError(`Payload field ${key} must be a valid, non-null alphanumeric string.`); + } + }); + + const formation_type = `actions${payload.name}`; + + check_events(payload.events); + + // Create a one-off formation + const formation = await formations.create( + pg_pool, + app.app_uuid, + app.app_name, + app.space_name, + app.space_tags, + app.org_name, + formation_type, + payload.size, + 1, + payload.command, + null, + null, + false, + 'System', + true, + payload.options, + ); + + // Insert an action into the DB + const action_id = uuid.v4(); + const created_by = req.headers['x-username'] || 'unknown'; + const action_params = [ + action_id, + app.app_uuid, + formation.id, + payload.name, + payload.description || '', + payload.events || '', + created_by, + ]; + const action = (await insert_action(pg_pool, action_params))[0]; + + return http_helper.created_response(res, action); +} + +const delete_action = query.bind(query, fs.readFileSync('sql/delete_action.sql').toString('utf8'), (result) => result); +async function http_delete(pg_pool, req, res, regex) { + const app_key = http_helper.first_match(req.url, regex); + const app = await common.app_exists(pg_pool, app_key); + const action_key = http_helper.second_match(req.url, regex); + const action = await common.action_exists(pg_pool, app.app_uuid, action_key); + + await formations.delete_dyno(pg_pool, app.app_uuid, app.app_name, app.space_name, action.formation.type); + + await delete_action(pg_pool, [action.action]); + + return http_helper.ok_response(res, action); +} + +async function http_get(pg_pool, req, res, regex) { + const app_key = http_helper.first_match(req.url, regex); + const app = await common.app_exists(pg_pool, app_key); + const action_key = http_helper.second_match(req.url, regex); + const action = await common.action_exists(pg_pool, app.app_uuid, action_key); + return http_helper.ok_response(res, action); +} + +const select_actions = query.bind(query, fs.readFileSync('sql/select_all_actions.sql').toString('utf8'), (result) => result); +async function http_list(pg_pool, req, res, regex) { + const app_key = http_helper.first_match(req.url, regex); + const app = await common.app_exists(pg_pool, app_key); + const actions = await select_actions(pg_pool, [app.app_uuid]); + return http_helper.ok_response(res, actions); +} + +const update_action = query.bind(query, fs.readFileSync('sql/update_action.sql').toString('utf-8'), (result) => result); +async function http_update(pg_pool, req, res, regex) { + const app_key = http_helper.first_match(req.url, regex); + const app = await common.app_exists(pg_pool, app_key); + const action_key = http_helper.second_match(req.url, regex); + const action = await common.action_exists(pg_pool, app.app_uuid, action_key); + const payload = await http_helper.buffer_json(req); + + const key_exists = (key) => Object.keys(payload).findIndex((k) => k === key) !== -1; + + // Required fields (can't set to empty string) + ['size', 'command'].forEach((key) => { + if (key_exists(key) && (payload[key] === '' || isBoolean(payload[key]) || isNumber(payload[key]))) { + throw new common.BadRequestError(`Action ${key} (if provided) must be a valid alphanumeric string.`); + } + }); + + // Optional fields (set to empty string to clear) + ['description', 'events'].forEach((key) => { + if (key_exists(key) && (isBoolean(payload[key]) || isNumber(payload[key]))) { + throw new common.BadRequestError(`Action ${key} (if provided) must be a valid string.`); + } + }); + + check_events(payload.events); + + try { + await update_action(pg_pool, [action.action, payload.description, payload.events]); + } catch (err) { + throw new common.BadRequestError('Unable to update action. Please check the payload and try again.'); + } + + // The rest of the payload (size, command, options) should be passed to update the formation (along with name/type) + try { + await formations.oneoff_update( + pg_pool, + app.app_uuid, + app.space_name, + action.formation.id, + // payload.name ? `actions${payload.name}` : undefined, + payload.size, + payload.command, + payload.options, + ); + } catch (err) { + throw new http_helper.BadRequestError(err.message); + } + + const updated_action = await common.action_exists(pg_pool, app.app_uuid, action_key); + + return http_helper.ok_response(res, updated_action); +} + +async function http_runs_create(pg_pool, req, res, regex) { + const app_key = http_helper.first_match(req.url, regex); + const action_key = http_helper.second_match(req.url, regex); + + const created_by = req.headers['x-username'] || 'unknown'; + const action_run = await common.trigger_action(pg_pool, app_key, action_key, created_by, 'manual_trigger'); + + return http_helper.created_response(res, action_run); +} + +const select_action_runs = query.bind(query, fs.readFileSync('sql/select_all_action_runs.sql').toString('utf8'), (result) => result); +async function http_runs_list(pg_pool, req, res, regex) { + const app_key = http_helper.first_match(req.url, regex); + const app = await common.app_exists(pg_pool, app_key); + const action_key = http_helper.second_match(req.url, regex); + const action = await common.action_exists(pg_pool, app.app_uuid, action_key); + const action_runs = await select_action_runs(pg_pool, [action.action]); + + return http_helper.ok_response(res, action_runs); +} + +const select_action_run = query.bind(query, fs.readFileSync('sql/select_action_run.sql').toString('utf8'), (result) => result); +async function http_runs_get(pg_pool, req, res, regex) { + const app_key = http_helper.first_match(req.url, regex); + const app = await common.app_exists(pg_pool, app_key); + const action_key = http_helper.second_match(req.url, regex); + const action = await common.action_exists(pg_pool, app.app_uuid, action_key); + const run_key = http_helper.third_match(req.url, regex); + const action_run = (await select_action_run(pg_pool, [action.action, run_key]))[0]; + + if (!action_run) { + throw new http_helper.NotFoundError(`The specified action run ${run_key} does not exist.`); + } + + // Fetch logs from logtrain + try { + const data = await common.alamo.get_logtrain_logs(pg_pool, app.space_name, action_run.action_run); + action_run.logs = data; + } catch (err) { + // ignore + console.log(err); + } + + return http_helper.ok_response(res, action_run); +} + +// Delete all actions. Normally called when deleting apps +async function delete_actions(pg_pool, app_key) { + const app = await common.app_exists(pg_pool, app_key); + const actions = await select_actions(pg_pool, [app.app_uuid]); + + await asyncForEach(actions, async (action) => { + await formations.delete_dyno(pg_pool, app.app_uuid, app.app_name, app.space_name, action.formation.type); + await delete_action(pg_pool, [action.action]); + }); +} + +module.exports = { + http: { + create: http_create, + get: http_get, + list: http_list, + delete: http_delete, + update: http_update, + runs: { + create: http_runs_create, + list: http_runs_list, + get: http_runs_get, + }, + }, + delete_actions, +}; diff --git a/lib/alamo.js b/lib/alamo.js index 092bb08..05a922b 100755 --- a/lib/alamo.js +++ b/lib/alamo.js @@ -942,6 +942,7 @@ async function oneoff_deploy( labels = { 'akkeris.io/app-name': app_name, 'akkeris.io/dyno-type': formation_type, + name: alamo_app_name, ...(labels || {}), }; @@ -1337,6 +1338,10 @@ async function es_status(pg_pool, space_name, app_name, service_id /* action_id return alamo_fetch('get', `${await get_region_api_by_space(pg_pool, space_name)}/v1/service/es/instance/${service_id}/status`, null); } +async function get_logtrain_logs(pg_pool, space_name, log_id) { + return alamo_fetch('get', `${await get_region_api_by_space(pg_pool, space_name)}/logs/${log_id}`, null); +} + module.exports = { deploy, oneoff_deploy, @@ -1465,4 +1470,5 @@ module.exports = { osb_action, update_osb_service, get_kafka_hosts, + get_logtrain_logs, }; diff --git a/lib/apps.js b/lib/apps.js index 0e39be6..c0778ea 100755 --- a/lib/apps.js +++ b/lib/apps.js @@ -14,6 +14,7 @@ const logs = require('./log-drains'); const query = require('./query.js'); const routes = require('./routes.js'); const sites = require('./sites.js'); +const actions = require('./actions.js'); // private function app_payload_to_response(payload) { @@ -442,6 +443,9 @@ async function del(pg_pool, app_key, elevated_access, user) { // remove config set await common.alamo.config.set.delete(pg_pool, app.app_name, app.space_name); + // remove actions + await actions.delete_actions(pg_pool, app.app_uuid); + // remove physical dynos and real apps await formation.delete_dynos(pg_pool, app.app_uuid, app.app_name, app.space_name); diff --git a/lib/common.js b/lib/common.js index d9f1ea1..1b8b990 100755 --- a/lib/common.js +++ b/lib/common.js @@ -11,7 +11,6 @@ const query = require('./query.js'); const config = require('./config.js'); const httph = require('./http_helper.js'); - const client = process.env.ES_URL ? new elasticsearch.Client({ hosts: [ process.env.ES_URL, @@ -22,7 +21,6 @@ class ApplicationLifecycle extends events {} const IV_LENGTH = 16; - function x5c_from_pem(pem) { return pem.split('\n').filter((x) => x !== '' && !x.startsWith('---')).join(''); } @@ -153,7 +151,6 @@ async function create_temp_jwt_token(pem, username, audience, issuer, elevated_a return sign_to_token(await jwks_sign(pem, payload)); } - function encrypt_token(key, token) { assert.ok(key.length === 24, 'Key must be 24 characters (192 bits)'); @@ -189,7 +186,6 @@ async function determine_app_url(pg_pool, tags, app_name, space_name, org_name) .replace(/-default/g, ''); } - function service_by_id_or_name(addon_id_or_name) { const addons_info = global.addon_services.filter((addon) => { if (!addon) { @@ -300,6 +296,11 @@ async function update_release_status(pg_pool, app_uuid, release_uuid, status) { await update_release_status_query(pg_pool, [app_uuid, release_uuid, status]); } +const update_action_run_status_query = query.bind(query, fs.readFileSync('./sql/update_action_run_status.sql').toString('utf8'), null); +async function update_action_run_status(pg_pool, action_uuid, run_uuid, status, exit_code, started_at, finished_at) { + await update_action_run_status_query(pg_pool, [action_uuid, run_uuid, status, exit_code, started_at, finished_at]); +} + const select_release = query.bind(query, fs.readFileSync('./sql/select_release.sql').toString('utf8'), null); async function check_release_exists(pg_pool, app_uuid, release_id) { const releases = await select_release(pg_pool, [release_id]); @@ -324,6 +325,24 @@ async function check_app_exists(pg_pool, app_key) { return result[0]; } +const select_action = query.bind(query, fs.readFileSync('./sql/select_action.sql').toString('utf8'), null); +async function check_action_exists(pg_pool, app_uuid, action_key) { + const result = await select_action(pg_pool, [app_uuid, action_key]); + if (result.length !== 1) { + throw new httph.NotFoundError(`The specified action ${action_key} does not exist.`); + } + return result[0]; +} + +const select_action_run = query.bind(query, fs.readFileSync('./sql/select_action_run.sql').toString('utf8'), null); +async function check_action_run_exists(pg_pool, action_key, run_key) { + const result = await select_action_run(pg_pool, [action_key, run_key]); + if (result.length !== 1) { + throw new httph.NotFoundError(`The specified action run ${run_key} does not exist.`); + } + return result[0]; +} + const select_addon = query.bind(query, fs.readFileSync('./sql/select_service.sql').toString('utf8'), (r) => r); async function check_addon_exists(pg_pool, addon_id, app_uuid) { const addons = await select_addon(pg_pool, [addon_id, app_uuid]); @@ -360,7 +379,6 @@ async function check_build_exists(pg_pool, build_uuid, pull_slug = false) { return build[0]; } - const select_formations = query.bind(query, fs.readFileSync('./sql/select_formations.sql').toString('utf8'), null); async function check_formations_exists(pg_pool, app_uuid) { return select_formations(pg_pool, [app_uuid]); @@ -423,7 +441,7 @@ async function check_deployment_filters(pg_pool, app_uuid, dyno_type, features) if (dyno_type !== 'web') { return []; } - let filters = (await check_filter_attachments(pg_pool, app_uuid)) + const filters = (await check_filter_attachments(pg_pool, app_uuid)) .map((x) => { if (x.filter.type === 'jwt') { return { @@ -436,7 +454,7 @@ async function check_deployment_filters(pg_pool, app_uuid, dyno_type, features) includes: (x.attachment_options.includes || []).join(','), }, }; - } else if (x.filter.type === 'cors') { + } if (x.filter.type === 'cors') { return { type: 'cors', data: { @@ -448,42 +466,42 @@ async function check_deployment_filters(pg_pool, app_uuid, dyno_type, features) allow_credentials: x.filter.options.allow_credentials === true ? 'true' : 'false', }, }; - } else if (x.filter.type === 'csp') { + } if (x.filter.type === 'csp') { return { type: 'csp', data: { policy: x.filter.options.policy, }, - } + }; } throw new Error(`Invalid filter type: ${x.filter.type}`); }); - if(!filters.some((x) => x.type === 'csp')) { + if (!filters.some((x) => x.type === 'csp')) { // Generate a new CSP filter based on four features. - const ignore_domains = config.csp_ignore_domains ? config.csp_ignore_domains.split(',').map((x) => x.trim().toLowerCase()) : []; + const ignore_domains = config.csp_ignore_domains ? config.csp_ignore_domains.split(',').map((x) => x.trim().toLowerCase()) : []; const allowed_domains = `${["'self'"].concat(global.domains.filter((x) => !ignore_domains.includes(x)).map((x) => `*.${x}`)).join(' ')}`; let policy = ''; - if(features['csp-javascript']) { + if (features['csp-javascript']) { policy += ` connect-src ${allowed_domains}; script-src ${allowed_domains};`; } - if(features['csp-media']) { - policy += ` font-src ${allowed_domains}; img-src ${allowed_domains}; media-src ${allowed_domains}; style-src ${allowed_domains};`; + if (features['csp-media']) { + policy += ` font-src ${allowed_domains}; img-src ${allowed_domains}; media-src ${allowed_domains}; style-src ${allowed_domains};`; } - if(features['csp-unsafe']) { + if (features['csp-unsafe']) { policy += ` base-uri ${allowed_domains};`; } - if(features['csp-embedded']) { + if (features['csp-embedded']) { policy += ` object-src ${allowed_domains}; frame-src ${allowed_domains};`; } - if(policy !== "") { - if(config.csp_report_uri) { + if (policy !== '') { + if (config.csp_report_uri) { policy += ` report-uri ${config.csp_report_uri};`; } filters.push({ type: 'csp', data: { - policy: 'default-src https:;' + policy, + policy: `default-src https:;${policy}`, }, }); } @@ -506,7 +524,7 @@ async function check_deployment_features(pg_pool, app_uuid, dyno_type) { if (dyno_type === 'web') { return { serviceMesh: await feature_enabled(pg_pool, app_uuid, 'service-mesh'), - 'http2': true, // we only support http2 at this point. + http2: true, // we only support http2 at this point. 'http2-end-to-end': await feature_enabled(pg_pool, app_uuid, 'http2-end-to-end'), 'csp-javascript': await feature_enabled(pg_pool, app_uuid, 'csp-javascript'), 'csp-media': await feature_enabled(pg_pool, app_uuid, 'csp-media'), @@ -567,7 +585,6 @@ const hook_types = [ require('./hook-types/https.js'), // THIS MUST BE LAST, order matters here. ].filter((x) => x.enabled()); - async function notify_audits(payload, username) { try { if (client) { @@ -649,11 +666,100 @@ function socs(envs) { } } +const insert_action_run = query.bind(query, fs.readFileSync('sql/insert_action_run.sql').toString('utf8'), (result) => result); +const select_latest_image = query.bind(query, fs.readFileSync('./sql/select_latest_image.sql').toString('utf8'), (r) => r); +const select_action_runs = query.bind(query, fs.readFileSync('sql/select_all_action_runs.sql').toString('utf8'), (result) => result); +async function trigger_action(pg_pool, app_key, action_key, triggered_by, source) { + const app = await check_app_exists(pg_pool, app_key); + const action = await check_action_exists(pg_pool, app.app_uuid, action_key); + + let image; + // If there is an image override, use it. Otherwise use the latest app image + if (action.formation.options && action.formation.options.image && action.formation.options.image !== '') { + image = action.formation.options.image; + } else { + const latest_image = (await select_latest_image(pg_pool, [app.app_uuid]))[0]; + if (!latest_image) { + throw new httph.BadRequestError('The specified app does not have an image. Either create a new release or set an image override on the action.'); + } + image = registry_image( + latest_image.build_org_name, + latest_image.build_app_name, + latest_image.build_app, + latest_image.foreign_build_key, + latest_image.foreign_build_system, + ); + } + + // If an action is already running, cancel it + const action_runs = await select_action_runs(pg_pool, [action.action]); + if (action_runs && action_runs.length > 0) { + const [run] = action_runs; + if (run.status === 'running' || run.status === 'starting') { + update_action_run_status(pg_pool, action.action, run.action_run, 'cancelled', 999, run.started_at, (new Date(Date.now()).toISOString())); + } + } + + // Deploy a one-off dyno + const runid = uuid.v4(); + const labels = { + 'akkeris.io/action': 'true', + 'akkeris.io/action-name': `${action.name}`, + 'akkeris.io/action-uuid': `${action.action}`, + 'akkeris.io/action-run-uuid': `${runid}`, + }; + let env = null; + if (action.formation.options && action.formation.options.env && Object.keys(action.formation.options.env).length > 0) { + env = action.formation.options.env; + } + await alamo.oneoff_deploy( + pg_pool, + app.space_name, + app.app_name, + action.formation.type, + image, + action.formation.command, + labels, + env, + action.formation.size, + runid, + ); + // Insert an action run into the DB + const action_run_params = [ + runid, + action.action, + 'starting', + source, + null, + triggered_by, + ]; + return (await insert_action_run(pg_pool, action_run_params))[0]; +} + +const select_actions = query.bind(query, fs.readFileSync('sql/select_all_actions.sql').toString('utf8'), (result) => result); +async function notify_actions(pg_pool, app_uuid, type, username) { + // TODO: Add functionality to do this in the future? + assert.ok(type !== 'destroy', "Actions can't fire on destroy events!"); + + // Filter actions that trigger on provided type + const actions = (await select_actions(pg_pool, [app_uuid])) + .filter((action) => action.events && action.events !== '' && action.events.split(',').includes(type)); + + // Trigger each action and report errors to the console + actions.forEach((action) => { + trigger_action(pg_pool, app_uuid, action.action, username, type) + .catch((e) => console.error(`Could not trigger action ${action.action} - `, e)); + }); +} + const select_hooks_destroy = query.bind(query, fs.readFileSync('./sql/select_hooks_destroy.sql').toString('utf8'), (x) => x); async function notify_hooks(pg_pool, app_uuid, type, payload, username) { try { assert.ok(typeof (payload) === 'string', 'The specified payload was not a string!'); notify_audits(payload, username).catch((e) => console.error(e)); + if (type !== 'destroy') { + notify_actions(pg_pool, app_uuid, type, username).catch((e) => console.error(e)); + } let hooks = []; if (type === 'destroy') { hooks = (await select_hooks_destroy(pg_pool, [app_uuid])) @@ -738,6 +844,8 @@ module.exports = { plan_by_id_or_name, feature_enabled, app_exists: check_app_exists, + action_exists: check_action_exists, + action_run_exists: check_action_run_exists, addon_exists: check_addon_exists, build_exists: check_build_exists, topic_exists: check_topic_exists, @@ -760,6 +868,7 @@ module.exports = { notify_hooks, preview_apps, update_release_status, + update_action_run_status, services: () => global.addon_services, random_name: () => nouns[Math.floor(nouns.length * Math.random())], HttpError: httph.HttpError, @@ -777,4 +886,5 @@ module.exports = { lifecycle: (new ApplicationLifecycle()), init, query_audits, + trigger_action, }; diff --git a/lib/events.js b/lib/events.js index 6d8f027..f139d7c 100644 --- a/lib/events.js +++ b/lib/events.js @@ -90,7 +90,53 @@ async function create(pg_pool, req, res /* regex */) { message: payload.message, ...'link' in payload && { link: payload.link }, }; + } else if (payload.action === 'action_run_started') { + // Make sure it is a valid action + if (!payload.action_details || !payload.action_details.id || !payload.action_details.run) { + throw new common.BadRequestError(); + } + + const action = await common.action_exists(pg_pool, app.app_uuid, payload.action_details.id); + const action_run = await common.action_run_exists(pg_pool, payload.action_details.id, payload.action_details.run); + // The apps-watcher doesn't know about the source event, so we need to add it here + payload.source = action_run.source; + + // Update action run status to 'running' + await common.update_action_run_status( + pg_pool, + action.action, + payload.action_details.run, + 'running', + null, + payload.started_at, + ); + } else if (payload.action === 'action_run_finished') { + // Make sure it is a valid action + if (!payload.action_details || !payload.action_details.id || !payload.action_details.run) { + throw new common.BadRequestError(); + } + + const action = await common.action_exists(pg_pool, app.app_uuid, payload.action_details.id); + const action_run = await common.action_run_exists(pg_pool, payload.action_details.id, payload.action_details.run); + + // The apps-watcher doesn't know about the source event, so we need to add it here + payload.source = action_run.source; + + // Update action run status and include run metadata + await common.update_action_run_status( + pg_pool, + action.action, + payload.action_details.run, + payload.success ? 'success' : 'failure', + typeof payload.exit_code === 'number' ? payload.exit_code : parseInt(payload.exit_code, 10), + payload.started_at, + payload.finished_at, + ); + + // Tell region-api to clean up pod + await common.alamo.oneoff_stop(pg_pool, app.space_name, app.app_name, action.formation.type); } + common.notify_hooks(pg_pool, app.app_uuid, payload.action, JSON.stringify(payload), req.headers['x-username']); return httph.ok_response(res, JSON.stringify({ status: 'ok' })); } diff --git a/lib/formations.js b/lib/formations.js index 5476045..ec10106 100755 --- a/lib/formations.js +++ b/lib/formations.js @@ -25,6 +25,8 @@ function postgres_to_payload(result) { type: result.type, port: result.type === 'web' ? result.port : null, healthcheck: result.type === 'web' ? result.healthcheck : null, + // Only add options for oneoff dynos + ...(result.oneoff && result.oneoff === true && { oneoff: result.oneoff, options: result.options }), updated_at: result.updated, }; } @@ -197,10 +199,32 @@ async function update_dyno(pg_pool, app_uuid, app_name, space_name, form) { return formation[0]; } +function check_image(image) { + assert.ok( + typeof image === 'string' && image.trim().length > 0, + 'The one-off image specified was invalid - must be a non-empty string', + ); + // Make sure that there is a tag on the end of the specified image + const tagMatch = /^(.*):([a-zA-Z0-9._-]+)$/.exec(image.trim()); + assert.ok(tagMatch && !!tagMatch[1], 'A tag must be specified for the one-off image option, but no tag was found.'); +} + +function check_env(env) { + // Make sure that env follows this forrmat: { "key": "value" } + assert.ok(env instanceof Object, 'The env option must be an object consisting of at least one key-value pair.'); + assert.ok(Object.keys(env).length > 0, 'The env option must be an object consisting of at least one key-value pair.'); + Object.keys(env).forEach((key) => { + assert.ok( + typeof env[key] === 'string', + 'Invalid env value: env option must be an object consisting of at least one key-value pair.', + ); + }); +} + // public function formation_payload_check(payload, sizes) { assert.ok(typeof (payload.size) === 'undefined' || sizes.indexOf(payload.size) !== -1, - 'The payload size was not recognized.'); + 'The formation size was not recognized.'); assert.ok((typeof (payload.type) === 'undefined' || payload.type) && /(^[a-z0-9]+$)/.exec(payload.type) !== null, 'The type specified was invalid, it cannot contain spaces or special characters (lower case alpha numeric only)'); assert.ok(typeof (payload.quantity) === 'undefined' || (Number.isInteger(payload.quantity) && payload.quantity > -1 && payload.quantity < 33), @@ -229,24 +253,10 @@ function formation_payload_check(payload, sizes) { ); if (payload.oneoff && payload.options && payload.options instanceof Object && Object.keys(payload.options).length > 0) { if (payload.options.image || payload.options.image === '') { - assert.ok( - typeof payload.options.image === 'string' && payload.options.image.trim().length > 0, - 'The one-off image specified was invalid - must be a non-empty string', - ); - // Make sure that there is a tag on the end of the specified image - const tagMatch = /^(.*):([a-zA-Z0-9._-]+)$/.exec(payload.options.image.trim()); - assert.ok(tagMatch && !!tagMatch[1], 'A tag must be specified for the one-off image option, but no tag was found.'); + check_image(payload.options.image); } if (payload.options.env || payload.options.env === '') { - // Make sure that env follows this forrmat: { "key": "value" } - assert.ok(payload.options.env instanceof Object, 'The env option must be an object consisting of at least one key-value pair.'); - assert.ok(Object.keys(payload.options.env).length > 0, 'The env option must be an object consisting of at least one key-value pair.'); - Object.keys(payload.options.env).forEach((key) => { - assert.ok( - typeof payload.options.env[key] === 'string', - 'Invalid env value: env option must be an object consisting of at least one key-value pair.', - ); - }); + check_env(payload.options.env); } } } @@ -317,6 +327,57 @@ async function create( ); } +const update_oneoff_formation = query.bind(query, fs.readFileSync('./sql/update_oneoff_formation.sql').toString('utf8'), (r) => r); +async function oneoff_update(pg_pool, app_uuid, space_name, formation_uuid, size, command, options) { + const formation = (await select_formation(pg_pool, [app_uuid, formation_uuid]))[0]; + assert(formation, 'The specified formation does not exist'); + + const payload = {}; + + // If given update value exists, check and add to payload. + + if (size && size !== '') { + const sizes = await sizes_to_enum(pg_pool, space_name); + assert.ok(sizes.indexOf(size) !== -1, 'The formation size was not recognized.'); + payload.size = size; + } + + if (command && command !== '') { + payload.command = command; + } + + // Options + + payload.options = formation.options || {}; + + if (options && options.image !== null && typeof options.image === 'string') { + // If empty string, skip check and clear image override + if (options.image !== '') { + check_image(options.image); + payload.options.image = options.image; + } else { + payload.options.image = undefined; + } + } + + // The environment will be replaced with what's in the payload, if provided. + // This is a full update, not an individual patch per env var. + // Might take another look at this in the future + + if (options && options.env && typeof options.env === 'object') { + // If empty object, skip check and clear environment variables + if (Object.keys(options.env).length !== 0) { + check_env(options.env); + payload.options.env = options.env; + } else { + payload.options.env = undefined; + } + } + + // Null/undefined values won't be updated (handled in query) + await update_oneoff_formation(pg_pool, [formation_uuid, payload.size, payload.command, payload.options]); +} + // public async function http_list(pg_pool, req, res, regex) { const app_key = httph.first_match(req.url, regex); @@ -570,4 +631,6 @@ module.exports = { delete: http_delete, }, delete_dynos, + delete_dyno, + oneoff_update, }; diff --git a/lib/hooks.js b/lib/hooks.js index 02edc8c..c6501ed 100755 --- a/lib/hooks.js +++ b/lib/hooks.js @@ -8,6 +8,14 @@ const common = require('./common.js'); const config = require('./config.js'); const availableHooks = [ + { + type: 'action_run_finished', + description: 'Fired when an action run is finished. Fires once per run.', + }, + { + type: 'action_run_started', + description: 'Fired when an action run is started. Fires at most once per run, but might not fire if the run takes less than 10 seconds', + }, { type: 'addon_change', description: 'Fired when an addon is provisioned or deprovisioned. This does not fire when addons are attached or de-attached.', @@ -250,4 +258,5 @@ module.exports = { results: hooks_results, result: hooks_result, descriptions: hooks_descriptions, + available_hooks: availableHooks, }; diff --git a/package-lock.json b/package-lock.json index 7eab6d4..9b76e09 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "akkeris-controller-api", - "version": "4.1.1", + "version": "4.3.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "akkeris-controller-api", - "version": "4.1.1", + "version": "4.2.0", "license": "Apache-2.0", "dependencies": { "dotenv": "^10.0.0", @@ -4270,9 +4270,9 @@ "dev": true }, "node_modules/ngrok": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/ngrok/-/ngrok-3.4.1.tgz", - "integrity": "sha512-OTm6Nmi6JINPbzkZff8ysA2WqMeNDg3sOPMFHW2CpatVD5yJxmX1qdyLq3QYNACTKNB3/K9jTkG4wUVpAFX9Dw==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/ngrok/-/ngrok-3.3.0.tgz", + "integrity": "sha512-NuTfuxttodW6o9cPEKiJgjmjNzolKHoUPaK7I4SbaJVkHd4cuUTlv9YsXNzBFrcNFSsDLuCyCpjsaqe1OvIPSQ==", "dev": true, "hasInstallScript": true, "dependencies": { @@ -9903,9 +9903,9 @@ "dev": true }, "ngrok": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/ngrok/-/ngrok-3.4.1.tgz", - "integrity": "sha512-OTm6Nmi6JINPbzkZff8ysA2WqMeNDg3sOPMFHW2CpatVD5yJxmX1qdyLq3QYNACTKNB3/K9jTkG4wUVpAFX9Dw==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/ngrok/-/ngrok-3.3.0.tgz", + "integrity": "sha512-NuTfuxttodW6o9cPEKiJgjmjNzolKHoUPaK7I4SbaJVkHd4cuUTlv9YsXNzBFrcNFSsDLuCyCpjsaqe1OvIPSQ==", "dev": true, "requires": { "@types/node": "^8.10.50", diff --git a/package.json b/package.json index 969e987..5b34333 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "akkeris-controller-api", - "version": "4.2.0", + "version": "4.3.0", "description": "Central API for controlling apps in akkeris", "main": "index.js", "scripts": { diff --git a/sql/create.sql b/sql/create.sql index 7091763..e9e2b16 100755 --- a/sql/create.sql +++ b/sql/create.sql @@ -659,6 +659,34 @@ begin primary key (app, service, resource_type, action) -- We only store the latest recommendation for the app, service, resource_type, and action ); + create table if not exists actions + ( + action uuid not null primary key, + app uuid not null references apps("app"), + formation uuid not null references formations("formation"), + name alpha_numeric not null, + description varchar(1024) not null default '', + events text, -- Comma separated list of events that will trigger the action + created_by varchar(1024) not null default 'unknown', + created timestamptz not null default now(), + updated timestamptz not null default now(), + deleted boolean not null default false + ); + + create table if not exists action_runs + ( + action_run uuid not null primary key, + action uuid not null references actions("action"), + run_number integer not null default 1, + status varchar(128) not null default 'starting', + exit_code integer null, + source varchar(1024), + started_at timestamptz, + finished_at timestamptz, + created_by varchar(1024) not null default 'unknown', + created timestamptz not null default now(), + UNIQUE (action, run_number) + ); alter table recommendations add column if not exists deleted bool not null default false; -- create default regions and stacks diff --git a/sql/delete_action.sql b/sql/delete_action.sql new file mode 100755 index 0000000..3e6e124 --- /dev/null +++ b/sql/delete_action.sql @@ -0,0 +1,6 @@ +update actions set + deleted = true, + updated = now() +where + action::varchar(1024) = $1 +returning * \ No newline at end of file diff --git a/sql/insert_action.sql b/sql/insert_action.sql new file mode 100644 index 0000000..ea5635a --- /dev/null +++ b/sql/insert_action.sql @@ -0,0 +1,5 @@ +insert into actions + (action, app, formation, name, description, events, created_by) +values + ($1, $2, $3, $4, $5, $6, $7) +returning * diff --git a/sql/insert_action_run.sql b/sql/insert_action_run.sql new file mode 100644 index 0000000..8afdb0e --- /dev/null +++ b/sql/insert_action_run.sql @@ -0,0 +1,5 @@ +insert into action_runs + (action_run, action, status, source, exit_code, created_by, run_number) +values + ($1, $2, $3, $4, $5, $6, (select coalesce(max(run_number), 0) from action_runs where action = $2) + 1) +returning * diff --git a/sql/select_action.sql b/sql/select_action.sql new file mode 100644 index 0000000..134b84a --- /dev/null +++ b/sql/select_action.sql @@ -0,0 +1,38 @@ +select + actions.action, + actions.app, + actions.name, + actions.description, + actions.events, + actions.created_by, + actions.created, + actions.updated, + actions.deleted, + ( + select + json_build_object('id', formations.formation, 'type', formations.type, 'size', formations.size, 'command', formations.command, 'options', formations.options) + from + formations + where + formations.app = apps.app + and formations.formation = actions.formation + and formations.deleted = false + ) formation +from + actions + join + apps + on apps.app = actions.app + join + formations + on formations.formation = actions.formation +where + actions.deleted = false + and apps.deleted = false + and apps.app::varchar(1024) = $1::varchar(1024) + and + ( + actions.action::varchar(1024) = $2::varchar(1024) + or actions.name = $2 + ) +; diff --git a/sql/select_action_run.sql b/sql/select_action_run.sql new file mode 100644 index 0000000..11f710c --- /dev/null +++ b/sql/select_action_run.sql @@ -0,0 +1,20 @@ +select + action_runs.action_run, + action_runs.action, + action_runs.run_number, + action_runs.status, + action_runs.exit_code, + action_runs.source, + action_runs.started_at, + action_runs.finished_at, + action_runs.created_by, + action_runs.created +from + action_runs + join + actions + on action_runs.action = actions.action +where + actions.deleted = false + and action_runs.action::varchar(1024) = $1::varchar(1024) + and action_runs.action_run::varchar(1024) = $2::varchar(1024) diff --git a/sql/select_all_action_runs.sql b/sql/select_all_action_runs.sql new file mode 100644 index 0000000..e15eaa1 --- /dev/null +++ b/sql/select_all_action_runs.sql @@ -0,0 +1,20 @@ +select + action_runs.action_run, + action_runs.action, + action_runs.run_number, + action_runs.status, + action_runs.exit_code, + action_runs.source, + action_runs.started_at, + action_runs.finished_at, + action_runs.created_by, + action_runs.created +from + action_runs + join + actions + on action_runs.action = actions.action +where + actions.deleted = false + and action_runs.action::varchar(1024) = $1::varchar(1024) +order by created desc \ No newline at end of file diff --git a/sql/select_all_actions.sql b/sql/select_all_actions.sql new file mode 100644 index 0000000..f247e90 --- /dev/null +++ b/sql/select_all_actions.sql @@ -0,0 +1,33 @@ +select + actions.action, + actions.app, + actions.name, + actions.description, + actions.events, + actions.created_by, + actions.created, + actions.updated, + actions.deleted, + ( + select + json_build_object('id', formations.formation, 'type', formations.type, 'size', formations.size, 'command', formations.command, 'options', formations.options) + from + formations + where + formations.app = apps.app + and formations.formation = actions.formation + and formations.deleted = false + ) formation +from + actions + join + apps + on apps.app = actions.app + join + formations + on formations.formation = actions.formation +where + actions.deleted = false + and apps.deleted = false + and apps.app::varchar(1024) = $1::varchar(1024) +order by actions.name asc diff --git a/sql/select_formation.sql b/sql/select_formation.sql index 4564eac..a63aa66 100755 --- a/sql/select_formation.sql +++ b/sql/select_formation.sql @@ -7,10 +7,11 @@ select formations.quantity, formations.size, formations.type, - formations.command, formations.port, formations.updated, - formations.healthcheck + formations.healthcheck, + formations.oneoff, + formations.options from formations join apps on formations.app = apps.app diff --git a/sql/update_action.sql b/sql/update_action.sql new file mode 100644 index 0000000..46d3508 --- /dev/null +++ b/sql/update_action.sql @@ -0,0 +1,6 @@ +update actions set + description = coalesce($2, description), + events = coalesce($3, events) +where + action = $1 and + deleted = false \ No newline at end of file diff --git a/sql/update_action_run_status.sql b/sql/update_action_run_status.sql new file mode 100644 index 0000000..7ad721d --- /dev/null +++ b/sql/update_action_run_status.sql @@ -0,0 +1,8 @@ +update action_runs set + status = coalesce($3, status), + exit_code = coalesce($4, exit_code), + started_at = coalesce($5, started_at), + finished_at = coalesce($6, finished_at) +where + action_runs.action::varchar(1024) = $1::varchar(1024) + and action_runs.action_run::varchar(1024) = $2::varchar(1024) \ No newline at end of file diff --git a/sql/update_oneoff_formation.sql b/sql/update_oneoff_formation.sql new file mode 100755 index 0000000..94df6a5 --- /dev/null +++ b/sql/update_oneoff_formation.sql @@ -0,0 +1,7 @@ +update formations set + size = coalesce($2, size), + command = coalesce($3, command), + options = coalesce($4, options) +where + (formation::varchar(1024) = $1) +returning * \ No newline at end of file diff --git a/test/actions.js b/test/actions.js new file mode 100644 index 0000000..5054213 --- /dev/null +++ b/test/actions.js @@ -0,0 +1,215 @@ +const { + expect +} = require('chai'); +const http_helper = require('../lib/http_helper.js'); + +/** + * Try to fetch the status of a completed pod every half second for up to 30 seconds + * @param {string} url URL of the pod status endpoint of the region-api + * @returns Status information of the pod in an object, if the pod exists and was complete + */ +const watch = async (url) => { + for (let i = 0; i < 60; i++) { + const pod_status_url = url; + const pod_status_after_finish = await http_helper.request('get', pod_status_url, null); // eslint-disable-line + if (pod_status_after_finish && pod_status_after_finish !== 'null') { + const status = JSON.parse(pod_status_after_finish)[0]; + // Pod is done + if (status.output === 'Completed' || status.output === 'Failed') { + return status; + } + } + // Wait half of a second + await (new Promise((res) => setTimeout(res, 500))); // eslint-disable-line + } + return null; +}; + +describe('actions:', function () { + this.timeout(64000); + process.env.AUTH_KEY = 'hello'; + const init = require('./support/init.js'); + const config = require('../lib/config.js'); + + const akkeris_headers = { + Authorization: process.env.AUTH_KEY, + 'User-Agent': 'Hello', + 'x-username': 'Calaway', + 'Content-type': 'application/json', + }; + + // Nested describe block needed for correct execution order of before/after hooks + describe('', function () { + let testapp; + let test_action; + let created_action = false; + + before(async () => { + testapp = await init.create_test_app(); + }); + + after(async () => { + await init.remove_app(testapp); + }); + + it('covers creating an action and manually triggering an action run', async () => { + // Create action + const payload = { + name: 'testaction', + description: 'This action runs a Docker container and then exits.', + command: 'sleep 10', + options: { + image: 'busybox:latest', + }, + }; + const action = await http_helper.request('post', `http://localhost:5000/apps/${testapp.name}/actions`, akkeris_headers, payload); + + // Verify result + test_action = JSON.parse(action); + const uuid_regex = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/; + expect(test_action.action).to.match(uuid_regex); + expect(test_action.app).to.equal(testapp.id); + expect(test_action.formation).to.match(uuid_regex); + expect(test_action.name).to.equal('testaction'); + expect(test_action.description).to.equal('This action runs a Docker container and then exits.'); + expect(test_action.created_by).to.equal('Calaway'); + expect(Date.now() - Date.parse(test_action.created)).to.be.lessThan(10000); + expect(Date.now() - Date.parse(test_action.updated)).to.be.lessThan(10000); + expect(test_action.deleted).to.equal(false); + + // Verify formation + const formations = await http_helper.request('get', `http://localhost:5000/apps/${testapp.name}/formation`, akkeris_headers); + const one_off_formation = JSON.parse(formations).find((f) => f.type !== 'web'); + expect(one_off_formation.app.id).to.equal(testapp.id); + expect(one_off_formation.id).to.match(uuid_regex); + expect(one_off_formation.type).to.equal('actionstestaction'); + expect(one_off_formation.quantity).to.equal(1); + expect(one_off_formation.size).to.equal(config.dyno_default_size); + expect(one_off_formation.command).to.equal('sleep 10'); + expect(one_off_formation.port).to.equal(null); + expect(one_off_formation.healthcheck).to.equal(null); + expect(Date.now() - Date.parse(one_off_formation.created_at)).to.be.lessThan(10000); + expect(Date.now() - Date.parse(one_off_formation.updated_at)).to.be.lessThan(10000); + + created_action = true; + + // Verify pod does not exist before action run created + const pod_status_url = `${process.env.MARU_STACK_API}/v1/kube/podstatus/${testapp.space.name}/${testapp.simple_name}--${one_off_formation.type}`; + const pod_status_before = await http_helper.request('get', pod_status_url, null); + expect(pod_status_before).to.equal('null'); + + const action_run_response = await http_helper.request('post', `http://localhost:5000/apps/${testapp.name}/actions/${test_action.name}/runs`, akkeris_headers); + const action_run = JSON.parse(action_run_response); + + // Verify that action run was properly in the database + expect(action_run.action_run).to.match(uuid_regex); + expect(action_run.action).to.equal(test_action.action); + expect(action_run.status).to.equal('starting'); + expect(action_run.exit_code).to.equal(null); + expect(action_run.created_by).to.equal('Calaway'); + expect(action_run.started_at).to.equal(null); + expect(action_run.finished_at).to.equal(null); + expect(action_run.source).to.equal('manual_trigger'); + expect(Date.now() - Date.parse(action_run.created)).to.be.lessThan(10000); + + // Wait 8 seconds + await init.wait(8000); + + // Verify that action run has started with Kubernetes + const pod_status_after_start = await http_helper.request('get', pod_status_url, null); + expect(JSON.parse(pod_status_after_start)[0].ready).to.equal(true); + + // We don't have the apps-watcher updating the test database so we can't check + // and see if the database updated with the results. + // TODO: May need to revisit this in the future (commented out code below) + + // Verify that action run has started in the database + // const run_started_details_response = await http_helper.request( + // 'get', + // `http://localhost:5000/apps/${testapp.name}/actions/${test_action.name}/runs/${action_run.action_run}`, + // akkeris_headers, + // ); + // const run_started_details = JSON.parse(run_started_details_response); + // expect(run_started_details.status).to.equal('running'); + + // Wait 10 seconds + await init.wait(10000); + + // Verify that action run status was updated as completed + // const run_finished_details_response = await http_helper.request( + // 'get', + // `http://localhost:5000/apps/${testapp.name}/actions/${test_action.name}/runs/${action_run.action_run}`, + // ); + // const run_finished_details = JSON.parse(run_finished_details_response); + // expect(run_finished_details.status).to.equal('success'); + // expect(run_finished_details.exit_code).to.equal(0); + + // Verify that action run completed with Kubernetes and was deleted + + // Bit of a race condition here with the apps-watcher & region-api deleting the pod + // We don't want to wait TOO long, because then the tests would take forever + // So, if the status is null it's a success. + // If it's not, make sure the run completed by checking the state + const pod_status_after_finish = await http_helper.request('get', pod_status_url, null); + if (pod_status_after_finish !== null) { + expect(JSON.parse(pod_status_after_finish)[0].state.terminated.reason).to.equal('Completed'); + expect(JSON.parse(pod_status_after_finish)[0].state.terminated.exitCode).to.equal(0); + } + }); + + it('covers updating an action', async () => { + // stub + expect(created_action).to.equal(true); + const payload = { + description: '', + command: 'sleep 10', + options: { + image: 'busybox:1.34', + }, + }; + // Test updating fields + // Test removing fields + const update_action_response = await http_helper.request('patch', `http://localhost:5000/apps/${testapp.name}/actions/${test_action.name}`, akkeris_headers, payload); + const action_update = JSON.parse(update_action_response); + + expect(action_update.description).to.equal(''); + expect(action_update.formation.options.image).to.equal('busybox:1.34'); + }); + + it('covers triggering an action on an event', () => { + // stub + expect(created_action).to.equal(true); + // Create an action that is triggered by an event + // Fire that event (config change is pretty easy) + // Make sure that the action fired and completed + }); + + it.only('covers actions that fail during execution', async () => { + // Create and trigger an action run that is expected to fail + // Make sure that the result is "failure" and the exit code is expected + const payload = { + name: 'badtestaction', + description: 'This action tries to run a Docker container, but it fails.', + command: 'filenotfound', + options: { + image: 'busybox:latest', + }, + }; + try { + // Create action + await http_helper.request('post', `http://localhost:5000/apps/${testapp.name}/actions`, akkeris_headers, payload); + + // Trigger run + await http_helper.request('post', `http://localhost:5000/apps/${testapp.name}/actions/badtestaction/runs`, akkeris_headers); + + // Watch for the pod to be done + const pod_status = await watch(`${process.env.MARU_STACK_API}/v1/kube/podstatus/${testapp.space.name}/${testapp.simple_name}--actionsbadtestaction`); + expect(pod_status).to.not.equal(null); + expect(pod_status.output).to.equal('Failed'); + } catch (err) { + console.error(err); + expect(err).to.equal(null); + } + }); + }); +}); \ No newline at end of file