Skip to content

Commit 7fc8c13

Browse files
authored
fix: ACNA-4617 / #258 make api add additive, dedupe duplicate service records, surface JIL errors (#260)
* fix: ACNA-4617 surface JIL subscription errors, match profiles by productId `aio console workspace api add` previously treated any HTTP 200 from the JIL subscribe endpoint as success and dumped the body through `--json`, including responses like `{ error: [...], errorDetails: [...] }` that indicate a partial or total failure. Scripts that check exit code saw a green run while the workspace was never actually subscribed. Detect a non-empty `error[]` or `errorDetails[]` and throw a CLI error instead, so `--json` exits non-zero and the JIL message (e.g. "Service FrameioAPISDK requires selection of a product") is visible to the user. Also extend `--license-config` profile matching to accept the licenseConfig `productId`, in addition to `id` and `name`. Users looking at the output of `aio console api list --json` reasonably try the `productId` they see under `properties.licenseConfigs[].productId`; matching by it is harmless when the (id, name, productId) tuple is unambiguous within a service and unblocks Frame.io-style services where each product has a single profile. * fix: ACNA-4617 dedupe duplicate service records by sdkCode Root cause of the Frame.io subscription failure: getEnabledServicesForOrg returns FrameioAPISDK twice in this org — once as type: 'adobeid' with no licenseConfigs, and once as type: 'entp' with the product profile metadata required for OAuth Server-to-Server. The previous Array.find by code returned whichever the API listed first (the adobeid record), so availableProfiles was null, --license-config was silently dropped, and JIL rejected the subscription with "requires selection of a product". Replace the find-by-code with pickServiceForCode, which prefers the entp record with populated licenseConfigs when a code appears more than once. This command always uses OAuth Server-to-Server credentials, so picking the entp record matches the credential type and aligns with how the existing interactive prompt filters services (servicesToPromptChoices in aio-cli-lib-console). Verified end-to-end against the reporter's org on fresh Stage and Production workspaces: Frame.io now subscribes cleanly and the resulting OAuth Server-to-Server credential carries the frame.s2s.all scope. * fix: GH-258 make api add additive instead of overwriting existing services JIL's PUT-services endpoint replaces the credential's service list rather than merging into it, so any second call to `aio console workspace api add` silently wiped the services attached by an earlier call. The success message still said "added", but the prior subscriptions (and their credential scopes) were gone, with no deploy-time signal that anything broke. Fetch the credential's current serviceProperties via getServicePropertiesFromWorkspaceWithCredentialType, merge with the requested adds (requested entry wins on sdkCode overlap so users can still update licenseConfigs), and submit the union. If the fetch fails (e.g. a brand-new workspace with no credential yet) treat it as "no existing services" so the first-time path still works. Dedupe the enabled-services list up front via dedupeServicesByCode so the existing-services helper picks the entp record for FrameioAPISDK in fixServiceProperties and preserves the licenseConfig on round-trip. Verified end-to-end against the reporter's org: after three sequential single-service `api add` calls (AdobeIOManagementAPISDK, FrameioAPISDK, AppBuilderDataServicesSDK), the credential ends up subscribed to all three with their scopes intact, instead of just the last one. Closes #258
1 parent a2b9156 commit 7fc8c13

3 files changed

Lines changed: 401 additions & 12 deletions

