From fdd02cab5f820a05f42a29495110450d5a04cc34 Mon Sep 17 00:00:00 2001 From: Calaway Date: Tue, 2 Mar 2021 15:14:23 -0700 Subject: [PATCH 01/21] COBRA-4159: Build out actions create endpoint --- index.js | 6 +++++ lib/actions.js | 57 ++++++++++++++++++++++++++++++++++++++++ sql/create.sql | 13 +++++++++ sql/insert_action.sql | 5 ++++ test/actions.js | 61 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 142 insertions(+) create mode 100644 lib/actions.js create mode 100644 sql/insert_action.sql create mode 100644 test/actions.js diff --git a/index.js b/index.js index f5b969d0..148f7739 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'), @@ -826,6 +827,11 @@ routes.add.post('/apps/([A-z0-9\\-\\_\\.]+)/recommendations$') .run(alamo.recommendations.http.create.bind(alamo.recommendations.http.create, pg_pool)) .and.authorization([simple_key, jwt_key]); +// -- actions +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]); + 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 00000000..12354296 --- /dev/null +++ b/lib/actions.js @@ -0,0 +1,57 @@ +const fs = require('fs'); +const uuid = require('uuid'); + +const common = require('./common'); +const formations = require('./formations'); +const http_helper = require('./http_helper'); +const query = require('./query'); + +// 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 space = await common.space_exists(pg_pool, app.space_name); + const payload = await http_helper.buffer_json(req); + const formation_type = `actions${payload.name}`; + + // Create a one-off formation + const formation = await formations.create( + pg_pool, + app.app_uuid, + app.app_name, + app.space_name, + 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 id = uuid.v4(); + const created_by = req.headers['x-username'] || 'Unknown'; + const actions_params = [ + id, + app.app_uuid, + formation.id, + payload.name, + payload.description || '', + created_by, + ]; + const actions = await insert_action(pg_pool, actions_params); + return http_helper.created_response(res, actions); +} + +module.exports = { + http: { + create: http_create, + }, +}; diff --git a/sql/create.sql b/sql/create.sql index df1219dc..5239f59c 100755 --- a/sql/create.sql +++ b/sql/create.sql @@ -645,6 +645,19 @@ 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 '', + created_by varchar(1024) not null default '', + created timestamptz not null default now(), + updated timestamptz not null default now(), + deleted boolean not null default false + ); + -- create default regions and stacks if (select count(*) from regions where deleted = false) = 0 then insert into regions diff --git a/sql/insert_action.sql b/sql/insert_action.sql new file mode 100644 index 00000000..c6adc76e --- /dev/null +++ b/sql/insert_action.sql @@ -0,0 +1,5 @@ +insert into actions + (action, app, formation, name, description, created_by) +values + ($1, $2, $3, $4, $5, $6) +returning * diff --git a/test/actions.js b/test/actions.js new file mode 100644 index 00000000..7a85f619 --- /dev/null +++ b/test/actions.js @@ -0,0 +1,61 @@ +const { expect } = require('chai'); +const http_helper = require('../lib/http_helper.js'); + +describe('Actions', () => { + 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('', async () => { + let testapp; + + before(async () => { + testapp = await init.create_test_app('default'); + }); + + after(async () => { + await init.remove_app(testapp); + }); + + it('create a new action', async () => { + const payload = { + name: 'testaction', + description: 'This action runs a Docker container and then exits.', + }; + const actions = await http_helper.request('post', `http://localhost:5000/apps/${testapp.name}/actions`, akkeris_headers, payload); + + const test_action = JSON.parse(actions)[0]; + 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(60); + expect(Date.now() - Date.parse(test_action.updated)).to.be.lessThan(60); + expect(test_action.deleted).to.equal(false); + + 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(null); + 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(60); + expect(Date.now() - Date.parse(one_off_formation.updated_at)).to.be.lessThan(60); + }); + }); +}); From cb86d4419b520b61676091e680e5be81beea9d8a Mon Sep 17 00:00:00 2001 From: Calaway Date: Mon, 8 Mar 2021 17:23:11 -0700 Subject: [PATCH 02/21] COBRA-4159: Build out actions runs create endpoint --- index.js | 3 +++ lib/actions.js | 40 ++++++++++++++++++++++++++++++++++++++-- lib/common.js | 14 ++++++++++++-- sql/select_action.sql | 30 ++++++++++++++++++++++++++++++ test/actions.js | 17 ++++++++++++++--- 5 files changed, 97 insertions(+), 7 deletions(-) create mode 100644 sql/select_action.sql diff --git a/index.js b/index.js index 148f7739..3a0ad0b4 100755 --- a/index.js +++ b/index.js @@ -831,6 +831,9 @@ routes.add.post('/apps/([A-z0-9\\-\\_\\.]+)/recommendations$') 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]); +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]); routes.add.default((req, res) => { res.writeHead(404, {}); diff --git a/lib/actions.js b/lib/actions.js index 12354296..c0b40439 100644 --- a/lib/actions.js +++ b/lib/actions.js @@ -11,7 +11,6 @@ const insert_action = query.bind(query, fs.readFileSync('sql/insert_action.sql') 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 space = await common.space_exists(pg_pool, app.space_name); const payload = await http_helper.buffer_json(req); const formation_type = `actions${payload.name}`; @@ -21,7 +20,7 @@ async function http_create(pg_pool, req, res, regex) { app.app_uuid, app.app_name, app.space_name, - space.tags, + app.space_tags, app.org_name, formation_type, payload.size, @@ -50,8 +49,45 @@ async function http_create(pg_pool, req, res, regex) { return http_helper.created_response(res, actions); } +async function http_runs_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 action_key = http_helper.second_match(req.url, regex); + const action = await common.action_exists(pg_pool, app.app_uuid, action_key); + const select_latest_image = query.bind(query, fs.readFileSync('./sql/select_latest_image.sql').toString('utf8'), (r) => r); + const image = (await select_latest_image(pg_pool, [app.app_uuid]))[0]; + const image_url = common.registry_image( + image.build_org_name, + image.build_app_name, + image.build_app, + image.foreign_build_key, + image.foreign_build_system, + ); + + const runid = uuid.v4(); + const labels = { 'logtrain.akkeris.io/drains': `persistent://${runid}` }; + const env = null; + await common.alamo.oneoff_deploy( + pg_pool, + app.space_name, + app.app_name, + action.formation.type, + image_url, + action.formation.command, + labels, + env, + action.formation.size, + runid, + ); + + return http_helper.created_response(res, 'OK'); +} + module.exports = { http: { create: http_create, + runs: { + create: http_runs_create, + }, }, }; diff --git a/lib/common.js b/lib/common.js index f2918690..02d94638 100755 --- a/lib/common.js +++ b/lib/common.js @@ -324,6 +324,15 @@ 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_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]); @@ -468,7 +477,7 @@ async function check_deployment_filters(pg_pool, app_uuid, dyno_type, features) 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};`; + policy += ` font-src ${allowed_domains}; img-src ${allowed_domains}; media-src ${allowed_domains}; style-src ${allowed_domains};`; } if(features['csp-unsafe']) { policy += ` base-uri ${allowed_domains};`; @@ -483,7 +492,7 @@ async function check_deployment_filters(pg_pool, app_uuid, dyno_type, features) filters.push({ type: 'csp', data: { - policy: 'default-src https:;' + policy, + policy: 'default-src https:;' + policy, }, }); } @@ -736,6 +745,7 @@ module.exports = { plan_by_id_or_name, feature_enabled, app_exists: check_app_exists, + action_exists: check_action_exists, addon_exists: check_addon_exists, build_exists: check_build_exists, topic_exists: check_topic_exists, diff --git a/sql/select_action.sql b/sql/select_action.sql new file mode 100644 index 00000000..b3bc434d --- /dev/null +++ b/sql/select_action.sql @@ -0,0 +1,30 @@ +select + actions.name, + ( + select + json_build_object('id', formations.formation, 'type', formations.type, 'size', formations.size, 'command', formations.command) + 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/test/actions.js b/test/actions.js index 7a85f619..f5dd75fb 100644 --- a/test/actions.js +++ b/test/actions.js @@ -1,7 +1,8 @@ const { expect } = require('chai'); const http_helper = require('../lib/http_helper.js'); -describe('Actions', () => { +describe('Actions', function () { + this.timeout(64000); process.env.AUTH_KEY = 'hello'; const init = require('./support/init.js'); const config = require('../lib/config.js'); @@ -18,14 +19,14 @@ describe('Actions', () => { let testapp; before(async () => { - testapp = await init.create_test_app('default'); + testapp = await init.create_test_app_with_content('OK', 'default'); }); after(async () => { await init.remove_app(testapp); }); - it('create a new action', async () => { + it('create a new action and then manually trigger a new run', async () => { const payload = { name: 'testaction', description: 'This action runs a Docker container and then exits.', @@ -56,6 +57,16 @@ describe('Actions', () => { expect(one_off_formation.healthcheck).to.equal(null); expect(Date.now() - Date.parse(one_off_formation.created_at)).to.be.lessThan(60); expect(Date.now() - Date.parse(one_off_formation.updated_at)).to.be.lessThan(60); + + 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'); + + await http_helper.request('post', `http://localhost:5000/apps/${testapp.name}/actions/${test_action.name}/runs`, akkeris_headers); + + await init.wait(2000); + const pod_status_after = await http_helper.request('get', pod_status_url, null); + expect(JSON.parse(pod_status_after)[0].ready).to.equal(true); }); }); }); From c847d7997af535a695dfbbdb5aa3e3212efb84f1 Mon Sep 17 00:00:00 2001 From: Calaway Date: Thu, 11 Mar 2021 16:43:58 -0700 Subject: [PATCH 03/21] COBRA-4159: Write action run data to DB --- lib/actions.js | 22 +++++++++++++++++----- sql/create.sql | 10 ++++++++++ sql/insert_action_run.sql | 5 +++++ sql/select_action.sql | 7 +++++++ test/actions.js | 20 ++++++++++++++------ 5 files changed, 53 insertions(+), 11 deletions(-) create mode 100644 sql/insert_action_run.sql diff --git a/lib/actions.js b/lib/actions.js index c0b40439..87efc472 100644 --- a/lib/actions.js +++ b/lib/actions.js @@ -35,20 +35,22 @@ async function http_create(pg_pool, req, res, regex) { ); // Insert an action into the DB - const id = uuid.v4(); + const action_id = uuid.v4(); const created_by = req.headers['x-username'] || 'Unknown'; - const actions_params = [ - id, + const action_params = [ + action_id, app.app_uuid, formation.id, payload.name, payload.description || '', created_by, ]; - const actions = await insert_action(pg_pool, actions_params); + const actions = await insert_action(pg_pool, action_params); + return http_helper.created_response(res, actions); } +const insert_action_run = query.bind(query, fs.readFileSync('sql/insert_action_run.sql').toString('utf8'), (result) => result); async function http_runs_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); @@ -80,7 +82,17 @@ async function http_runs_create(pg_pool, req, res, regex) { runid, ); - return http_helper.created_response(res, 'OK'); + const action_run_id = uuid.v4(); + const action_run_params = [ + action_run_id, + action.action, + runid, + 'running', + null, + ]; + const action_runs = await insert_action_run(pg_pool, action_run_params); + + return http_helper.created_response(res, action_runs); } module.exports = { diff --git a/sql/create.sql b/sql/create.sql index 5239f59c..7c6195ce 100755 --- a/sql/create.sql +++ b/sql/create.sql @@ -658,6 +658,16 @@ begin 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"), + runid uuid not null, + status varchar(128) not null, + exit_code integer null, + created timestamptz not null default now() + ); + -- create default regions and stacks if (select count(*) from regions where deleted = false) = 0 then insert into regions diff --git a/sql/insert_action_run.sql b/sql/insert_action_run.sql new file mode 100644 index 00000000..c9c6e8bd --- /dev/null +++ b/sql/insert_action_run.sql @@ -0,0 +1,5 @@ +insert into action_runs + (action_run, action, runid, status, exit_code) +values + ($1, $2, $3, $4, $5) +returning * diff --git a/sql/select_action.sql b/sql/select_action.sql index b3bc434d..a29c7b1a 100644 --- a/sql/select_action.sql +++ b/sql/select_action.sql @@ -1,5 +1,12 @@ select + actions.action, + actions.app, actions.name, + actions.description, + 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) diff --git a/test/actions.js b/test/actions.js index f5dd75fb..5498400e 100644 --- a/test/actions.js +++ b/test/actions.js @@ -41,8 +41,8 @@ describe('Actions', function () { 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(60); - expect(Date.now() - Date.parse(test_action.updated)).to.be.lessThan(60); + 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); const formations = await http_helper.request('get', `http://localhost:5000/apps/${testapp.name}/formation`, akkeris_headers); @@ -55,18 +55,26 @@ describe('Actions', function () { expect(one_off_formation.command).to.equal(null); 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(60); - expect(Date.now() - Date.parse(one_off_formation.updated_at)).to.be.lessThan(60); + 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); 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'); - await http_helper.request('post', `http://localhost:5000/apps/${testapp.name}/actions/${test_action.name}/runs`, akkeris_headers); + const action_runs = await http_helper.request('post', `http://localhost:5000/apps/${testapp.name}/actions/${test_action.name}/runs`, akkeris_headers); - await init.wait(2000); + await init.wait(4000); const pod_status_after = await http_helper.request('get', pod_status_url, null); expect(JSON.parse(pod_status_after)[0].ready).to.equal(true); + + const action_run = JSON.parse(action_runs)[0]; + expect(action_run.action_run).to.match(uuid_regex); + expect(action_run.action).to.equal(test_action.action); + expect(action_run.runid).to.match(uuid_regex); + expect(action_run.status).to.equal('running'); + expect(action_run.exit_code).to.equal(null); + expect(Date.now() - Date.parse(action_run.created)).to.be.lessThan(10000); }); }); }); From d39740bbfb254326ef5e10ca463ee6d0aa3f37ef Mon Sep 17 00:00:00 2001 From: Calaway Date: Thu, 11 Mar 2021 21:48:35 -0700 Subject: [PATCH 04/21] COBRA-4159: Add created_by field to action_runs --- lib/actions.js | 6 +++++- sql/create.sql | 5 +++-- sql/insert_action_run.sql | 4 ++-- test/actions.js | 1 + 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/lib/actions.js b/lib/actions.js index 87efc472..67f4415d 100644 --- a/lib/actions.js +++ b/lib/actions.js @@ -36,7 +36,7 @@ async function http_create(pg_pool, req, res, regex) { // Insert an action into the DB const action_id = uuid.v4(); - const created_by = req.headers['x-username'] || 'Unknown'; + const created_by = req.headers['x-username'] || 'unknown'; const action_params = [ action_id, app.app_uuid, @@ -66,6 +66,7 @@ async function http_runs_create(pg_pool, req, res, regex) { image.foreign_build_system, ); + // Deploy a one-off dyno const runid = uuid.v4(); const labels = { 'logtrain.akkeris.io/drains': `persistent://${runid}` }; const env = null; @@ -82,13 +83,16 @@ async function http_runs_create(pg_pool, req, res, regex) { runid, ); + // Insert an action run into the DB const action_run_id = uuid.v4(); + const created_by = req.headers['x-username'] || 'unknown'; const action_run_params = [ action_run_id, action.action, runid, 'running', null, + created_by, ]; const action_runs = await insert_action_run(pg_pool, action_run_params); diff --git a/sql/create.sql b/sql/create.sql index 7c6195ce..c984437f 100755 --- a/sql/create.sql +++ b/sql/create.sql @@ -652,7 +652,7 @@ begin formation uuid not null references formations("formation"), name alpha_numeric not null, description varchar(1024) not null default '', - created_by varchar(1024) not null default '', + 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 @@ -663,8 +663,9 @@ begin action_run uuid not null primary key, action uuid not null references actions("action"), runid uuid not null, - status varchar(128) not null, + status varchar(128) not null default 'unknown', exit_code integer null, + created_by varchar(1024) not null default 'unknown', created timestamptz not null default now() ); diff --git a/sql/insert_action_run.sql b/sql/insert_action_run.sql index c9c6e8bd..09c69130 100644 --- a/sql/insert_action_run.sql +++ b/sql/insert_action_run.sql @@ -1,5 +1,5 @@ insert into action_runs - (action_run, action, runid, status, exit_code) + (action_run, action, runid, status, exit_code, created_by) values - ($1, $2, $3, $4, $5) + ($1, $2, $3, $4, $5, $6) returning * diff --git a/test/actions.js b/test/actions.js index 5498400e..b282d966 100644 --- a/test/actions.js +++ b/test/actions.js @@ -74,6 +74,7 @@ describe('Actions', function () { expect(action_run.runid).to.match(uuid_regex); expect(action_run.status).to.equal('running'); expect(action_run.exit_code).to.equal(null); + expect(action_run.created_by).to.equal('Calaway'); expect(Date.now() - Date.parse(action_run.created)).to.be.lessThan(10000); }); }); From 0476d97851f7118884693bba52d5f574bf36069f Mon Sep 17 00:00:00 2001 From: Calaway Date: Thu, 11 Mar 2021 21:58:39 -0700 Subject: [PATCH 05/21] COBRA-4159: Document existing actions endpoints --- docs/API-Reference.md | 5 ++-- docs/Actions.md | 70 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 docs/Actions.md diff --git a/docs/API-Reference.md b/docs/API-Reference.md index d9fc2265..b2d7a9a9 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) @@ -33,5 +34,5 @@ You can use this API to programmatically create apps, provision add-ons, promote - [Sites](Sites.md) - [Routes](Routes.md) - [Audits](Audits.md) -- [Filters](Filters.md) +- [Filters](Filters.md) diff --git a/docs/Actions.md b/docs/Actions.md new file mode 100644 index 00000000..e7650391 --- /dev/null +++ b/docs/Actions.md @@ -0,0 +1,70 @@ +## 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. + +### Actions Create + +`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 | + +**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" }' +``` + +**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 +} +``` + + +### Action Runs Create + +`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" +} +``` From 8346a0678c116a97e22e418caae8ee68f6e14f2d Mon Sep 17 00:00:00 2001 From: Sam Beckett Date: Wed, 13 Oct 2021 16:55:31 -0600 Subject: [PATCH 06/21] add get, list, delete action & action runs --- index.js | 22 +++++++++ lib/actions.js | 87 +++++++++++++++++++++++++++++----- lib/formations.js | 3 ++ sql/delete_action.sql | 6 +++ sql/select_action.sql | 2 +- sql/select_action_run.sql | 18 +++++++ sql/select_all_action_runs.sql | 17 +++++++ sql/select_all_actions.sql | 32 +++++++++++++ sql/select_formation.sql | 5 +- 9 files changed, 178 insertions(+), 14 deletions(-) create mode 100755 sql/delete_action.sql create mode 100644 sql/select_action_run.sql create mode 100644 sql/select_all_action_runs.sql create mode 100644 sql/select_all_actions.sql diff --git a/index.js b/index.js index 3a0ad0b4..bd8e515c 100755 --- a/index.js +++ b/index.js @@ -828,12 +828,34 @@ routes.add.post('/apps/([A-z0-9\\-\\_\\.]+)/recommendations$') .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]); +// 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, {}); diff --git a/lib/actions.js b/lib/actions.js index 67f4415d..ee0ad451 100644 --- a/lib/actions.js +++ b/lib/actions.js @@ -50,21 +50,59 @@ async function http_create(pg_pool, req, res, regex) { return http_helper.created_response(res, actions); } +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); +} + +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 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); async function http_runs_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 action_key = http_helper.second_match(req.url, regex); const action = await common.action_exists(pg_pool, app.app_uuid, action_key); - const select_latest_image = query.bind(query, fs.readFileSync('./sql/select_latest_image.sql').toString('utf8'), (r) => r); - const image = (await select_latest_image(pg_pool, [app.app_uuid]))[0]; - const image_url = common.registry_image( - image.build_org_name, - image.build_app_name, - image.build_app, - image.foreign_build_key, - image.foreign_build_system, - ); + + 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]; + image = common.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, + ); + } // Deploy a one-off dyno const runid = uuid.v4(); @@ -75,14 +113,13 @@ async function http_runs_create(pg_pool, req, res, regex) { app.space_name, app.app_name, action.formation.type, - image_url, + image, action.formation.command, labels, env, action.formation.size, runid, ); - // Insert an action run into the DB const action_run_id = uuid.v4(); const created_by = req.headers['x-username'] || 'unknown'; @@ -99,11 +136,39 @@ async function http_runs_create(pg_pool, req, res, regex) { return http_helper.created_response(res, action_runs); } +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]; + + return http_helper.ok_response(res, action_run); +} + module.exports = { http: { create: http_create, + get: http_get, + list: http_list, + delete: http_delete, runs: { create: http_runs_create, + list: http_runs_list, + get: http_runs_get, }, }, }; diff --git a/lib/formations.js b/lib/formations.js index 5476045e..823f8618 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, }; } @@ -570,4 +572,5 @@ module.exports = { delete: http_delete, }, delete_dynos, + delete_dyno, }; diff --git a/sql/delete_action.sql b/sql/delete_action.sql new file mode 100755 index 00000000..3e6e1242 --- /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/select_action.sql b/sql/select_action.sql index a29c7b1a..09f3b822 100644 --- a/sql/select_action.sql +++ b/sql/select_action.sql @@ -9,7 +9,7 @@ select actions.deleted, ( select - json_build_object('id', formations.formation, 'type', formations.type, 'size', formations.size, 'command', formations.command) + json_build_object('id', formations.formation, 'type', formations.type, 'size', formations.size, 'command', formations.command, 'options', formations.options) from formations where diff --git a/sql/select_action_run.sql b/sql/select_action_run.sql new file mode 100644 index 00000000..860f6046 --- /dev/null +++ b/sql/select_action_run.sql @@ -0,0 +1,18 @@ +select + action_runs.action_run, + action_runs.action, + action_runs.runid, + action_runs.status, + action_runs.exit_code, + 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) +; \ No newline at end of file diff --git a/sql/select_all_action_runs.sql b/sql/select_all_action_runs.sql new file mode 100644 index 00000000..053da440 --- /dev/null +++ b/sql/select_all_action_runs.sql @@ -0,0 +1,17 @@ +select + action_runs.action_run, + action_runs.action, + action_runs.runid, + action_runs.status, + action_runs.exit_code, + 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) +; \ 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 00000000..0965c0ae --- /dev/null +++ b/sql/select_all_actions.sql @@ -0,0 +1,32 @@ +select + actions.action, + actions.app, + actions.name, + actions.description, + 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) + 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) +; diff --git a/sql/select_formation.sql b/sql/select_formation.sql index 4564eaca..77a77c42 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 From 54f3e4c091df69d9da6db61e59aaaeee6f509b1c Mon Sep 17 00:00:00 2001 From: Sam Beckett Date: Thu, 14 Oct 2021 10:36:32 -0600 Subject: [PATCH 07/21] fix actions typos, add env to oneoff dyno --- lib/actions.js | 9 ++++++--- sql/select_formation.sql | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/lib/actions.js b/lib/actions.js index ee0ad451..1babb561 100644 --- a/lib/actions.js +++ b/lib/actions.js @@ -59,9 +59,9 @@ async function http_delete(pg_pool, req, res, regex) { 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); + await delete_action(pg_pool, [action.action]); - return http_helper.ok_response(res); + return http_helper.ok_response(res, action); } async function http_get(pg_pool, req, res, regex) { @@ -107,7 +107,10 @@ async function http_runs_create(pg_pool, req, res, regex) { // Deploy a one-off dyno const runid = uuid.v4(); const labels = { 'logtrain.akkeris.io/drains': `persistent://${runid}` }; - const env = null; + 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 common.alamo.oneoff_deploy( pg_pool, app.space_name, diff --git a/sql/select_formation.sql b/sql/select_formation.sql index 77a77c42..a63aa66d 100755 --- a/sql/select_formation.sql +++ b/sql/select_formation.sql @@ -10,7 +10,7 @@ select formations.port, formations.updated, formations.healthcheck, - formations.oneoff + formations.oneoff, formations.options from formations From 67b4c6dc582bd3bcbf51fb6655e6f3745d31e4fe Mon Sep 17 00:00:00 2001 From: Sam Beckett Date: Thu, 14 Oct 2021 10:49:25 -0600 Subject: [PATCH 08/21] update documentation for actions --- docs/Actions.md | 187 ++++++++++++++++++++++++++++++++++++- sql/select_all_actions.sql | 2 +- 2 files changed, 185 insertions(+), 4 deletions(-) diff --git a/docs/Actions.md b/docs/Actions.md index e7650391..a6ba2a2d 100644 --- a/docs/Actions.md +++ b/docs/Actions.md @@ -2,7 +2,7 @@ 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. -### Actions Create +### Create an Action `POST /apps/{appname}/actions` @@ -12,6 +12,11 @@ Create a new action on an app. |---|---|---|---| | 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** @@ -20,7 +25,7 @@ curl \ -H 'Authorization: ...' \ -X POST \ https://apps.akkeris.io/apps/app-space/actions - -d '{ "name": "testsuite", "descripiton": "End-to-end automated tests" }' + -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** @@ -39,8 +44,89 @@ curl \ } ``` +### List Actions -### Action Runs Create +`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` @@ -68,3 +154,98 @@ curl \ "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/sql/select_all_actions.sql b/sql/select_all_actions.sql index 0965c0ae..eda62904 100644 --- a/sql/select_all_actions.sql +++ b/sql/select_all_actions.sql @@ -9,7 +9,7 @@ select actions.deleted, ( select - json_build_object('id', formations.formation, 'type', formations.type, 'size', formations.size, 'command', formations.command) + json_build_object('id', formations.formation, 'type', formations.type, 'size', formations.size, 'command', formations.command, 'options', formations.options) from formations where From 77d511af2866a5cf58d3d2369a60db9defea847c Mon Sep 17 00:00:00 2001 From: Sam Beckett Date: Mon, 31 Jan 2022 11:54:16 -0700 Subject: [PATCH 09/21] ability to update actions, remove superfluous id --- index.js | 4 ++ lib/actions.js | 68 +++++++++++++++++++++-- lib/formations.js | 98 +++++++++++++++++++++++++++------ sql/create.sql | 1 - sql/insert_action_run.sql | 4 +- sql/select_action_run.sql | 1 - sql/select_all_action_runs.sql | 1 - sql/update_action.sql | 6 ++ sql/update_oneoff_formation.sql | 8 +++ test/actions.js | 1 - 10 files changed, 165 insertions(+), 27 deletions(-) create mode 100644 sql/update_action.sql create mode 100755 sql/update_oneoff_formation.sql diff --git a/index.js b/index.js index 2ecf74fd..a7e28efa 100755 --- a/index.js +++ b/index.js @@ -864,6 +864,10 @@ routes.add.get('/apps/([A-z0-9\\-\\_\\.]+)/actions/([A-z0-9\\-\\_\\.]+)/runs/([A 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)) diff --git a/lib/actions.js b/lib/actions.js index 1babb561..d6f15a1f 100644 --- a/lib/actions.js +++ b/lib/actions.js @@ -1,4 +1,5 @@ const fs = require('fs'); +const { isBoolean, isNumber } = require('util'); const uuid = require('uuid'); const common = require('./common'); @@ -80,6 +81,57 @@ async function http_list(pg_pool, req, res, regex) { 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) + ['name', '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'].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.`); + } + }); + + try { + await update_action(pg_pool, [action.action, payload.name, payload.description]); + } 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); +} + 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); async function http_runs_create(pg_pool, req, res, regex) { @@ -106,7 +158,12 @@ async function http_runs_create(pg_pool, req, res, regex) { // Deploy a one-off dyno const runid = uuid.v4(); - const labels = { 'logtrain.akkeris.io/drains': `persistent://${runid}` }; + 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; @@ -124,12 +181,10 @@ async function http_runs_create(pg_pool, req, res, regex) { runid, ); // Insert an action run into the DB - const action_run_id = uuid.v4(); const created_by = req.headers['x-username'] || 'unknown'; const action_run_params = [ - action_run_id, - action.action, runid, + action.action, 'running', null, created_by, @@ -159,6 +214,10 @@ async function http_runs_get(pg_pool, req, res, regex) { 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.`); + } + return http_helper.ok_response(res, action_run); } @@ -168,6 +227,7 @@ module.exports = { get: http_get, list: http_list, delete: http_delete, + update: http_update, runs: { create: http_runs_create, list: http_runs_list, diff --git a/lib/formations.js b/lib/formations.js index 823f8618..62b2a8e9 100755 --- a/lib/formations.js +++ b/lib/formations.js @@ -199,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), @@ -231,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); } } } @@ -319,6 +327,61 @@ 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, name, 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 (name && name !== '') { + payload.name = name; + } + + 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.name, 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); @@ -573,4 +636,5 @@ module.exports = { }, delete_dynos, delete_dyno, + oneoff_update, }; diff --git a/sql/create.sql b/sql/create.sql index 90aa9570..16ee5e7d 100755 --- a/sql/create.sql +++ b/sql/create.sql @@ -676,7 +676,6 @@ begin ( action_run uuid not null primary key, action uuid not null references actions("action"), - runid uuid not null, status varchar(128) not null default 'unknown', exit_code integer null, created_by varchar(1024) not null default 'unknown', diff --git a/sql/insert_action_run.sql b/sql/insert_action_run.sql index 09c69130..54797033 100644 --- a/sql/insert_action_run.sql +++ b/sql/insert_action_run.sql @@ -1,5 +1,5 @@ insert into action_runs - (action_run, action, runid, status, exit_code, created_by) + (action_run, action, status, exit_code, created_by) values - ($1, $2, $3, $4, $5, $6) + ($1, $2, $3, $4, $5) returning * diff --git a/sql/select_action_run.sql b/sql/select_action_run.sql index 860f6046..d65d5a89 100644 --- a/sql/select_action_run.sql +++ b/sql/select_action_run.sql @@ -1,7 +1,6 @@ select action_runs.action_run, action_runs.action, - action_runs.runid, action_runs.status, action_runs.exit_code, action_runs.created_by, diff --git a/sql/select_all_action_runs.sql b/sql/select_all_action_runs.sql index 053da440..22635884 100644 --- a/sql/select_all_action_runs.sql +++ b/sql/select_all_action_runs.sql @@ -1,7 +1,6 @@ select action_runs.action_run, action_runs.action, - action_runs.runid, action_runs.status, action_runs.exit_code, action_runs.created_by, diff --git a/sql/update_action.sql b/sql/update_action.sql new file mode 100644 index 00000000..17af307b --- /dev/null +++ b/sql/update_action.sql @@ -0,0 +1,6 @@ +update actions set + name = coalesce($2, name), + description = coalesce($3, description) +where + action = $1 and + deleted = false \ 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 00000000..972f9179 --- /dev/null +++ b/sql/update_oneoff_formation.sql @@ -0,0 +1,8 @@ +update formations set + type = coalesce($2, type), + size = coalesce($3, size), + command = coalesce($4, command), + options = coalesce($5, options) +where + (formation::varchar(1024) = $1) +returning * \ No newline at end of file diff --git a/test/actions.js b/test/actions.js index b282d966..73d40ee9 100644 --- a/test/actions.js +++ b/test/actions.js @@ -71,7 +71,6 @@ describe('Actions', function () { const action_run = JSON.parse(action_runs)[0]; expect(action_run.action_run).to.match(uuid_regex); expect(action_run.action).to.equal(test_action.action); - expect(action_run.runid).to.match(uuid_regex); expect(action_run.status).to.equal('running'); expect(action_run.exit_code).to.equal(null); expect(action_run.created_by).to.equal('Calaway'); From efd1b21308b62f807545932bb64d646fcd32944e Mon Sep 17 00:00:00 2001 From: Sam Beckett Date: Thu, 3 Feb 2022 16:08:45 -0700 Subject: [PATCH 10/21] add action hooks, updating status --- lib/actions.js | 2 +- lib/common.js | 6 ++++++ lib/events.js | 31 +++++++++++++++++++++++++++++++ lib/hooks.js | 8 ++++++++ sql/create.sql | 4 +++- sql/select_action_run.sql | 3 ++- sql/select_all_action_runs.sql | 2 ++ sql/update_action_run_status.sql | 8 ++++++++ 8 files changed, 61 insertions(+), 3 deletions(-) create mode 100644 sql/update_action_run_status.sql diff --git a/lib/actions.js b/lib/actions.js index d6f15a1f..7832bd1a 100644 --- a/lib/actions.js +++ b/lib/actions.js @@ -185,7 +185,7 @@ async function http_runs_create(pg_pool, req, res, regex) { const action_run_params = [ runid, action.action, - 'running', + 'starting', null, created_by, ]; diff --git a/lib/common.js b/lib/common.js index 02d94638..21eaad23 100755 --- a/lib/common.js +++ b/lib/common.js @@ -300,6 +300,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]); @@ -768,6 +773,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, diff --git a/lib/events.js b/lib/events.js index 6d8f0272..bde331b5 100644 --- a/lib/events.js +++ b/lib/events.js @@ -90,7 +90,38 @@ 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 + const action = await common.action_exists(pg_pool, app.app_uuid, payload.action_details.id); + + // 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 + const action = await common.action_exists(pg_pool, app.app_uuid, payload.action_details.id); + + // 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/hooks.js b/lib/hooks.js index 02edc8c7..836c7000 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.', diff --git a/sql/create.sql b/sql/create.sql index 16ee5e7d..77b303f0 100755 --- a/sql/create.sql +++ b/sql/create.sql @@ -676,8 +676,10 @@ begin ( action_run uuid not null primary key, action uuid not null references actions("action"), - status varchar(128) not null default 'unknown', + status varchar(128) not null default 'starting', exit_code integer null, + started_at timestamptz, + finished_at timestamptz, created_by varchar(1024) not null default 'unknown', created timestamptz not null default now() ); diff --git a/sql/select_action_run.sql b/sql/select_action_run.sql index d65d5a89..0bbf09b2 100644 --- a/sql/select_action_run.sql +++ b/sql/select_action_run.sql @@ -3,6 +3,8 @@ select action_runs.action, action_runs.status, action_runs.exit_code, + action_runs.started_at, + action_runs.finished_at, action_runs.created_by, action_runs.created from @@ -14,4 +16,3 @@ where actions.deleted = false and 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/select_all_action_runs.sql b/sql/select_all_action_runs.sql index 22635884..263cb97e 100644 --- a/sql/select_all_action_runs.sql +++ b/sql/select_all_action_runs.sql @@ -3,6 +3,8 @@ select action_runs.action, action_runs.status, action_runs.exit_code, + action_runs.started_at, + action_runs.finished_at, action_runs.created_by, action_runs.created from diff --git a/sql/update_action_run_status.sql b/sql/update_action_run_status.sql new file mode 100644 index 00000000..7ad721d6 --- /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 From a724efca0a75766df000482c8c5698431fd98d3f Mon Sep 17 00:00:00 2001 From: Sam Beckett Date: Thu, 3 Feb 2022 18:06:43 -0700 Subject: [PATCH 11/21] add ability to trigger actions based on events --- lib/actions.js | 85 +++++++++++++------------------------- lib/common.js | 74 +++++++++++++++++++++++++++++++++ lib/hooks.js | 1 + sql/create.sql | 4 +- sql/insert_action.sql | 4 +- sql/select_action.sql | 1 + sql/select_all_actions.sql | 1 + sql/update_action.sql | 3 +- 8 files changed, 113 insertions(+), 60 deletions(-) diff --git a/lib/actions.js b/lib/actions.js index 7832bd1a..da27295a 100644 --- a/lib/actions.js +++ b/lib/actions.js @@ -6,6 +6,26 @@ const common = require('./common'); const formations = require('./formations'); const http_helper = require('./http_helper'); const query = require('./query'); +const hooks = require('./hooks'); + +// Verify events are valid. Events should be a comma separated string of events +function check_events(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); @@ -35,6 +55,8 @@ async function http_create(pg_pool, req, res, regex) { payload.options, ); + check_events(payload.events); + // Insert an action into the DB const action_id = uuid.v4(); const created_by = req.headers['x-username'] || 'unknown'; @@ -44,6 +66,7 @@ async function http_create(pg_pool, req, res, regex) { formation.id, payload.name, payload.description || '', + payload.events || '', created_by, ]; const actions = await insert_action(pg_pool, action_params); @@ -99,14 +122,16 @@ async function http_update(pg_pool, req, res, regex) { }); // Optional fields (set to empty string to clear) - ['description'].forEach((key) => { + ['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.name, payload.description]); + await update_action(pg_pool, [action.action, payload.name, payload.description, payload.events]); } catch (err) { throw new common.BadRequestError('Unable to update action. Please check the payload and try again.'); } @@ -132,66 +157,14 @@ async function http_update(pg_pool, req, res, regex) { return http_helper.ok_response(res, updated_action); } -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); async function http_runs_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 action_key = http_helper.second_match(req.url, regex); - const action = await common.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]; - image = common.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, - ); - } - // 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 common.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 created_by = req.headers['x-username'] || 'unknown'; - const action_run_params = [ - runid, - action.action, - 'starting', - null, - created_by, - ]; - const action_runs = await insert_action_run(pg_pool, action_run_params); + const action_run = await common.trigger_action(pg_pool, app_key, action_key, created_by); - return http_helper.created_response(res, action_runs); + 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); diff --git a/lib/common.js b/lib/common.js index 21eaad23..e6550ce9 100755 --- a/lib/common.js +++ b/lib/common.js @@ -661,11 +661,84 @@ 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); +async function trigger_action(pg_pool, app_key, action_key, triggered_by) { + 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]; + 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, + ); + } + + // 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', + null, + triggered_by, + ]; + return insert_action_run(pg_pool, action_run_params); +} + +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) + .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)); + 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])) @@ -791,4 +864,5 @@ module.exports = { lifecycle: (new ApplicationLifecycle()), init, query_audits, + trigger_action, }; diff --git a/lib/hooks.js b/lib/hooks.js index 836c7000..c6501ed6 100755 --- a/lib/hooks.js +++ b/lib/hooks.js @@ -258,4 +258,5 @@ module.exports = { results: hooks_results, result: hooks_result, descriptions: hooks_descriptions, + available_hooks: availableHooks, }; diff --git a/sql/create.sql b/sql/create.sql index 77b303f0..dfe5af2d 100755 --- a/sql/create.sql +++ b/sql/create.sql @@ -666,10 +666,12 @@ begin 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 + deleted boolean not null default false, + UNIQUE (action, app, name) ); create table if not exists action_runs diff --git a/sql/insert_action.sql b/sql/insert_action.sql index c6adc76e..ea5635ae 100644 --- a/sql/insert_action.sql +++ b/sql/insert_action.sql @@ -1,5 +1,5 @@ insert into actions - (action, app, formation, name, description, created_by) + (action, app, formation, name, description, events, created_by) values - ($1, $2, $3, $4, $5, $6) + ($1, $2, $3, $4, $5, $6, $7) returning * diff --git a/sql/select_action.sql b/sql/select_action.sql index 09f3b822..134b84a2 100644 --- a/sql/select_action.sql +++ b/sql/select_action.sql @@ -3,6 +3,7 @@ select actions.app, actions.name, actions.description, + actions.events, actions.created_by, actions.created, actions.updated, diff --git a/sql/select_all_actions.sql b/sql/select_all_actions.sql index eda62904..e5264172 100644 --- a/sql/select_all_actions.sql +++ b/sql/select_all_actions.sql @@ -3,6 +3,7 @@ select actions.app, actions.name, actions.description, + actions.events, actions.created_by, actions.created, actions.updated, diff --git a/sql/update_action.sql b/sql/update_action.sql index 17af307b..830a3800 100644 --- a/sql/update_action.sql +++ b/sql/update_action.sql @@ -1,6 +1,7 @@ update actions set name = coalesce($2, name), - description = coalesce($3, description) + description = coalesce($3, description), + events = coalesce($4, events) where action = $1 and deleted = false \ No newline at end of file From fafeacd1d998ef7c850d18d4c930717987261ad6 Mon Sep 17 00:00:00 2001 From: Sam Beckett Date: Thu, 3 Feb 2022 18:28:36 -0700 Subject: [PATCH 12/21] add source for action runs (trigger event) --- lib/actions.js | 2 +- lib/common.js | 15 +++++++++++++-- lib/events.js | 15 +++++++++++++++ sql/create.sql | 1 + sql/insert_action_run.sql | 4 ++-- sql/select_action_run.sql | 1 + sql/select_all_action_runs.sql | 1 + 7 files changed, 34 insertions(+), 5 deletions(-) diff --git a/lib/actions.js b/lib/actions.js index da27295a..7d47d4fe 100644 --- a/lib/actions.js +++ b/lib/actions.js @@ -162,7 +162,7 @@ async function http_runs_create(pg_pool, req, res, 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); + 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); } diff --git a/lib/common.js b/lib/common.js index e6550ce9..3ca8d9c8 100755 --- a/lib/common.js +++ b/lib/common.js @@ -338,6 +338,15 @@ async function check_action_exists(pg_pool, app_uuid, action_key) { 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]); @@ -663,7 +672,7 @@ 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); -async function trigger_action(pg_pool, app_key, action_key, triggered_by) { +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); @@ -711,6 +720,7 @@ async function trigger_action(pg_pool, app_key, action_key, triggered_by) { runid, action.action, 'starting', + source, null, triggered_by, ]; @@ -728,7 +738,7 @@ async function notify_actions(pg_pool, app_uuid, type, username) { // Trigger each action and report errors to the console actions.forEach((action) => { - trigger_action(pg_pool, app_uuid, action.action, username) + trigger_action(pg_pool, app_uuid, action.action, username, type) .catch((e) => console.error(`Could not trigger action ${action.action} - `, e)); }); } @@ -824,6 +834,7 @@ module.exports = { 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, diff --git a/lib/events.js b/lib/events.js index bde331b5..f139d7c1 100644 --- a/lib/events.js +++ b/lib/events.js @@ -92,7 +92,14 @@ async function create(pg_pool, req, res /* regex */) { }; } 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( @@ -105,7 +112,15 @@ async function create(pg_pool, req, res /* regex */) { ); } 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( diff --git a/sql/create.sql b/sql/create.sql index dfe5af2d..6aa032de 100755 --- a/sql/create.sql +++ b/sql/create.sql @@ -680,6 +680,7 @@ begin action uuid not null references actions("action"), 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', diff --git a/sql/insert_action_run.sql b/sql/insert_action_run.sql index 54797033..cf533910 100644 --- a/sql/insert_action_run.sql +++ b/sql/insert_action_run.sql @@ -1,5 +1,5 @@ insert into action_runs - (action_run, action, status, exit_code, created_by) + (action_run, action, status, source, exit_code, created_by) values - ($1, $2, $3, $4, $5) + ($1, $2, $3, $4, $5, $6) returning * diff --git a/sql/select_action_run.sql b/sql/select_action_run.sql index 0bbf09b2..a3425005 100644 --- a/sql/select_action_run.sql +++ b/sql/select_action_run.sql @@ -3,6 +3,7 @@ select action_runs.action, action_runs.status, action_runs.exit_code, + action_runs.source, action_runs.started_at, action_runs.finished_at, action_runs.created_by, diff --git a/sql/select_all_action_runs.sql b/sql/select_all_action_runs.sql index 263cb97e..ccb87a3d 100644 --- a/sql/select_all_action_runs.sql +++ b/sql/select_all_action_runs.sql @@ -3,6 +3,7 @@ select action_runs.action, action_runs.status, action_runs.exit_code, + action_runs.source, action_runs.started_at, action_runs.finished_at, action_runs.created_by, From d4f2769122be1dc8b498d5b56689eb8f3b45e24e Mon Sep 17 00:00:00 2001 From: Sam Beckett Date: Fri, 4 Feb 2022 10:32:19 -0700 Subject: [PATCH 13/21] update create action test, add test stubs --- lib/actions.js | 4 +- lib/alamo.js | 1 + lib/common.js | 2 +- test/actions.js | 106 +++++++++++++++++++++++++++++++++++++++++------- 4 files changed, 96 insertions(+), 17 deletions(-) diff --git a/lib/actions.js b/lib/actions.js index 7d47d4fe..ed2a50b4 100644 --- a/lib/actions.js +++ b/lib/actions.js @@ -69,9 +69,9 @@ async function http_create(pg_pool, req, res, regex) { payload.events || '', created_by, ]; - const actions = await insert_action(pg_pool, action_params); + const action = (await insert_action(pg_pool, action_params))[0]; - return http_helper.created_response(res, actions); + return http_helper.created_response(res, action); } const delete_action = query.bind(query, fs.readFileSync('sql/delete_action.sql').toString('utf8'), (result) => result); diff --git a/lib/alamo.js b/lib/alamo.js index 092bb08c..af1a0b90 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 || {}), }; diff --git a/lib/common.js b/lib/common.js index 3ca8d9c8..76303c16 100755 --- a/lib/common.js +++ b/lib/common.js @@ -724,7 +724,7 @@ async function trigger_action(pg_pool, app_key, action_key, triggered_by, source null, triggered_by, ]; - return insert_action_run(pg_pool, action_run_params); + 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); diff --git a/test/actions.js b/test/actions.js index 73d40ee9..14bd2f96 100644 --- a/test/actions.js +++ b/test/actions.js @@ -1,7 +1,7 @@ const { expect } = require('chai'); const http_helper = require('../lib/http_helper.js'); -describe('Actions', function () { +describe('actions:', function () { this.timeout(64000); process.env.AUTH_KEY = 'hello'; const init = require('./support/init.js'); @@ -15,25 +15,33 @@ describe('Actions', function () { }; // Nested describe block needed for correct execution order of before/after hooks - describe('', async () => { + describe('', function () { let testapp; + let test_action; + let created_action = false; before(async () => { - testapp = await init.create_test_app_with_content('OK', 'default'); + testapp = await init.create_test_app(); }); after(async () => { await init.remove_app(testapp); }); - it('create a new action and then manually trigger a new run', async () => { + 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 actions = await http_helper.request('post', `http://localhost:5000/apps/${testapp.name}/actions`, akkeris_headers, payload); + const action = await http_helper.request('post', `http://localhost:5000/apps/${testapp.name}/actions`, akkeris_headers, payload); - const test_action = JSON.parse(actions)[0]; + // 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); @@ -45,6 +53,7 @@ describe('Actions', function () { 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); @@ -52,29 +61,98 @@ describe('Actions', function () { 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(null); + 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_runs = await http_helper.request('post', `http://localhost:5000/apps/${testapp.name}/actions/${test_action.name}/runs`, akkeris_headers); - - await init.wait(4000); - const pod_status_after = await http_helper.request('get', pod_status_url, null); - expect(JSON.parse(pod_status_after)[0].ready).to.equal(true); + 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); - const action_run = JSON.parse(action_runs)[0]; + // 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('running'); + 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); + // Test updating fields + // Test removing fields + }); + + 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('covers actions that fail during execution', async () => { + // stub + expect(created_action).to.equal(true); + // Create and trigger an action run that is expected to fail + // Make sure that the result is "failure" and the exit code is expected }); }); }); From 14ab261d6ec50748b7284b8d22098a4e95a4d9b5 Mon Sep 17 00:00:00 2001 From: Sam Beckett Date: Fri, 4 Feb 2022 16:07:54 -0700 Subject: [PATCH 14/21] delete action when app is deleted --- lib/actions.js | 20 ++++++++++++++++++++ lib/apps.js | 4 ++++ lib/common.js | 4 +++- sql/select_all_action_runs.sql | 2 +- 4 files changed, 28 insertions(+), 2 deletions(-) diff --git a/lib/actions.js b/lib/actions.js index ed2a50b4..3d80bcf8 100644 --- a/lib/actions.js +++ b/lib/actions.js @@ -8,6 +8,13 @@ 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) { // Invalid events - action_*, destroy @@ -194,6 +201,18 @@ async function http_runs_get(pg_pool, req, res, regex) { 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, @@ -207,4 +226,5 @@ module.exports = { get: http_runs_get, }, }, + delete_actions, }; diff --git a/lib/apps.js b/lib/apps.js index 0e39be64..c0778eaf 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 4980a07e..4e02fa61 100755 --- a/lib/common.js +++ b/lib/common.js @@ -749,7 +749,9 @@ 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)); - notify_actions(pg_pool, app_uuid, type, 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])) diff --git a/sql/select_all_action_runs.sql b/sql/select_all_action_runs.sql index ccb87a3d..a521a73e 100644 --- a/sql/select_all_action_runs.sql +++ b/sql/select_all_action_runs.sql @@ -16,4 +16,4 @@ from where actions.deleted = false and action_runs.action::varchar(1024) = $1::varchar(1024) -; \ No newline at end of file +order by created desc \ No newline at end of file From 30f5692201cdac108cc7a320a46f81204cada27f Mon Sep 17 00:00:00 2001 From: Sam Beckett Date: Mon, 7 Feb 2022 12:04:47 -0700 Subject: [PATCH 15/21] add auto increment "run_number" column --- sql/create.sql | 4 +++- sql/insert_action_run.sql | 4 ++-- sql/select_action_run.sql | 1 + sql/select_all_action_runs.sql | 1 + 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/sql/create.sql b/sql/create.sql index 6aa032de..6bd3b535 100755 --- a/sql/create.sql +++ b/sql/create.sql @@ -678,13 +678,15 @@ begin ( 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() + created timestamptz not null default now(), + UNIQUE (action, run_number) ); alter table recommendations add column if not exists deleted bool not null default false; diff --git a/sql/insert_action_run.sql b/sql/insert_action_run.sql index cf533910..8afdb0e9 100644 --- a/sql/insert_action_run.sql +++ b/sql/insert_action_run.sql @@ -1,5 +1,5 @@ insert into action_runs - (action_run, action, status, source, exit_code, created_by) + (action_run, action, status, source, exit_code, created_by, run_number) values - ($1, $2, $3, $4, $5, $6) + ($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_run.sql b/sql/select_action_run.sql index a3425005..11f710c9 100644 --- a/sql/select_action_run.sql +++ b/sql/select_action_run.sql @@ -1,6 +1,7 @@ select action_runs.action_run, action_runs.action, + action_runs.run_number, action_runs.status, action_runs.exit_code, action_runs.source, diff --git a/sql/select_all_action_runs.sql b/sql/select_all_action_runs.sql index a521a73e..e15eaa11 100644 --- a/sql/select_all_action_runs.sql +++ b/sql/select_all_action_runs.sql @@ -1,6 +1,7 @@ select action_runs.action_run, action_runs.action, + action_runs.run_number, action_runs.status, action_runs.exit_code, action_runs.source, From 210731f521ff5ca91d0c41068c0ab8fca67071b5 Mon Sep 17 00:00:00 2001 From: Sam Beckett Date: Thu, 10 Feb 2022 14:17:55 -0700 Subject: [PATCH 16/21] remove ability to change action name (for now) --- lib/actions.js | 16 +++++++++++++--- lib/formations.js | 8 ++------ sql/create.sql | 3 +-- sql/select_all_actions.sql | 2 +- sql/update_action.sql | 5 ++--- sql/update_oneoff_formation.sql | 7 +++---- 6 files changed, 22 insertions(+), 19 deletions(-) diff --git a/lib/actions.js b/lib/actions.js index 3d80bcf8..8f2ff13b 100644 --- a/lib/actions.js +++ b/lib/actions.js @@ -40,6 +40,16 @@ 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}`; // Create a one-off formation @@ -122,7 +132,7 @@ async function http_update(pg_pool, req, res, regex) { const key_exists = (key) => Object.keys(payload).findIndex((k) => k === key) !== -1; // Required fields (can't set to empty string) - ['name', 'size', 'command'].forEach((key) => { + ['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.`); } @@ -138,7 +148,7 @@ async function http_update(pg_pool, req, res, regex) { check_events(payload.events); try { - await update_action(pg_pool, [action.action, payload.name, payload.description, payload.events]); + 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.'); } @@ -150,7 +160,7 @@ async function http_update(pg_pool, req, res, regex) { app.app_uuid, app.space_name, action.formation.id, - payload.name ? `actions${payload.name}` : undefined, + // payload.name ? `actions${payload.name}` : undefined, payload.size, payload.command, payload.options, diff --git a/lib/formations.js b/lib/formations.js index 62b2a8e9..ec101064 100755 --- a/lib/formations.js +++ b/lib/formations.js @@ -328,7 +328,7 @@ 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, name, size, command, options) { +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'); @@ -336,10 +336,6 @@ async function oneoff_update(pg_pool, app_uuid, space_name, formation_uuid, name // If given update value exists, check and add to payload. - if (name && name !== '') { - payload.name = name; - } - 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.'); @@ -379,7 +375,7 @@ async function oneoff_update(pg_pool, app_uuid, space_name, formation_uuid, name } // Null/undefined values won't be updated (handled in query) - await update_oneoff_formation(pg_pool, [formation_uuid, payload.name, payload.size, payload.command, payload.options]); + await update_oneoff_formation(pg_pool, [formation_uuid, payload.size, payload.command, payload.options]); } // public diff --git a/sql/create.sql b/sql/create.sql index 6bd3b535..e9e2b16e 100755 --- a/sql/create.sql +++ b/sql/create.sql @@ -670,8 +670,7 @@ begin 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, - UNIQUE (action, app, name) + deleted boolean not null default false ); create table if not exists action_runs diff --git a/sql/select_all_actions.sql b/sql/select_all_actions.sql index e5264172..f247e905 100644 --- a/sql/select_all_actions.sql +++ b/sql/select_all_actions.sql @@ -30,4 +30,4 @@ 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/update_action.sql b/sql/update_action.sql index 830a3800..46d3508e 100644 --- a/sql/update_action.sql +++ b/sql/update_action.sql @@ -1,7 +1,6 @@ update actions set - name = coalesce($2, name), - description = coalesce($3, description), - events = coalesce($4, events) + 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_oneoff_formation.sql b/sql/update_oneoff_formation.sql index 972f9179..94df6a5d 100755 --- a/sql/update_oneoff_formation.sql +++ b/sql/update_oneoff_formation.sql @@ -1,8 +1,7 @@ update formations set - type = coalesce($2, type), - size = coalesce($3, size), - command = coalesce($4, command), - options = coalesce($5, options) + size = coalesce($2, size), + command = coalesce($3, command), + options = coalesce($4, options) where (formation::varchar(1024) = $1) returning * \ No newline at end of file From a5face073d9578032a07ebc008d3eeea604dd8b7 Mon Sep 17 00:00:00 2001 From: Sam Beckett Date: Thu, 10 Feb 2022 14:18:02 -0700 Subject: [PATCH 17/21] add more tests --- test/actions.js | 67 +++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 62 insertions(+), 5 deletions(-) diff --git a/test/actions.js b/test/actions.js index 14bd2f96..5054213a 100644 --- a/test/actions.js +++ b/test/actions.js @@ -1,6 +1,30 @@ -const { expect } = require('chai'); +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'; @@ -136,8 +160,20 @@ describe('actions:', function () { 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', () => { @@ -148,11 +184,32 @@ describe('actions:', function () { // Make sure that the action fired and completed }); - it('covers actions that fail during execution', async () => { - // stub - expect(created_action).to.equal(true); + 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 From 3d11a390667743475a92c4ba176a64592367c17e Mon Sep 17 00:00:00 2001 From: Sam Beckett Date: Wed, 23 Feb 2022 10:55:24 -0700 Subject: [PATCH 18/21] improve input error handling --- lib/actions.js | 7 +++++-- lib/common.js | 3 +++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/actions.js b/lib/actions.js index 8f2ff13b..d3979fdc 100644 --- a/lib/actions.js +++ b/lib/actions.js @@ -17,6 +17,9 @@ const asyncForEach = async (array, callback) => { // 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; @@ -52,6 +55,8 @@ async function http_create(pg_pool, req, res, regex) { const formation_type = `actions${payload.name}`; + check_events(payload.events); + // Create a one-off formation const formation = await formations.create( pg_pool, @@ -72,8 +77,6 @@ async function http_create(pg_pool, req, res, regex) { payload.options, ); - check_events(payload.events); - // Insert an action into the DB const action_id = uuid.v4(); const created_by = req.headers['x-username'] || 'unknown'; diff --git a/lib/common.js b/lib/common.js index 4e02fa61..917567cb 100755 --- a/lib/common.js +++ b/lib/common.js @@ -683,6 +683,9 @@ async function trigger_action(pg_pool, app_key, action_key, triggered_by, source 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, From 3bb8ebed7b484a1f20a96151a0a9865b54475852 Mon Sep 17 00:00:00 2001 From: Sam Beckett Date: Mon, 12 Dec 2022 23:28:00 -0700 Subject: [PATCH 19/21] fetch run logs from logtrain --- lib/actions.js | 9 +++++++++ lib/alamo.js | 5 +++++ lib/common.js | 46 +++++++++++++++++++++++++--------------------- 3 files changed, 39 insertions(+), 21 deletions(-) diff --git a/lib/actions.js b/lib/actions.js index d3979fdc..55f2b9b3 100644 --- a/lib/actions.js +++ b/lib/actions.js @@ -211,6 +211,15 @@ async function http_runs_get(pg_pool, req, res, regex) { 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); } diff --git a/lib/alamo.js b/lib/alamo.js index af1a0b90..147e6e2f 100755 --- a/lib/alamo.js +++ b/lib/alamo.js @@ -1338,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, @@ -1466,4 +1470,5 @@ module.exports = { osb_action, update_osb_service, get_kafka_hosts, + get_logtrain_logs, }; diff --git a/lib/common.js b/lib/common.js index 917567cb..e3bd6eab 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) { @@ -338,7 +334,7 @@ async function check_action_exists(pg_pool, app_uuid, action_key) { return result[0]; } -const select_action_run = query.bind(query, fs.readFileSync('./sql/select_action_run.sql').toString('utf8'), null); +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) { @@ -383,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]); @@ -446,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 { @@ -459,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: { @@ -471,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']) { + 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}`, }, }); } @@ -529,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'), @@ -589,7 +584,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) { @@ -673,6 +667,7 @@ 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); @@ -695,6 +690,15 @@ async function trigger_action(pg_pool, app_key, action_key, triggered_by, source ); } + // 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 = { From 3b1d6652f816b6fcff0866a11d1a391ef92d929e Mon Sep 17 00:00:00 2001 From: Sam Beckett Date: Wed, 14 Dec 2022 20:52:04 -0700 Subject: [PATCH 20/21] bump version --- package-lock.json | 16 ++++++++-------- package.json | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7eab6d4c..9b76e093 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 969e9872..5b343335 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": { From f58470c07cc5f1d196f6f1ccdc9c0a6127db5bf6 Mon Sep 17 00:00:00 2001 From: Sam Beckett Date: Wed, 14 Dec 2022 22:22:38 -0700 Subject: [PATCH 21/21] fix log id, eslint --- lib/actions.js | 3 +-- lib/alamo.js | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/actions.js b/lib/actions.js index 55f2b9b3..7a3a4b8b 100644 --- a/lib/actions.js +++ b/lib/actions.js @@ -29,7 +29,7 @@ function check_events(events) { // 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)) { + } 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`); } }); @@ -234,7 +234,6 @@ async function delete_actions(pg_pool, app_key) { }); } - module.exports = { http: { create: http_create, diff --git a/lib/alamo.js b/lib/alamo.js index 147e6e2f..05a922b3 100755 --- a/lib/alamo.js +++ b/lib/alamo.js @@ -1339,7 +1339,7 @@ async function es_status(pg_pool, space_name, app_name, service_id /* action_id } 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); + return alamo_fetch('get', `${await get_region_api_by_space(pg_pool, space_name)}/logs/${log_id}`, null); } module.exports = {