Skip to content

Commit ff0c5a8

Browse files
Ensure a more orderly deletion when using triggers (#104)
* Ensure a more orderly deletion when using triggers * Back out usage of AggregateError (too many issues)
1 parent 97e6b57 commit ff0c5a8

File tree

5 files changed

+123
-126
lines changed

5 files changed

+123
-126
lines changed

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@nimbella/nimbella-deployer",
3-
"version": "4.3.8",
3+
"version": "4.3.9",
44
"description": "The Nimbella platform deployer library",
55
"main": "lib/index.js",
66
"repository": {

src/deploy.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -586,7 +586,7 @@ async function deployActionFromCodeOrSequence(action: ActionSpec, spec: DeploySt
586586

587587
let triggerResults: (DeploySuccess|Error)[] = []
588588
if (action.triggers) {
589-
triggerResults = await deployTriggers(action.triggers, name, wsk, spec.credentials.namespace)
589+
triggerResults = await deployTriggers(action.triggers, name, spec.credentials.namespace)
590590
}
591591
const map = {}
592592
if (digest) {

src/triggers.ts

Lines changed: 30 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -14,97 +14,59 @@
1414
// Includes support for listing, installing, and removing triggers, when such triggers are attached to
1515
// an action.
1616

17-
// TEMPORARY: some of this code will, if requested, invoke actions in /nimbella/triggers/[create|delete|list]
18-
// rather than the trigger APIs defined on api.digitalocean.com/v2/functions. The actions in question
19-
// implement a prototype verion of the API which is still being used as a reference implementation until
20-
// the trigger APIs are stable and adequate. The two are not functionally equivalent in that the prototype
21-
// API will result in updates to the public scheduling service (not the production one in DOCC).
22-
//
23-
// To request the prototype API set TRIGGERS_USE_PROTOTYPE non-empty in the environment.
24-
//
25-
// Unless the prototype API is used, the DO_API_KEY environment variable must be set to the DO API key that
26-
// should be used to contact the DO API endpoint. In doctl this key will be placed in the process
27-
// environment of the subprocess that runs the deployer. If the deployer is invoked via nim rather than
28-
// doctl, the invoking context must set this key (e.g. in the API build container or for testing).
29-
//
30-
// If neither environment variable is set, then trigger operations are skipped (become no-ops).
31-
// Reasoning: the deployer should always be called by one of
32-
// - doctl, which will always set DO_API_KEY
33-
// - tests, which will knowingly set one of the two
34-
// - the AP build container, which should have maximum flexibility to either (1) refuse to deploy if
35-
// the project has triggers, (2) set DO_API_KEY in the subprocess enviornment when invoking nim, (3)
36-
// proceed with a warning and without deploying triggers.
37-
// And, the list operation, at least, will be called routinely during cleanup operations and must be
38-
// able to act as a no-op (return the empty list) when triggers are not being used.
17+
// Note well: this code is prepared to succeed silently (no-op) in contexts where the authorization to
18+
// manage triggers is absent. To make the code do something, the DO_API_KEY environment variable must be
19+
// set to the DO API key that should be used to contact the DO API endpoint. In doctl, this key will be
20+
// placed in the process environment of the subprocess that runs the deployer. If the deployer is invoked
21+
// via nim rather than doctl, the invoking context must set this key (e.g. in the app platform build container
22+
// or for testing).
3923

40-
import openwhisk from 'openwhisk'
4124
import { TriggerSpec, SchedulerSourceDetails, DeploySuccess } from './deploy-struct'
4225
import { default as axios, AxiosRequestConfig } from 'axios'
4326
import makeDebug from 'debug'
4427
const debug = makeDebug('nim:deployer:triggers')
45-
const usePrototype=process.env.TRIGGERS_USE_PROTOTYPE
4628
const doAPIKey=process.env.DO_API_KEY
4729
const doAPIEndpoint='https://api.digitalocean.com'
4830

49-
export async function deployTriggers(triggers: TriggerSpec[], functionName: string, wsk: openwhisk.Client,
50-
namespace: string): Promise<(DeploySuccess|Error)[]> {
31+
// Returns an array. Elements are a either DeploySuccess structure for use in reporting when the trigger
32+
// is deployed successfully, or an Error if the trigger failed to deploy. These are 1-1 with the input
33+
// triggers argument.
34+
export async function deployTriggers(triggers: TriggerSpec[], functionName: string, namespace: string): Promise<(DeploySuccess|Error)[]> {
5135
const result: (DeploySuccess|Error)[] = []
5236
for (const trigger of triggers) {
53-
result.push(await deployTrigger(trigger, functionName, wsk, namespace))
37+
result.push(await deployTrigger(trigger, functionName, namespace))
5438
}
5539
debug('finished deploying triggers, returning result')
5640
return result
5741
}
5842

59-
export async function undeployTriggers(triggers: string[], wsk: openwhisk.Client, namespace: string): Promise<(Error|true)[]> {
60-
const result: (Error|true)[] = []
43+
// Undeploy one or more triggers. Returns the empty array on success. Otherwise, the return
44+
// contains all the errors from attempted deletions.
45+
export async function undeployTriggers(triggers: string[], namespace: string): Promise<Error[]> {
46+
const errors: any[] = []
6147
for (const trigger of triggers) {
6248
try {
63-
await undeployTrigger(trigger, wsk, namespace)
64-
result.push(true)
49+
await undeployTrigger(trigger, namespace)
6550
} catch (err) {
66-
// See comment in catch clause in deployTrigger
67-
if (typeof err === 'string') {
68-
result.push(Error(err))
69-
} else {
70-
result.push(err as Error)
71-
}
51+
const msg = err.message ? err.message : err
52+
errors.push(new Error(`unable to undeploy trigger '${trigger}': ${msg}`))
7253
}
7354
}
74-
return result
55+
return errors
7556
}
7657

7758
// Code to deploy a trigger.
7859
// Note that basic structural validation of each trigger has been done previously
7960
// so paranoid checking is omitted.
80-
async function deployTrigger(trigger: TriggerSpec, functionName: string, wsk: openwhisk.Client, namespace: string): Promise<DeploySuccess|Error> {
61+
async function deployTrigger(trigger: TriggerSpec, functionName: string, namespace: string): Promise<DeploySuccess|Error> {
8162
const details = trigger.sourceDetails as SchedulerSourceDetails
8263
const { cron, withBody } = details
83-
const { sourceType, enabled } = trigger
84-
const params = {
85-
triggerName: trigger.name,
86-
function: functionName,
87-
sourceType,
88-
cron,
89-
withBody,
90-
overwrite: true,
91-
enabled
92-
}
64+
const { enabled } = trigger
9365
try {
94-
if (!usePrototype && doAPIKey) {
95-
// Call the real API
96-
debug('calling the real trigger API to create %s', trigger.name)
66+
if (doAPIKey) {
67+
debug('calling the trigger API to create %s', trigger.name)
9768
return await doTriggerCreate(trigger.name, functionName, namespace, cron, enabled, withBody)
98-
} else if (usePrototype) {
99-
// Call the prototype API
100-
await wsk.actions.invoke({
101-
name: '/nimbella/triggers/create',
102-
params,
103-
blocking: true,
104-
result: true
105-
})
106-
return { name: trigger.name, kind: 'trigger', skipped: false }
107-
}
69+
} // otherwise do nothing
10870
} catch (err) {
10971
debug('caught an error while deploying trigger; will return it')
11072
// Assume 'err' is either string or Error in the following. Actually it can be anything but use of
@@ -155,22 +117,10 @@ async function doAxios(config: AxiosRequestConfig): Promise<object> {
155117
}
156118

157119
// Code to delete a trigger.
158-
async function undeployTrigger(trigger: string, wsk: openwhisk.Client, namespace: string) {
120+
async function undeployTrigger(trigger: string, namespace: string) {
159121
debug('undeploying trigger %s', trigger)
160-
if (doAPIKey && !usePrototype) {
161-
// Use the real API
122+
if (doAPIKey) {
162123
return doTriggerDelete(trigger, namespace)
163-
} else if (usePrototype) {
164-
// Prototype API
165-
const params = {
166-
triggerName: trigger
167-
}
168-
return await wsk.actions.invoke({
169-
name: '/nimbella/triggers/delete',
170-
params,
171-
blocking: true,
172-
result: true
173-
})
174124
}
175125
// Else no-up. A Promise<void> (non-error) is returned implicitly
176126
}
@@ -184,32 +134,17 @@ async function doTriggerDelete(trigger: string, namespace: string): Promise<obje
184134
return doAxios(config)
185135
}
186136

187-
// Code to get all the triggers for a namespace, or all the triggers for a function in the
188-
// namespace.
189-
export async function listTriggersForNamespace(wsk: openwhisk.Client, namespace: string, fcn?: string): Promise<string[]> {
137+
// Code to get all the triggers for a namespace, or all the triggers for a function in the namespace.
138+
export async function listTriggersForNamespace(namespace: string, fcn?: string): Promise<string[]> {
190139
debug('listing triggers')
191-
if (doAPIKey && !usePrototype) {
192-
// Use the real API
140+
if (doAPIKey) {
193141
return doTriggerList(namespace, fcn)
194-
} else if (usePrototype) {
195-
// Use the prototype API
196-
const params: any = {
197-
name: '/nimbella/triggers/list',
198-
blocking: true,
199-
result: true
200-
}
201-
if (fcn) {
202-
params.params = { function: fcn }
203-
}
204-
const triggers: any = await wsk.actions.invoke(params)
205-
debug('triggers listed')
206-
return triggers.items.map((trigger: any) => trigger.triggerName)
207142
}
208143
// No-op if no envvars are set
209144
return []
210145
}
211146

212-
// The following is my best guess on what the trigger list API is planning to return
147+
// The trigger list API returns this structure (abridged here to just what we care about)
213148
interface TriggerList {
214149
triggers: TriggerInfo[]
215150
}

src/util.ts

Lines changed: 89 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1207,29 +1207,51 @@ function deployerAnnotationFromGithub(githubPath: string): DeployerAnnotation {
12071207
return { digest: undefined, user: 'cloud', repository, projectPath: def.path, commit: def.ref || 'master' }
12081208
}
12091209

1210-
// Wipe all the entities from the namespace referred to by an OW client handle
1210+
// Wipe all the entities from the namespace referred to by an OW client handle.
1211+
// This is not guaranteed to succeed but it will attempt to wipe as much as possible,
1212+
// returning all errors that occurred. If an error occurs when removing a trigger that
1213+
// is associated with a function, the function is not deleted.
12111214
export async function wipe(client: Client): Promise<void> {
1212-
await wipeAll(client.actions, 'Action')
1215+
const errors: any[] = []
1216+
// Delete the actions, which will delete associated triggers if possible. If a trigger
1217+
// cannot be deleted, the action is left also.
1218+
await wipeActions(client, errors)
12131219
debug('Actions wiped')
1214-
await wipeAll(client.rules, 'Rule')
1215-
debug('Rules wiped')
1216-
await wipeAll(client.triggers, 'Trigger')
1217-
debug('OpenWhisk triggers wiped')
1218-
await wipeAll(client.packages, 'Package')
1220+
// Delete the packages. Note that if an action deletion failed, the containing
1221+
// package will likely fail also.
1222+
await wipeAll(client.packages, 'Package', errors)
12191223
debug('Packages wiped')
1224+
// There _could_ have been orphaned triggers that were not deleted when we
1225+
// deleted actions (since they were not connected to an existing action).
1226+
// On the other hand, if one or more triggers failed deletion before, they
1227+
// may fail again here, with duplicate errors. We are tolerating that for the moment.
12201228
const namespace = await getTargetNamespace(client)
1221-
const triggers = await listTriggersForNamespace(client, namespace)
1222-
if (triggers) {
1223-
debug('There are %d DigitalOcean triggers to remove', triggers.length)
1224-
await undeployTriggers(triggers, client, namespace)
1225-
// TODO errors are being fed back here but are currently ignored. It is not completely
1226-
// clear what should be done with them.
1229+
const triggers = await listTriggersForNamespace(namespace, '')
1230+
const errs = await undeployTriggers(triggers, namespace)
1231+
if (errs.length > 0) {
1232+
errors.push(...errs)
1233+
}
1234+
// Delete rules and OpenWhisk triggers for good measure (there really shouldn't be any)
1235+
await wipeAll(client.rules, 'Rule', errors)
1236+
debug('Rules wiped')
1237+
await wipeAll(client.triggers, 'Trigger', errors)
1238+
debug('OpenWhisk triggers wiped')
1239+
// Determine if any errors occurred. If so, throw them. We have tried using AggregateError
1240+
// here but ran into perplexing problems. So, using an ad hoc approach when combining errors.
1241+
if (errors.length > 1) {
1242+
const combined = Error('multiple errors occurred while cleaning the namespace') as any
1243+
combined.errors = errors
1244+
throw combined
1245+
}
1246+
if (errors.length == 1){
1247+
throw errors[0]
12271248
}
12281249
}
12291250

1230-
// Repeatedly wipe an entity (action, rule, trigger, or package) from the namespace denoted by the OW client until none are left
1231-
// Note that the list function can only return 200 entities at a time)
1232-
async function wipeAll(handle: any, kind: string) {
1251+
// Repeatedly wipe an entity (rule, trigger, or package) from the namespace denoted by the OW client until none are left
1252+
// Note that the list function can only return 200 entities at a time).
1253+
// Actions are handled differently since they may have triggers associated with them.
1254+
async function wipeAll(handle: any, kind: string, errors: any[]) {
12331255
while (true) {
12341256
const entities = await handle.list({ limit: 200 })
12351257
if (entities.length === 0) {
@@ -1241,22 +1263,62 @@ async function wipeAll(handle: any, kind: string) {
12411263
if (nsparts.length > 1) {
12421264
name = nsparts[1] + '/' + name
12431265
}
1244-
await handle.delete(name)
1245-
debug('%s %s deleted', kind, name)
1266+
try {
1267+
await handle.delete(name)
1268+
debug('%s %s deleted', kind, name)
1269+
} catch (err) {
1270+
debug('error deleting %s %s: %O', err)
1271+
errors.push(err)
1272+
}
12461273
}
12471274
}
12481275
}
12491276

1250-
// Delete an action while also deleting any DigitalOcean triggers that are targetting it
1251-
export async function deleteAction(actionName: string, owClient: Client): Promise<Action> {
1252-
// First delete the action itself. If this fails it will throw.
1253-
// Save the action contents so they can be returned as a result.
1254-
const action = await owClient.actions.delete({ name: actionName})
1255-
// Delete associated triggers (if any)
1277+
// Repeatedly wipe actions from the namespace denoted by the OW client until none are left.
1278+
// Note that the list function can only return 200 actions at a time. This is done
1279+
// differently from the other entity types because actions can have associated triggers
1280+
// which should be deleted first (with the action remaining if the trigger deletion fails).
1281+
async function wipeActions(client: Client, errors: any[]) {
1282+
while (true) {
1283+
const actions = await client.actions.list({ limit: 200 })
1284+
if (actions.length === 0) {
1285+
return
1286+
}
1287+
for (const action of actions) {
1288+
let name = action.name
1289+
const nsparts = action.namespace.split('/')
1290+
if (nsparts.length > 1) {
1291+
name = nsparts[1] + '/' + name
1292+
}
1293+
const result = await deleteAction(name, client)
1294+
if (Array.isArray(result)) {
1295+
errors.push(...result)
1296+
debug('error deleting the triggers')
1297+
}
1298+
debug('action %s deleted', name)
1299+
}
1300+
}
1301+
}
1302+
1303+
// Delete an action after first deleting any DigitalOcean triggers that are targeting it.
1304+
// If we can't delete the triggers, we don't delete the action (maintains the invariant that
1305+
// a trigger should only exist if its invoked function also exists). This function is not
1306+
// intended to throw but to return errors in an array (either errors deleting triggers or an
1307+
// error deleting the action).
1308+
export async function deleteAction(actionName: string, owClient: Client): Promise<Action|Error[]> {
1309+
// Delete triggers.
12561310
const namespace = await getTargetNamespace(owClient)
1257-
const triggers = await listTriggersForNamespace(owClient, namespace, actionName)
1258-
await undeployTriggers(triggers, owClient, namespace)
1259-
return action
1311+
const triggers = await listTriggersForNamespace(namespace, actionName)
1312+
const errs = await undeployTriggers(triggers, namespace)
1313+
if (errs.length > 0) {
1314+
return errs
1315+
}
1316+
try {
1317+
return await owClient.actions.delete({ name: actionName})
1318+
} catch (err) {
1319+
const msg = err.message ? err.message : err
1320+
return [ new Error(`unable to undeploy action '${actionName}': ${msg}`) ]
1321+
}
12601322
}
12611323

12621324
// Generate a secret in the form of a random alphameric string (TODO what form(s) do we actually support)

0 commit comments

Comments
 (0)