File tree

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -610,7 +610,8 @@ FLAGS
610610
-y, --yml Output yml
611611
--help Show help
612612
--license-config=<value>... Product profile(s) for a service, format:
613-
'<sdkCode>=<profileNameOrId>[,<profileNameOrId>...]'. Repeat for multiple services.
613+
'<sdkCode>=<profileNameOrIdOrProductId>[,<profileNameOrIdOrProductId>...]'. Repeat for
614+
multiple services.
614615
--orgId=<value> Organization id
615616
--projectName=<value> (required) Name of the project containing the workspace
616617
--service-code=<value> (required) Comma-separated list of API service codes to add (e.g.
@@ -941,7 +942,8 @@ FLAGS
941942
-y, --yml Output yml
942943
--help Show help
943944
--license-config=<value>... Product profile(s) for a service, format:
944-
'<sdkCode>=<profileNameOrId>[,<profileNameOrId>...]'. Repeat for multiple services.
945+
'<sdkCode>=<profileNameOrIdOrProductId>[,<profileNameOrIdOrProductId>...]'. Repeat for
946+
multiple services.
945947
--orgId=<value> Organization id
946948
--projectName=<value> (required) Name of the project containing the workspace
947949
--service-code=<value> (required) Comma-separated list of API service codes to add (e.g.

src/commands/console/workspace/api/add.js

Lines changed: 156 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ const LibConsoleCLI = require('@adobe/aio-cli-lib-console')
2121
* Format: "<sdkCode>=<nameOrId>[,<nameOrId>...]"
2222
*
2323
* @param {string[]} values raw flag values
24-
* @returns {Object<string, string[]>} map of sdkCode to list of profile identifiers
24+
* @returns {{[sdkCode: string]: string[]}} map of sdkCode to list of profile identifiers
2525
*/
2626
function parseLicenseConfigFlags (values) {
2727
const result = {}
@@ -46,18 +46,23 @@ function parseLicenseConfigFlags (values) {
4646

4747
/**
4848
* Match requested profile identifiers against a service's available
49-
* licenseConfigs by either id or name.
49+
* licenseConfigs by id, name, or productId.
5050
*
51-
* @param {Array<{id: string, name: string, productId: string}>} available
52-
* @param {string[]} requested profile names or ids
51+
* Matching by productId lets users pass the value they see in
52+
* `properties.licenseConfigs[].productId` from `aio console api list`,
53+
* which is convenient for services like Frame.io that expose a single
54+
* profile per product.
55+
*
56+
* @param {Array<{id: string, name: string, productId: string}>} available licenseConfigs reported for the service
57+
* @param {string[]} requested profile names, ids, or productIds
5358
* @param {string} sdkCode service code for error messages
5459
* @returns {Array} selected licenseConfig objects
5560
*/
5661
function resolveLicenseConfigs (available, requested, sdkCode) {
5762
const selected = []
5863
const notFound = []
5964
for (const id of requested) {
60-
const match = available.find(lc => lc.id === id || lc.name === id)
65+
const match = available.find(lc => lc.id === id || lc.name === id || lc.productId === id)
6166
if (match) {
6267
selected.push(match)
6368
} else {
@@ -74,6 +79,112 @@ function resolveLicenseConfigs (available, requested, sdkCode) {
7479
return selected
7580
}
7681

82+
/**
83+
* Pick the best service record to subscribe when multiple records share
84+
* the same sdkCode.
85+
*
86+
* Some services (notably Frame.io) appear twice in `getEnabledServicesForOrg`:
87+
* once as `type: 'adobeid'` with no licenseConfigs (browser/SPA flow) and
88+
* once as `type: 'entp'` with the product profile metadata required for
89+
* OAuth Server-to-Server. `Array.find` returns whichever the API lists
90+
* first, which silently drops `--license-config` when the adobeid record
91+
* wins and causes JIL to reject the subscription. Since this command
92+
* always uses OAuth Server-to-Server credentials, prefer the `entp` record
93+
* (and, within that, the one that actually carries licenseConfigs).
94+
*
95+
* @param {Array<object>} services full enabled-services list
96+
* @param {string} code sdkCode to look up
97+
* @returns {object|undefined} the chosen service record, or undefined
98+
*/
99+
function pickServiceForCode (services, code) {
100+
const matches = services.filter(s => s.code === code)
101+
if (matches.length === 0) {
102+
return undefined
103+
}
104+
const hasLicenseConfigs = s =>
105+
s.properties &&
106+
Array.isArray(s.properties.licenseConfigs) &&
107+
s.properties.licenseConfigs.length > 0
108+
const entpWithProfiles = matches.find(s => s.type === 'entp' && hasLicenseConfigs(s))
109+
if (entpWithProfiles) {
110+
return entpWithProfiles
111+
}
112+
const entp = matches.find(s => s.type === 'entp')
113+
if (entp) {
114+
return entp
115+
}
116+
return matches[0]
117+
}
118+
119+
/**
120+
* Reduce the enabled-services list to one record per sdkCode using
121+
* pickServiceForCode. Preserves the original ordering of the chosen records.
122+
*
123+
* @param {Array<object>} services enabled services
124+
* @returns {Array<object>} deduplicated services
125+
*/
126+
function dedupeServicesByCode (services) {
127+
const seen = new Set()
128+
const result = []
129+
for (const s of services) {
130+
if (seen.has(s.code)) continue
131+
seen.add(s.code)
132+
// pickServiceForCode is guaranteed to return a record because s itself
133+
// is in services and matches by code.
134+
result.push(pickServiceForCode(services, s.code))
135+
}
136+
return result
137+
}
138+
139+
/**
140+
* Merge new service-subscription requests with existing services on the
141+
* credential. JIL's PUT-services endpoint replaces the credential's
142+
* service list rather than appending to it, so without this merge a
143+
* subsequent `aio console workspace api add` call would silently wipe
144+
* the services subscribed by an earlier call.
145+
*
146+
* For codes present in both, the new entry wins (the user is overriding
147+
* the existing subscription, including any licenseConfig changes).
148+
*
149+
* @param {Array<object>} existing serviceProperties currently on the credential
150+
* @param {Array<object>} requested serviceProperties the user is adding
151+
* @returns {Array<object>} merged serviceProperties
152+
*/
153+
function mergeServiceProperties (existing, requested) {
154+
const requestedCodes = new Set(requested.map(sp => sp.sdkCode))
155+
const kept = existing.filter(sp => !requestedCodes.has(sp.sdkCode))
156+
return [...kept, ...requested]
157+
}
158+
159+
/**
160+
* Detect JIL subscription errors embedded in a 200 response and throw
161+
* a CLI-friendly error if any are found.
162+
*
163+
* JIL returns `{ error: [<sdkCode>...], errorDetails: [{ sdkCode, domain, code, message }...] }`
164+
* for partial/total failures inside an otherwise successful HTTP response,
165+
* so without this check `--json` output silently looks like success.
166+
*
167+
* @param {object} response the subscribe response body
168+
*/
169+
function assertSubscribeSuccess (response) {
170+
if (!response || typeof response !== 'object') {
171+
return
172+
}
173+
const errorDetails = Array.isArray(response.errorDetails) ? response.errorDetails : []
174+
const errorCodes = Array.isArray(response.error) ? response.error : []
175+
if (errorDetails.length === 0 && errorCodes.length === 0) {
176+
return
177+
}
178+
const formatted = errorDetails.length > 0
179+
? errorDetails.map(d => {
180+
const where = d && d.sdkCode ? `${d.sdkCode}: ` : ''
181+
const message = d == null ? '(unknown error)' : (d.message || JSON.stringify(d))
182+
return ` ${where}${message}`
183+
}).join('\n')
184+
: ` ${errorCodes.join(', ')}`
185+
throw new Error(`Failed to add API service(s):\n${formatted}`)
186+
}
187+
77188
class AddCommand extends ConsoleCommand {
78189
async run () {
79190
const { flags } = await this.parse(AddCommand)
@@ -107,14 +218,27 @@ class AddCommand extends ConsoleCommand {
107218

108219
const licenseConfigMap = parseLicenseConfigFlags(flags['license-config'] || [])
109220

221+
// Fail fast if --license-config references a service code that isn't in
222+
// --service-code. Otherwise the entry is silently ignored, which is the
223+
// exact silent-drop class of bug this command was patched against.
224+
const requestedSet = new Set(requestedCodes)
225+
const orphanLicenseConfigs = Object.keys(licenseConfigMap).filter(c => !requestedSet.has(c))
226+
if (orphanLicenseConfigs.length > 0) {
227+
this.error(
228+
`--license-config given for service code(s) not in --service-code: ${orphanLicenseConfigs.join(', ')}. ` +
229+
`Requested service codes: ${requestedCodes.join(', ')}.`
230+
)
231+
}
232+
110233
const enabledServices = await this.consoleCLI.getEnabledServicesForOrg(orgId)
111-
aioConsoleLogger.debug(`Enabled services: ${JSON.stringify(enabledServices.map(s => s.code))}`)
234+
const supportedServices = dedupeServicesByCode(enabledServices)
235+
aioConsoleLogger.debug(`Enabled services (deduped): ${JSON.stringify(supportedServices.map(s => s.code))}`)
112236

113237
const serviceProperties = []
114238
const notFound = []
115239
const missingProfiles = []
116240
for (const code of requestedCodes) {
117-
const service = enabledServices.find(s => s.code === code)
241+
const service = supportedServices.find(s => s.code === code)
118242
if (!service) {
119243
notFound.push(code)
120244
continue
@@ -154,14 +278,33 @@ class AddCommand extends ConsoleCommand {
154278
)
155279
}
156280

281+
// JIL's PUT-services endpoint replaces the credential's service list,
282+
// so fetch what's already subscribed and submit the union — otherwise
283+
// a later `api add` call silently wipes services attached by an earlier
284+
// one. The lib returns [] (not throws) for a workspace without a
285+
// credential yet, so any thrown error here is real (auth, network,
286+
// server) and we let it propagate: proceeding with an empty list
287+
// would cause the very overwrite this merge is supposed to prevent.
288+
const existingProperties = await this.consoleCLI.getServicePropertiesFromWorkspaceWithCredentialType({
289+
orgId,
290+
projectId: project.id,
291+
workspace,
292+
supportedServices,
293+
credentialType: LibConsoleCLI.OAUTH_SERVER_TO_SERVER_CREDENTIAL
294+
})
295+
const mergedProperties = mergeServiceProperties(existingProperties, serviceProperties)
296+
aioConsoleLogger.debug(`Submitting service list: ${JSON.stringify(mergedProperties.map(sp => sp.sdkCode))}`)
297+
157298
const result = await this.consoleCLI.subscribeToServicesWithCredentialType({
158299
orgId,
159300
project,
160301
workspace,
161-
serviceProperties,
302+
serviceProperties: mergedProperties,
162303
credentialType: LibConsoleCLI.OAUTH_SERVER_TO_SERVER_CREDENTIAL
163304
})
164305

306+
assertSubscribeSuccess(result)
307+
165308
if (flags.json) {
166309
this.printJson(result)
167310
} else if (flags.yml) {
@@ -200,7 +343,7 @@ AddCommand.flags = {
200343
required: true
201344
}),
202345
'license-config': Flags.string({
203-
description: 'Product profile(s) for a service, format: \'<sdkCode>=<profileNameOrId>[,<profileNameOrId>...]\'. Repeat for multiple services.',
346+
description: 'Product profile(s) for a service, format: \'<sdkCode>=<profileNameOrIdOrProductId>[,<profileNameOrIdOrProductId>...]\'. Repeat for multiple services.',
204347
multiple: true
205348
}),
206349
json: Flags.boolean({
@@ -222,3 +365,7 @@ AddCommand.aliases = [
222365
module.exports = AddCommand
223366
module.exports.parseLicenseConfigFlags = parseLicenseConfigFlags
224367
module.exports.resolveLicenseConfigs = resolveLicenseConfigs
368+
module.exports.assertSubscribeSuccess = assertSubscribeSuccess
369+
module.exports.pickServiceForCode = pickServiceForCode
370+
module.exports.dedupeServicesByCode = dedupeServicesByCode
371+
module.exports.mergeServiceProperties = mergeServiceProperties

0 commit comments

Comments
 (0)