Skip to content

Commit 398f875

Browse files
committed
Add ability to delete dangling s2 services
When the stage one task is not running for what ever reason it is unable to delete the stage two services. This change adds this ability as well as send a message to Slack incomming Webhook of the result. Addresses: purpleteam-labs/purpleteam-iac#9. Changes were also made to IaC
1 parent 8c3df38 commit 398f875

File tree

6 files changed

+3424
-2
lines changed

6 files changed

+3424
-2
lines changed

.eslintignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
.git/
33
cloud/app-emissary-provisioner/node_modules/
44
cloud/s2-deprovisioner/node_modules/
5+
cloud/s2-ecs-service-deletion/node_modules/
56
local/app-emissary-provisioner/node_modules/
67
local/selenium-standalone-provisioner/node_modules/
78
local/s2-deprovisioner/node_modules/
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
// Copyright (C) 2017-2021 BinaryMist Limited. All rights reserved.
2+
3+
// This file is part of PurpleTeam.
4+
5+
// PurpleTeam is free software: you can redistribute it and/or modify
6+
// it under the terms of the GNU Affero General Public License as published by
7+
// the Free Software Foundation version 3.
8+
9+
// PurpleTeam is distributed in the hope that it will be useful,
10+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
// GNU Affero General Public License for more details.
13+
14+
// You should have received a copy of the GNU Affero General Public License
15+
// along with PurpleTeam. If not, see <https://www.gnu.org/licenses/>.
16+
17+
// Doc: https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/clients/client-ecs/index.html
18+
const { ECSClient, ListServicesCommand, DeleteServiceCommand } = require('@aws-sdk/client-ecs');
19+
const got = require('got');
20+
21+
const internals = {
22+
slackWebHook: got.extend({
23+
headers: {},
24+
resolveBodyOnly: true,
25+
prefixUrl: process.env.SLACK_WEBHOOK_URL
26+
})
27+
};
28+
29+
internals.printEnv = () => {
30+
console.log('Environment Variables of interest follow.\nS2_PROVISIONING_TIMEOUT should be 2 seconds less than Lambda "Timeout": ', {
31+
NODE_ENV: process.env.NODE_ENV,
32+
S2_PROVISIONING_TIMEOUT: process.env.S2_PROVISIONING_TIMEOUT,
33+
AWS_REGION: process.env.AWS_REGION,
34+
SLACK_WEBHOOK_URL: process.env.SLACK_WEBHOOK_URL
35+
});
36+
};
37+
38+
39+
internals.promiseAllTimeout = async (promises, timeout, resolvePartial = true) => new Promise(((resolve, reject) => { // Test this with false and internals.s2ProvisioningTimeout = 50
40+
const results = [];
41+
let finished = 0;
42+
const numPromises = promises.length;
43+
let onFinish = () => {
44+
if (finished < numPromises) {
45+
if (resolvePartial) {
46+
(resolve)(results);
47+
} else {
48+
// throw new Error('Not all promises completed within the specified time'); // This will not be caught because it's inside a setTimeout.
49+
reject(new Error('Not all promises completed within the specified time')); // This will be handled in deleteDanglingServices.
50+
}
51+
} else {
52+
(resolve)(results);
53+
}
54+
onFinish = null;
55+
};
56+
57+
const fulfilAPromise = (i) => {
58+
promises[i].then(
59+
(res) => {
60+
results[i] = res;
61+
finished += 1;
62+
if (finished === numPromises && onFinish) {
63+
onFinish();
64+
}
65+
},
66+
reject
67+
);
68+
};
69+
70+
for (let i = 0; i < numPromises; i += 1) {
71+
results[i] = undefined;
72+
fulfilAPromise(i);
73+
}
74+
75+
setTimeout(() => { if (onFinish) onFinish(); }, timeout);
76+
}));
77+
78+
79+
internals.getServicesOfCustCluster = async (ecsClient, customerClusterArn) => {
80+
const listServicesCommand = new ListServicesCommand({ cluster: customerClusterArn });
81+
const { serviceArns } = await ecsClient.send(listServicesCommand);
82+
return {
83+
s2ServicesArnsForDeletion: serviceArns.filter((sA) => sA.includes('s2_app_emissary_')),
84+
allServices: serviceArns
85+
};
86+
};
87+
88+
internals.deleteDanglingServices = async (ecsClient, servicesForDeletion, customerClusterArn) => {
89+
const { promiseAllTimeout, s2ProvisioningTimeout } = internals;
90+
const deleteServiceCommands = servicesForDeletion.map((s) => new DeleteServiceCommand({ cluster: customerClusterArn, service: s, force: true }));
91+
const promisedResponses = deleteServiceCommands.map((c) => ecsClient.send(c));
92+
const message = {
93+
success: 'Stage two ECS services have been brought down.',
94+
failure: 'Timeout exceeded while attempting to bring the s2 ECS service(s) down. One or more may still be running.'
95+
};
96+
let result;
97+
await promiseAllTimeout(promisedResponses, s2ProvisioningTimeout).then((resolved) => {
98+
console.info(`These are the values returned from the ECS deleteServiceCommand: ${JSON.stringify(resolved)}`); // Used for debugging.
99+
result = resolved.every((e) => !!e)
100+
? { success: message.success }
101+
: { failure: message.failure };
102+
}).catch((err) => {
103+
result = { failure: `${message.failure} The error was: ${err}` };
104+
});
105+
return result;
106+
};
107+
108+
// Available emoji: https://gist.github.com/rxaviers/7360908
109+
// Doc for message formatting:
110+
// https://api.slack.com/reference/surfaces/formatting#emoji
111+
// https://api.slack.com/messaging/composing/layouts
112+
// Setting up Slack App for incomming Webhook: https://api.slack.com/messaging/webhooks#posting_with_webhooks
113+
internals.publishResult = async ({ detail, s2ServicesArnsForDeletion, allServices, success, failure }) => {
114+
const { slackWebHook } = internals;
115+
console.info(`The current service Arns of cluster: "${detail.clusterArn}" were: ${allServices}`);
116+
console.info(`There were: "${s2ServicesArnsForDeletion.length ? s2ServicesArnsForDeletion.length : 0}" stage two services to be deleted`);
117+
success ? console.info(success) : console.error(failure);
118+
119+
await slackWebHook.post({
120+
json: {
121+
blocks: [
122+
{
123+
type: 'header',
124+
text: {
125+
type: 'plain_text',
126+
text: 'ECS Task State Change',
127+
emoji: true
128+
}
129+
},
130+
{
131+
type: 'section',
132+
fields: [{
133+
type: 'mrkdwn',
134+
text: `*Task Group:*\n\`${detail.group}\``
135+
}, {
136+
type: 'mrkdwn',
137+
text: `*lastStatus:*\n${detail.lastStatus}`
138+
}]
139+
},
140+
{
141+
type: 'section',
142+
text: {
143+
type: 'mrkdwn',
144+
text: `*Current services of cluster (${allServices.length}):*\n${allServices.length ? allServices.reduce((pV, cV) => `${pV}${cV}\n`, '') : 'NA'}`
145+
}
146+
},
147+
{
148+
type: 'section',
149+
text: {
150+
type: 'mrkdwn',
151+
text: `*Current services requiring deletion (${s2ServicesArnsForDeletion.length}):*\n${s2ServicesArnsForDeletion.length ? s2ServicesArnsForDeletion.reduce((pV, cV) => `${pV}${cV}\n`, '') : 'NA'}`
152+
}
153+
},
154+
{
155+
type: 'section',
156+
text: {
157+
type: 'mrkdwn',
158+
text: `*Result of deletion attempt:*\n${success ? `:ok: ${success}` : `:sos: ${failure}`}`
159+
}
160+
}
161+
162+
]
163+
}
164+
}).then((response) => { console.info(`The response from invoking the Slack incoming web hook was: "${response}"`); })
165+
.catch((err) => { console.error(`The call to the Slack incoming web hook responded with an error. The error was: "${err}"`); });
166+
};
167+
168+
// Deletes dangling stage two containers. I.E. whenever the stage one task isn't running to do so.
169+
exports.deleteS2ECSServices = async (event, context) => { // eslint-disable-line no-unused-vars
170+
const { detail, detail: { clusterArn: customerClusterArn } } = event;
171+
internals.s2ProvisioningTimeout = process.env.S2_PROVISIONING_TIMEOUT * 1000;
172+
173+
// internals.s2ProvisioningTimeout = 50; // Used to test error messages
174+
175+
const { getServicesOfCustCluster, deleteDanglingServices, publishResult, printEnv } = internals; // eslint-disable-line no-unused-vars
176+
console.info(`The event received from EventBridge was: ${JSON.stringify(event)}`);
177+
// console.info(`The context is: ${JSON.stringify(context)}`);
178+
// printEnv();
179+
const ecsClient = new ECSClient({ region: process.env.AWS_REGION });
180+
const servicesOfCustCluster = await getServicesOfCustCluster(ecsClient, customerClusterArn);
181+
182+
const deleteDanglingServicesResult = servicesOfCustCluster.s2ServicesArnsForDeletion.length
183+
? await deleteDanglingServices(ecsClient, servicesOfCustCluster.s2ServicesArnsForDeletion, customerClusterArn)
184+
: { success: 'There were no stage two services to be deleted', failure: undefined };
185+
186+
await publishResult({ detail, ...servicesOfCustCluster, ...deleteDanglingServicesResult });
187+
};

0 commit comments

Comments
 (0)