@stackgenhq/backstage-plugin-stackgen-backend 0.3.6-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +485 -0
- package/config.d.ts +39 -0
- package/dist/actions/core/assign_project_members_action.cjs.js +227 -0
- package/dist/actions/core/assign_project_members_action.cjs.js.map +1 -0
- package/dist/actions/core/create_appstack_action.cjs.js +953 -0
- package/dist/actions/core/create_appstack_action.cjs.js.map +1 -0
- package/dist/actions/core/create_project_action.cjs.js +243 -0
- package/dist/actions/core/create_project_action.cjs.js.map +1 -0
- package/dist/actions/core/download_iac_action.cjs.js +133 -0
- package/dist/actions/core/download_iac_action.cjs.js.map +1 -0
- package/dist/actions/core/set_environment_variable_values_action.cjs.js +121 -0
- package/dist/actions/core/set_environment_variable_values_action.cjs.js.map +1 -0
- package/dist/actions/core/set_state_backend_action.cjs.js +174 -0
- package/dist/actions/core/set_state_backend_action.cjs.js.map +1 -0
- package/dist/actions/exporter/export_iac_to_git_action.cjs.js +290 -0
- package/dist/actions/exporter/export_iac_to_git_action.cjs.js.map +1 -0
- package/dist/actions/exporter/list_vault_secrets_action.cjs.js +39 -0
- package/dist/actions/exporter/list_vault_secrets_action.cjs.js.map +1 -0
- package/dist/config/constants.cjs.js +23 -0
- package/dist/config/constants.cjs.js.map +1 -0
- package/dist/exporter/exportOverrides.cjs.js +27 -0
- package/dist/exporter/exportOverrides.cjs.js.map +1 -0
- package/dist/exporter/gitExporterConfigHttp.cjs.js +105 -0
- package/dist/exporter/gitExporterConfigHttp.cjs.js.map +1 -0
- package/dist/exporter/http.cjs.js +14 -0
- package/dist/exporter/http.cjs.js.map +1 -0
- package/dist/exporter/paths.cjs.js +25 -0
- package/dist/exporter/paths.cjs.js.map +1 -0
- package/dist/exporter/projectGitExporterTemplate.cjs.js +62 -0
- package/dist/exporter/projectGitExporterTemplate.cjs.js.map +1 -0
- package/dist/exporter/topologyResolve.cjs.js +71 -0
- package/dist/exporter/topologyResolve.cjs.js.map +1 -0
- package/dist/index.cjs.js +31 -0
- package/dist/index.cjs.js.map +1 -0
- package/dist/index.d.ts +224 -0
- package/dist/lib/extractArchive.cjs.js +62 -0
- package/dist/lib/extractArchive.cjs.js.map +1 -0
- package/dist/modules/stackgenExporterScaffolderModule.cjs.js +29 -0
- package/dist/modules/stackgenExporterScaffolderModule.cjs.js.map +1 -0
- package/dist/modules/stackgenUiProxiesModule.cjs.js +366 -0
- package/dist/modules/stackgenUiProxiesModule.cjs.js.map +1 -0
- package/dist/plugin/plugin.cjs.js +136 -0
- package/dist/plugin/plugin.cjs.js.map +1 -0
- package/dist/plugin/router.cjs.js +341 -0
- package/dist/plugin/router.cjs.js.map +1 -0
- package/dist/services/MappingsService/mappingsService.cjs.js +149 -0
- package/dist/services/MappingsService/mappingsService.cjs.js.map +1 -0
- package/dist/services/StackgenService/stackgenService.cjs.js +267 -0
- package/dist/services/StackgenService/stackgenService.cjs.js.map +1 -0
- package/dist/utils/api-objects.cjs.js +205 -0
- package/dist/utils/api-objects.cjs.js.map +1 -0
- package/dist/vendor/stackgen-api-client/index.cjs.js +47681 -0
- package/dist/vendor/stackgen-api-client/index.cjs.js.map +1 -0
- package/migrations/20250131055302_init.js +24 -0
- package/migrations/20250305123456_alter_type_associations.js +23 -0
- package/package.json +86 -0
- package/templates/appstack-configure-environment.yaml +81 -0
- package/templates/appstack-from-template.yaml +76 -0
- package/templates/appstack-with-modules.yaml +69 -0
- package/templates/appstack-with-resources.yaml +81 -0
- package/templates/export-iac.yaml +97 -0
- package/templates/full-infra-setup.yaml +156 -0
- package/templates/git_export_exporter_multi_scm.yaml +123 -0
- package/templates/project-advanced.yaml +77 -0
- package/templates/project-basic.yaml +39 -0
- package/templates/project-with-members.yaml +86 -0
|
@@ -0,0 +1,953 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var pluginScaffolderNode = require('@backstage/plugin-scaffolder-node');
|
|
4
|
+
var zod = require('zod');
|
|
5
|
+
var apiObjects = require('../../utils/api-objects.cjs.js');
|
|
6
|
+
|
|
7
|
+
const iacTypeSchema = zod.z.enum(["Helm", "Terraform"]);
|
|
8
|
+
const childResourceSchema = zod.z.lazy(
|
|
9
|
+
() => zod.z.object({
|
|
10
|
+
resourceType: zod.z.string(),
|
|
11
|
+
name: zod.z.string().optional(),
|
|
12
|
+
iacType: iacTypeSchema.optional(),
|
|
13
|
+
configuration: zod.z.record(zod.z.string(), zod.z.any()).optional(),
|
|
14
|
+
tfVars: zod.z.record(zod.z.string(), zod.z.string()).optional(),
|
|
15
|
+
resourceTemplateVersionName: zod.z.string().optional(),
|
|
16
|
+
resourcePackId: zod.z.string().optional(),
|
|
17
|
+
templateId: zod.z.string().optional(),
|
|
18
|
+
children: zod.z.array(childResourceSchema).optional()
|
|
19
|
+
})
|
|
20
|
+
);
|
|
21
|
+
const resourceSchema = zod.z.object({
|
|
22
|
+
resourceType: zod.z.string().optional(),
|
|
23
|
+
name: zod.z.string().optional(),
|
|
24
|
+
iacType: iacTypeSchema.optional(),
|
|
25
|
+
resourceTemplateVersionName: zod.z.string().optional(),
|
|
26
|
+
configuration: zod.z.record(zod.z.string(), zod.z.any()).optional(),
|
|
27
|
+
tfVars: zod.z.record(zod.z.string(), zod.z.string()).optional(),
|
|
28
|
+
resourcePackId: zod.z.string().optional(),
|
|
29
|
+
templateId: zod.z.string().optional(),
|
|
30
|
+
children: zod.z.array(childResourceSchema).optional(),
|
|
31
|
+
/** ModuleSelect field shape — coerced to a custom module resource. */
|
|
32
|
+
id: zod.z.string().optional(),
|
|
33
|
+
baseId: zod.z.string().optional(),
|
|
34
|
+
label: zod.z.string().optional(),
|
|
35
|
+
versionName: zod.z.string().optional(),
|
|
36
|
+
moduleName: zod.z.string().optional()
|
|
37
|
+
}).superRefine((res, ctx) => {
|
|
38
|
+
const hasConfiguration = !!res.configuration;
|
|
39
|
+
const hasResourcePackId = !!res.resourcePackId;
|
|
40
|
+
const looksLikeModuleSelect = typeof res.id === "string" && res.id && !res.resourceType && !res.templateId && !res.resourcePackId;
|
|
41
|
+
if (!res.resourceType && !looksLikeModuleSelect) {
|
|
42
|
+
ctx.addIssue({
|
|
43
|
+
code: zod.z.ZodIssueCode.custom,
|
|
44
|
+
message: "resourceType is required (or pass a ModuleSelect value with id)",
|
|
45
|
+
path: ["resourceType"]
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
if (hasConfiguration && hasResourcePackId) {
|
|
49
|
+
ctx.addIssue({
|
|
50
|
+
code: zod.z.ZodIssueCode.custom,
|
|
51
|
+
message: "only one of 'configuration' or 'resourcePackId' should be provided, not both for a resource",
|
|
52
|
+
path: ["configuration"]
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
const DEFAULT_TOPOLOGY_DEPLOYMENT_TYPE = "k8s";
|
|
57
|
+
const createAppstackInputSchema = zod.z.object({
|
|
58
|
+
appstack: zod.z.object({
|
|
59
|
+
name: zod.z.string().trim().optional(),
|
|
60
|
+
appstackName: zod.z.string().trim().optional(),
|
|
61
|
+
teamId: zod.z.string().trim().min(1, "appstack.teamId is required"),
|
|
62
|
+
/**
|
|
63
|
+
* Required unless `templateAppstackId` is set (provider is then read from the
|
|
64
|
+
* tenant template list `cloudProvider` or appcd `coreConfig.provider`).
|
|
65
|
+
*/
|
|
66
|
+
cloudProvider: zod.z.string().trim().optional(),
|
|
67
|
+
/**
|
|
68
|
+
* When set: resolve tenant template summary (and optionally appcd AppStack
|
|
69
|
+
* `coreConfig`), create an empty AppStack, then POST iac-gen topology with
|
|
70
|
+
* `appstackRefId` to clone. Mutually exclusive with a non-empty `resources` array.
|
|
71
|
+
*/
|
|
72
|
+
templateAppstackId: zod.z.string().trim().uuid().optional(),
|
|
73
|
+
resources: zod.z.array(resourceSchema).default([])
|
|
74
|
+
}).superRefine((appstack, ctx) => {
|
|
75
|
+
const name = appstack.name?.trim();
|
|
76
|
+
const appstackName = appstack.appstackName?.trim();
|
|
77
|
+
if (!name && !appstackName) {
|
|
78
|
+
ctx.addIssue({
|
|
79
|
+
code: zod.z.ZodIssueCode.custom,
|
|
80
|
+
message: "one of 'name' or 'appstackName' must be provided",
|
|
81
|
+
path: ["name"]
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
const templateId = appstack.templateAppstackId?.trim();
|
|
85
|
+
const cloud = appstack.cloudProvider?.trim();
|
|
86
|
+
if (!templateId && !cloud) {
|
|
87
|
+
ctx.addIssue({
|
|
88
|
+
code: zod.z.ZodIssueCode.custom,
|
|
89
|
+
message: "appstack.cloudProvider is required when templateAppstackId is not set",
|
|
90
|
+
path: ["cloudProvider"]
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
if (templateId && appstack.resources.length > 0) {
|
|
94
|
+
ctx.addIssue({
|
|
95
|
+
code: zod.z.ZodIssueCode.custom,
|
|
96
|
+
message: "templateAppstackId cannot be used together with a non-empty resources array; use either topology clone or explicit resources, not both",
|
|
97
|
+
path: ["templateAppstackId"]
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
});
|
|
102
|
+
const appstackOutputSchema = zod.z.object({
|
|
103
|
+
appstackId: zod.z.string(),
|
|
104
|
+
appstackName: zod.z.string(),
|
|
105
|
+
appStackURL: zod.z.string(),
|
|
106
|
+
topologyId: zod.z.string().optional()
|
|
107
|
+
});
|
|
108
|
+
function readIntegrationsAppstackIds(body) {
|
|
109
|
+
const appstackId = typeof body?.appstackId === "string" && body.appstackId.trim() || typeof body?.id === "string" && body.id.trim() || "";
|
|
110
|
+
const topologyId = typeof body?.uuid === "string" && body.uuid.trim() || typeof body?.topologyId === "string" && body.topologyId.trim() || appstackId || "";
|
|
111
|
+
return { appstackId, topologyId };
|
|
112
|
+
}
|
|
113
|
+
const DEFAULT_PROJECT_MEMBER_ROLE_ID = "00000000-0000-0000-0000-000000000002";
|
|
114
|
+
function validateTeamId(teamId, allowedTeams) {
|
|
115
|
+
if (allowedTeams.length === 0) return;
|
|
116
|
+
if (teamId && !allowedTeams.includes(teamId)) {
|
|
117
|
+
throw new Error(`Team ID ${teamId} is not allowed.`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
function walkResources(resources, visit) {
|
|
121
|
+
for (const resource of resources) {
|
|
122
|
+
visit(resource);
|
|
123
|
+
if (Array.isArray(resource?.children) && resource.children.length > 0) {
|
|
124
|
+
walkResources(resource.children, visit);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function countField(resources, field) {
|
|
129
|
+
let count = 0;
|
|
130
|
+
walkResources(resources, (resource) => {
|
|
131
|
+
if (resource && resource[field] !== void 0) {
|
|
132
|
+
count += 1;
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
return count;
|
|
136
|
+
}
|
|
137
|
+
function countResources(resources) {
|
|
138
|
+
let count = 0;
|
|
139
|
+
walkResources(resources, () => {
|
|
140
|
+
count += 1;
|
|
141
|
+
});
|
|
142
|
+
return count;
|
|
143
|
+
}
|
|
144
|
+
function inferIacType(resourceType) {
|
|
145
|
+
return resourceType.toLowerCase().includes("helm") ? "Helm" : "Terraform";
|
|
146
|
+
}
|
|
147
|
+
function stripTrailingParenthetical(label) {
|
|
148
|
+
const isSpace = (ch) => ch.trim() === "";
|
|
149
|
+
let end = label.length;
|
|
150
|
+
while (end > 0 && isSpace(label.charAt(end - 1))) {
|
|
151
|
+
end -= 1;
|
|
152
|
+
}
|
|
153
|
+
if (end === 0 || label.charAt(end - 1) !== ")") {
|
|
154
|
+
return label.trim();
|
|
155
|
+
}
|
|
156
|
+
const close = end - 1;
|
|
157
|
+
const open = label.lastIndexOf("(", close);
|
|
158
|
+
if (open < 0 || label.slice(open + 1, close).includes(")")) {
|
|
159
|
+
return label.trim();
|
|
160
|
+
}
|
|
161
|
+
let start = open;
|
|
162
|
+
while (start > 0 && isSpace(label.charAt(start - 1))) {
|
|
163
|
+
start -= 1;
|
|
164
|
+
}
|
|
165
|
+
return `${label.slice(0, start)}${label.slice(end)}`.trim();
|
|
166
|
+
}
|
|
167
|
+
function normalizeIacType(iacType, resourceType) {
|
|
168
|
+
if (typeof iacType === "string") {
|
|
169
|
+
const normalized = iacType.trim().toLowerCase();
|
|
170
|
+
if (normalized === "helm") return "Helm";
|
|
171
|
+
if (normalized === "terraform") return "Terraform";
|
|
172
|
+
}
|
|
173
|
+
return inferIacType(resourceType);
|
|
174
|
+
}
|
|
175
|
+
function resolveResourceName(resource, path) {
|
|
176
|
+
const explicitName = typeof resource?.name === "string" ? resource.name.trim() : "";
|
|
177
|
+
if (explicitName) return explicitName;
|
|
178
|
+
const configName = typeof resource?.configuration?.name === "string" ? resource.configuration.name.trim() : "";
|
|
179
|
+
if (configName) return configName;
|
|
180
|
+
return `${resource.resourceType}-${path}`;
|
|
181
|
+
}
|
|
182
|
+
function legacyNormalizeResources(resources, parentPath = "") {
|
|
183
|
+
return resources.map((res, index) => {
|
|
184
|
+
const resourcePath = parentPath ? `${parentPath}-${index}` : `${index}`;
|
|
185
|
+
const looksLikeModuleSelect = res && typeof res === "object" && typeof res.id === "string" && res.id && !res.resourceType && !res.templateId && !res.resourcePackId;
|
|
186
|
+
const source = looksLikeModuleSelect ? {
|
|
187
|
+
resourceType: "vibe-coded-module",
|
|
188
|
+
iacType: "Terraform",
|
|
189
|
+
templateId: res.id,
|
|
190
|
+
name: typeof res.label === "string" && res.label.trim() ? stripTrailingParenthetical(res.label) : res.moduleName || res.id
|
|
191
|
+
} : res;
|
|
192
|
+
const { children, ...rest } = source;
|
|
193
|
+
const normalized = {
|
|
194
|
+
...rest,
|
|
195
|
+
iacType: normalizeIacType(rest.iacType, rest.resourceType),
|
|
196
|
+
name: resolveResourceName(rest, resourcePath)
|
|
197
|
+
};
|
|
198
|
+
if (Array.isArray(children) && children.length > 0) {
|
|
199
|
+
normalized.children = legacyNormalizeResources(children, resourcePath);
|
|
200
|
+
}
|
|
201
|
+
return normalized;
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
function validateExpectedJsonResponse(responseText, endpoint) {
|
|
205
|
+
const trimmed = responseText.trim();
|
|
206
|
+
if (!trimmed) {
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const lowered = trimmed.toLowerCase();
|
|
210
|
+
if (lowered === "/login" || lowered.startsWith("<!doctype html") || lowered.startsWith("<html")) {
|
|
211
|
+
throw new Error(
|
|
212
|
+
`Received login response from ${endpoint}. Check configured base URL and API token permissions.`
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
if (!(trimmed.startsWith("{") || trimmed.startsWith("["))) {
|
|
216
|
+
throw new Error(
|
|
217
|
+
`Unexpected non-JSON success response from ${endpoint}: ${trimmed.slice(0, 120)}`
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
function formatRequestFailure(res) {
|
|
222
|
+
return apiObjects.formatApiFailure(res.status, res.parsed, res.text, res.requestId);
|
|
223
|
+
}
|
|
224
|
+
async function detailFromThrownError(error) {
|
|
225
|
+
if (error?.response) {
|
|
226
|
+
return apiObjects.formatClientErrorMessage(error);
|
|
227
|
+
}
|
|
228
|
+
if (error instanceof Error) {
|
|
229
|
+
return error.message;
|
|
230
|
+
}
|
|
231
|
+
return String(error);
|
|
232
|
+
}
|
|
233
|
+
async function cloneTopologyFromTemplateAppstack(params) {
|
|
234
|
+
const {
|
|
235
|
+
stackgenApis,
|
|
236
|
+
orgId,
|
|
237
|
+
newAppstackId,
|
|
238
|
+
templateAppstackId,
|
|
239
|
+
cloudProvider,
|
|
240
|
+
deploymentType,
|
|
241
|
+
iacType,
|
|
242
|
+
traceId,
|
|
243
|
+
logger
|
|
244
|
+
} = params;
|
|
245
|
+
const body = {
|
|
246
|
+
appstackId: newAppstackId,
|
|
247
|
+
appstackRefId: templateAppstackId,
|
|
248
|
+
cloudProvider,
|
|
249
|
+
deploymentType,
|
|
250
|
+
iacType
|
|
251
|
+
};
|
|
252
|
+
logger.info(
|
|
253
|
+
`[${traceId}] cloning topology from template appstack ${templateAppstackId} onto ${newAppstackId}`
|
|
254
|
+
);
|
|
255
|
+
const res = await apiObjects.executeApiRequest(
|
|
256
|
+
() => stackgenApis.v1TopologyApiObj.createTopology({
|
|
257
|
+
orgId,
|
|
258
|
+
newTopologyRequest: body
|
|
259
|
+
}),
|
|
260
|
+
201
|
|
261
|
+
);
|
|
262
|
+
if (!res.ok) {
|
|
263
|
+
throw new Error(
|
|
264
|
+
`Failed to clone topology from template AppStack: ${formatRequestFailure(res)}`
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
validateExpectedJsonResponse(
|
|
268
|
+
res.text,
|
|
269
|
+
"topology.createTopologyRaw"
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
function pickTopologyIacTypeFromTargetIac(targetIac) {
|
|
273
|
+
if (!Array.isArray(targetIac)) {
|
|
274
|
+
return void 0;
|
|
275
|
+
}
|
|
276
|
+
for (const entry of targetIac) {
|
|
277
|
+
if (entry === "Helm" || entry === "Terraform") {
|
|
278
|
+
return entry;
|
|
279
|
+
}
|
|
280
|
+
if (typeof entry === "string") {
|
|
281
|
+
const n = entry.trim();
|
|
282
|
+
if (n === "Helm" || n === "Terraform") {
|
|
283
|
+
return n;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return void 0;
|
|
288
|
+
}
|
|
289
|
+
async function fetchTemplateAppStackSummary(params) {
|
|
290
|
+
const { stackgenApis, templateUuid, traceId, logger } = params;
|
|
291
|
+
const pageSize = 100;
|
|
292
|
+
let offset = 0;
|
|
293
|
+
for (; ; ) {
|
|
294
|
+
logger.info(
|
|
295
|
+
`[${traceId}] resolving template AppStack ${templateUuid} from tenant template list`
|
|
296
|
+
);
|
|
297
|
+
const pageOffset = offset;
|
|
298
|
+
const res = await apiObjects.executeApiRequest(
|
|
299
|
+
() => stackgenApis.appStacksApiObj.getTemplateAppstacks({
|
|
300
|
+
limit: pageSize,
|
|
301
|
+
offset: pageOffset
|
|
302
|
+
})
|
|
303
|
+
);
|
|
304
|
+
if (!res.ok) {
|
|
305
|
+
throw new Error(
|
|
306
|
+
`Failed to list template AppStacks from appcd: ${formatRequestFailure(res)}`
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
validateExpectedJsonResponse(res.text, "appStacksApiObj.getTemplateAppstacks");
|
|
310
|
+
const rows = Array.isArray(res.parsed?.appstacks) ? res.parsed.appstacks : [];
|
|
311
|
+
const match = rows.find(
|
|
312
|
+
(row) => String(row?.appstackId || "").trim() === templateUuid
|
|
313
|
+
);
|
|
314
|
+
if (match) {
|
|
315
|
+
const cloudProvider = typeof match.cloudProvider === "string" ? match.cloudProvider.trim() : void 0;
|
|
316
|
+
const owner = typeof match.owner === "string" ? match.owner.trim() : void 0;
|
|
317
|
+
const name = typeof match.name === "string" ? match.name.trim() : void 0;
|
|
318
|
+
return {
|
|
319
|
+
appstackId: templateUuid,
|
|
320
|
+
owner: owner || void 0,
|
|
321
|
+
cloudProvider: cloudProvider || void 0,
|
|
322
|
+
name: name || void 0
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
if (rows.length < pageSize) {
|
|
326
|
+
return void 0;
|
|
327
|
+
}
|
|
328
|
+
offset += pageSize;
|
|
329
|
+
if (offset > 1e4) {
|
|
330
|
+
logger.warn(
|
|
331
|
+
`[${traceId}] stopped paging template-appstacks after offset ${offset} without finding ${templateUuid}`
|
|
332
|
+
);
|
|
333
|
+
return void 0;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
async function fetchAppStackSnapshotForTemplateClone(params) {
|
|
338
|
+
const {
|
|
339
|
+
stackgenApis,
|
|
340
|
+
orgId,
|
|
341
|
+
templateUuid,
|
|
342
|
+
traceId,
|
|
343
|
+
logger
|
|
344
|
+
} = params;
|
|
345
|
+
logger.info(
|
|
346
|
+
`[${traceId}] loading template AppStack for clone defaults`
|
|
347
|
+
);
|
|
348
|
+
const res = await apiObjects.executeApiRequest(
|
|
349
|
+
() => stackgenApis.appStacksApiObj.getAppStack({
|
|
350
|
+
uuid: templateUuid,
|
|
351
|
+
orgId
|
|
352
|
+
})
|
|
353
|
+
);
|
|
354
|
+
if (!res.ok) {
|
|
355
|
+
logger.warn(
|
|
356
|
+
`[${traceId}] could not load AppStack ${templateUuid} via appcd GET (${formatRequestFailure(res)}); using tenant template summary / defaults for clone`
|
|
357
|
+
);
|
|
358
|
+
return void 0;
|
|
359
|
+
}
|
|
360
|
+
validateExpectedJsonResponse(res.text, "appStacksApiObj.getAppStack");
|
|
361
|
+
const cc = res.parsed?.coreConfig;
|
|
362
|
+
return {
|
|
363
|
+
provider: typeof cc?.provider === "string" ? cc.provider.trim() : void 0,
|
|
364
|
+
targetCompute: typeof cc?.targetCompute === "string" ? cc.targetCompute.trim() : void 0,
|
|
365
|
+
targetIac: cc?.targetIac
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
function isAlreadyMemberResponse(status, parsedBody, responseText) {
|
|
369
|
+
const lowered = `${parsedBody?.msg || ""} ${parsedBody?.message || ""} ${parsedBody?.error || ""} ${responseText || ""}`.toLowerCase();
|
|
370
|
+
if (status === 409 && (lowered.includes("already") || lowered.includes("exists"))) {
|
|
371
|
+
return true;
|
|
372
|
+
}
|
|
373
|
+
return lowered.includes("already member") || lowered.includes("already exists");
|
|
374
|
+
}
|
|
375
|
+
async function ensureProjectMembershipForAppstackCreate(params) {
|
|
376
|
+
const { stackgenApis, projectId, roleId, traceId, logger } = params;
|
|
377
|
+
logger.info(`[${traceId}] resolving PAT user via appcd auth`);
|
|
378
|
+
const meResponse = await apiObjects.executeApiRequest(
|
|
379
|
+
() => stackgenApis.authenticationApiObject.getMe()
|
|
380
|
+
);
|
|
381
|
+
if (!meResponse.ok) {
|
|
382
|
+
throw new Error(
|
|
383
|
+
`Failed to fetch PAT user from auth/me: ${formatRequestFailure(meResponse)}`
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
validateExpectedJsonResponse(meResponse.text, "authenticationApiObject.getMe");
|
|
387
|
+
const userId = String(meResponse.parsed?.userId || "").trim();
|
|
388
|
+
if (!userId) {
|
|
389
|
+
throw new Error(
|
|
390
|
+
"auth/me response did not include userId; cannot assign project membership for appstack creation"
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
const addUserPayload = {
|
|
394
|
+
userAssignments: [{ userId, roleId }]
|
|
395
|
+
};
|
|
396
|
+
logger.info(
|
|
397
|
+
`[${traceId}] assigning PAT user ${userId} to project ${projectId} with role ${roleId}`
|
|
398
|
+
);
|
|
399
|
+
const addUserResponse = await apiObjects.executeApiRequest(
|
|
400
|
+
() => stackgenApis.projectApiObject.assignUsersToOrg({
|
|
401
|
+
orgId: projectId,
|
|
402
|
+
assignUsersToOrgRequest: addUserPayload
|
|
403
|
+
}),
|
|
404
|
+
201
|
|
405
|
+
);
|
|
406
|
+
if (!addUserResponse.ok) {
|
|
407
|
+
if (isAlreadyMemberResponse(
|
|
408
|
+
addUserResponse.status,
|
|
409
|
+
addUserResponse.parsed,
|
|
410
|
+
addUserResponse.text
|
|
411
|
+
)) {
|
|
412
|
+
logger.info(
|
|
413
|
+
`[${traceId}] PAT user ${userId} is already assigned to project ${projectId}, continuing`
|
|
414
|
+
);
|
|
415
|
+
return {
|
|
416
|
+
userId,
|
|
417
|
+
addedToProject: false
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
throw new Error(
|
|
421
|
+
`Failed to assign PAT user to project before appstack creation: ${formatRequestFailure(addUserResponse)}`
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
validateExpectedJsonResponse(
|
|
425
|
+
addUserResponse.text,
|
|
426
|
+
"projectApiObject.assignUsersToOrg"
|
|
427
|
+
);
|
|
428
|
+
return {
|
|
429
|
+
userId,
|
|
430
|
+
addedToProject: true
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
function collectResourcePackResources(resources) {
|
|
434
|
+
const collected = [];
|
|
435
|
+
walkResources(resources, (resource) => {
|
|
436
|
+
if (resource?.resourcePackId) {
|
|
437
|
+
collected.push(resource);
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
return collected;
|
|
441
|
+
}
|
|
442
|
+
function buildAppStackUrl(baseUrl, appstackId, projectName) {
|
|
443
|
+
const cleanBaseUrl = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
|
|
444
|
+
if (!appstackId) {
|
|
445
|
+
return `${cleanBaseUrl}/appstacks`;
|
|
446
|
+
}
|
|
447
|
+
if (projectName) {
|
|
448
|
+
return `${cleanBaseUrl}/project/${encodeURIComponent(
|
|
449
|
+
projectName
|
|
450
|
+
)}/appstacks/${encodeURIComponent(appstackId)}?tabId=topology`;
|
|
451
|
+
}
|
|
452
|
+
return `${cleanBaseUrl}/appstacks/${encodeURIComponent(appstackId)}`;
|
|
453
|
+
}
|
|
454
|
+
async function resolveTeamNameForUrl(stackgenApis, teamId, logger, traceId) {
|
|
455
|
+
try {
|
|
456
|
+
const response = await apiObjects.executeApiRequest(
|
|
457
|
+
() => stackgenApis.integrationsTeamApiObj.listTeams({ includeAll: true })
|
|
458
|
+
);
|
|
459
|
+
if (!response.ok) {
|
|
460
|
+
logger.warn(
|
|
461
|
+
`[${traceId}] failed to resolve team name for appStack URL (status=${response.status})`
|
|
462
|
+
);
|
|
463
|
+
return void 0;
|
|
464
|
+
}
|
|
465
|
+
validateExpectedJsonResponse(response.text, "integrationsTeamApiObj.listTeams");
|
|
466
|
+
let teams = [];
|
|
467
|
+
if (Array.isArray(response.parsed)) {
|
|
468
|
+
teams = response.parsed;
|
|
469
|
+
} else if (Array.isArray(response.parsed?.items)) {
|
|
470
|
+
teams = response.parsed.items;
|
|
471
|
+
}
|
|
472
|
+
const matchedTeam = teams.find(
|
|
473
|
+
(team) => (team?.id || team?.teamId || "").trim() === teamId
|
|
474
|
+
);
|
|
475
|
+
let teamName;
|
|
476
|
+
if (matchedTeam) {
|
|
477
|
+
teamName = matchedTeam?.name?.trim() || matchedTeam?.teamName?.trim() || matchedTeam?.displayName?.trim();
|
|
478
|
+
}
|
|
479
|
+
return teamName || void 0;
|
|
480
|
+
} catch (error) {
|
|
481
|
+
logger.warn(
|
|
482
|
+
`[${traceId}] unable to resolve team name for appStack URL: ${await detailFromThrownError(error)}`
|
|
483
|
+
);
|
|
484
|
+
return void 0;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
const createAppStackAction = (config, logger) => {
|
|
488
|
+
return pluginScaffolderNode.createTemplateAction({
|
|
489
|
+
id: "stackGen:createAppStack",
|
|
490
|
+
description: "Creates an AppStack in StackGen for the provided configuration",
|
|
491
|
+
schema: {
|
|
492
|
+
// Backstage parseSchemas only accepts (z) => ZodType or per-field callbacks.
|
|
493
|
+
// Raw ZodObject yields schema.input/output === undefined (Installed actions blank).
|
|
494
|
+
input: () => createAppstackInputSchema,
|
|
495
|
+
output: () => appstackOutputSchema
|
|
496
|
+
},
|
|
497
|
+
async handler(ctx) {
|
|
498
|
+
let membershipPreflightWarning;
|
|
499
|
+
const traceId = `create-appstack-${Date.now()}`;
|
|
500
|
+
try {
|
|
501
|
+
const allowedTeams = config.getOptionalString("stackGen.allowedTeams")?.split(",").map((id) => id.trim()) || [];
|
|
502
|
+
validateTeamId(ctx.input.appstack.teamId, allowedTeams);
|
|
503
|
+
const stackgenApis = apiObjects.getStackgenApiObjects(config);
|
|
504
|
+
const { appstack } = ctx.input;
|
|
505
|
+
const resolvedAppstackName = appstack.name?.trim() || appstack.appstackName?.trim() || "";
|
|
506
|
+
const incomingResources = appstack.resources ?? [];
|
|
507
|
+
const projectMemberRoleId = config.getOptionalString("stackGen.projectMemberRoleId")?.trim() || DEFAULT_PROJECT_MEMBER_ROLE_ID;
|
|
508
|
+
if (!resolvedAppstackName) {
|
|
509
|
+
ctx.logger.error(
|
|
510
|
+
`[${traceId}] Invalid appstack input: both name and appstackName are empty`
|
|
511
|
+
);
|
|
512
|
+
throw new Error("appstack name is required and cannot be empty");
|
|
513
|
+
}
|
|
514
|
+
const cleanBaseUrl = stackgenApis.baseUrl;
|
|
515
|
+
const sanitizedResources = legacyNormalizeResources(incomingResources);
|
|
516
|
+
const inputResourcePackIdCount = countField(incomingResources, "resourcePackId");
|
|
517
|
+
const outputResourcePackIdCount = countField(sanitizedResources, "resourcePackId");
|
|
518
|
+
const iacTypeCount = countField(sanitizedResources, "iacType");
|
|
519
|
+
const nameCount = countField(sanitizedResources, "name");
|
|
520
|
+
const templateIdCount = countField(sanitizedResources, "templateId");
|
|
521
|
+
const totalResourceCount = countResources(sanitizedResources);
|
|
522
|
+
const templateUuidForClone = appstack.templateAppstackId?.trim();
|
|
523
|
+
const orgScopeForAppcd = config.getOptionalString("stackGen.orgId")?.trim() || appstack.teamId;
|
|
524
|
+
let templateSummary;
|
|
525
|
+
let templateSnapshot;
|
|
526
|
+
if (templateUuidForClone) {
|
|
527
|
+
templateSummary = await fetchTemplateAppStackSummary({
|
|
528
|
+
stackgenApis,
|
|
529
|
+
templateUuid: templateUuidForClone,
|
|
530
|
+
traceId,
|
|
531
|
+
logger: ctx.logger
|
|
532
|
+
});
|
|
533
|
+
if (!templateSummary) {
|
|
534
|
+
ctx.logger.warn(
|
|
535
|
+
`[${traceId}] template AppStack ${templateUuidForClone} was not found in tenant template-appstacks; will try appcd GET with destination/org scope`
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
const orgIdForTemplateGet = templateSummary?.owner || orgScopeForAppcd;
|
|
539
|
+
templateSnapshot = await fetchAppStackSnapshotForTemplateClone({
|
|
540
|
+
stackgenApis,
|
|
541
|
+
orgId: orgIdForTemplateGet,
|
|
542
|
+
templateUuid: templateUuidForClone,
|
|
543
|
+
traceId,
|
|
544
|
+
logger: ctx.logger
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
const effectiveCloudProvider = (appstack.cloudProvider?.trim() || templateSnapshot?.provider || templateSummary?.cloudProvider || "").trim();
|
|
548
|
+
if (!effectiveCloudProvider) {
|
|
549
|
+
throw new Error(
|
|
550
|
+
templateUuidForClone ? `Could not determine cloudProvider for template AppStack ${templateUuidForClone}. Pass appstack.cloudProvider, or ensure the UUID is a tenant template (template-appstacks) with cloudProvider set.` : "appstack.cloudProvider is required when templateAppstackId is not set"
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
const deploymentTypeForTopologyClone = templateSnapshot?.targetCompute?.trim() || DEFAULT_TOPOLOGY_DEPLOYMENT_TYPE;
|
|
554
|
+
const topologyIacTypeForClone = pickTopologyIacTypeFromTargetIac(templateSnapshot?.targetIac) ?? "Terraform";
|
|
555
|
+
ctx.logger.info(
|
|
556
|
+
`[${traceId}] createAppStack input summary: ${JSON.stringify({
|
|
557
|
+
teamId: appstack.teamId,
|
|
558
|
+
cloudProviderInput: appstack.cloudProvider?.trim() || void 0,
|
|
559
|
+
effectiveCloudProvider,
|
|
560
|
+
resolvedAppstackName,
|
|
561
|
+
totalResourceCount,
|
|
562
|
+
inputResourcePackIdCount,
|
|
563
|
+
outputResourcePackIdCount,
|
|
564
|
+
iacTypeCount,
|
|
565
|
+
nameCount,
|
|
566
|
+
templateAppstackId: templateUuidForClone || void 0,
|
|
567
|
+
deploymentTypeForTopologyClone,
|
|
568
|
+
topologyIacTypeForClone,
|
|
569
|
+
appcdBaseUrl: cleanBaseUrl,
|
|
570
|
+
roleId: projectMemberRoleId
|
|
571
|
+
})}`
|
|
572
|
+
);
|
|
573
|
+
try {
|
|
574
|
+
const membershipResult = await ensureProjectMembershipForAppstackCreate({
|
|
575
|
+
stackgenApis,
|
|
576
|
+
projectId: appstack.teamId,
|
|
577
|
+
roleId: projectMemberRoleId,
|
|
578
|
+
traceId,
|
|
579
|
+
logger
|
|
580
|
+
});
|
|
581
|
+
if (membershipResult.addedToProject) {
|
|
582
|
+
logger.info(
|
|
583
|
+
`[${traceId}] PAT user ${membershipResult.userId} was added to project/team ${appstack.teamId} and remains a member`
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
logger.info(
|
|
587
|
+
`[${traceId}] verified PAT user membership for project/team ${appstack.teamId} before appstack creation`
|
|
588
|
+
);
|
|
589
|
+
} catch (membershipError) {
|
|
590
|
+
membershipPreflightWarning = String(membershipError);
|
|
591
|
+
logger.warn(
|
|
592
|
+
`[${traceId}] membership preflight failed, proceeding with appstack creation: ${membershipPreflightWarning}`
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
const hasResourcePackInput = inputResourcePackIdCount > 0;
|
|
596
|
+
const hasCustomModuleInput = templateIdCount > 0;
|
|
597
|
+
if (hasResourcePackInput || hasCustomModuleInput) {
|
|
598
|
+
const blankCreateUrl = `${cleanBaseUrl}/integrations/api/v1/projects/${appstack.teamId}/appstacks`;
|
|
599
|
+
const blankCreatePayload = {
|
|
600
|
+
name: resolvedAppstackName,
|
|
601
|
+
cloudProvider: effectiveCloudProvider,
|
|
602
|
+
topology: {
|
|
603
|
+
source: { name: "backstage" },
|
|
604
|
+
resources: [],
|
|
605
|
+
resourceConnections: []
|
|
606
|
+
}
|
|
607
|
+
};
|
|
608
|
+
logger.info(
|
|
609
|
+
`[${traceId}] two-step topology flow enabled (resourcePacks=${inputResourcePackIdCount}, customModules=${templateIdCount}), creating blank appStack at ${blankCreateUrl}`
|
|
610
|
+
);
|
|
611
|
+
const blankCreateResponse = await apiObjects.executeApiRequest(
|
|
612
|
+
() => stackgenApis.integrationsAppstackApiObj.createProjectAppStack({
|
|
613
|
+
projectId: appstack.teamId,
|
|
614
|
+
appStackRequest: blankCreatePayload
|
|
615
|
+
}),
|
|
616
|
+
201
|
|
617
|
+
);
|
|
618
|
+
const blankCreateBody = blankCreateResponse.parsed;
|
|
619
|
+
const blankCreateText = blankCreateResponse.text;
|
|
620
|
+
if (!blankCreateResponse.ok) {
|
|
621
|
+
const errorMessage = formatRequestFailure(blankCreateResponse);
|
|
622
|
+
const isLikelyExistsError = blankCreateResponse.status === 409 || blankCreateResponse.status === 400;
|
|
623
|
+
if (isLikelyExistsError) {
|
|
624
|
+
try {
|
|
625
|
+
const listResponse = await apiObjects.executeApiRequest(
|
|
626
|
+
() => stackgenApis.integrationsAppstackApiObj.listProjectAppStacks({
|
|
627
|
+
projectId: appstack.teamId,
|
|
628
|
+
name: resolvedAppstackName
|
|
629
|
+
})
|
|
630
|
+
);
|
|
631
|
+
if (listResponse.ok) {
|
|
632
|
+
const listBody = listResponse.parsed;
|
|
633
|
+
ctx.logger.info(`[${traceId}] Existing AppStack lookup response body: ${JSON.stringify(listBody)}`);
|
|
634
|
+
let appstacks = [];
|
|
635
|
+
if (Array.isArray(listBody)) {
|
|
636
|
+
appstacks = listBody;
|
|
637
|
+
} else if (Array.isArray(listBody?.items)) {
|
|
638
|
+
appstacks = listBody.items;
|
|
639
|
+
}
|
|
640
|
+
ctx.logger.info(`[${traceId}] Found ${appstacks.length} items in appstack list`);
|
|
641
|
+
const exactMatch = appstacks.find((candidate) => {
|
|
642
|
+
const candidateName = (candidate?.appstackName || candidate?.name || "").trim();
|
|
643
|
+
const match = candidateName === resolvedAppstackName;
|
|
644
|
+
if (!match) {
|
|
645
|
+
ctx.logger.info(`[${traceId}] candidate '${candidateName}' != '${resolvedAppstackName}'`);
|
|
646
|
+
}
|
|
647
|
+
return match;
|
|
648
|
+
});
|
|
649
|
+
if (exactMatch) {
|
|
650
|
+
ctx.logger.info(`[${traceId}] Found exact match for existing appstack: ${exactMatch.appstackId}`);
|
|
651
|
+
const existingAppstackId = exactMatch.appstackId || exactMatch.id;
|
|
652
|
+
const teamNameForUrl3 = await resolveTeamNameForUrl(
|
|
653
|
+
stackgenApis,
|
|
654
|
+
appstack.teamId,
|
|
655
|
+
logger,
|
|
656
|
+
traceId
|
|
657
|
+
);
|
|
658
|
+
const appStackUrl = buildAppStackUrl(
|
|
659
|
+
cleanBaseUrl,
|
|
660
|
+
existingAppstackId,
|
|
661
|
+
teamNameForUrl3
|
|
662
|
+
);
|
|
663
|
+
throw new Error(
|
|
664
|
+
`AppStack '${resolvedAppstackName}' already exists. View it here: ${appStackUrl}`
|
|
665
|
+
);
|
|
666
|
+
} else {
|
|
667
|
+
ctx.logger.warn(`[${traceId}] No exact match found for '${resolvedAppstackName}' in ${appstacks.length} items`);
|
|
668
|
+
}
|
|
669
|
+
} else {
|
|
670
|
+
ctx.logger.warn(`[${traceId}] List appstacks failed with status ${listResponse.status}`);
|
|
671
|
+
}
|
|
672
|
+
} catch (resolveError) {
|
|
673
|
+
const errorString = String(resolveError);
|
|
674
|
+
if (errorString.includes("already exists. View it here")) {
|
|
675
|
+
throw resolveError;
|
|
676
|
+
}
|
|
677
|
+
const resolveDetail = await detailFromThrownError(resolveError);
|
|
678
|
+
ctx.logger.warn(
|
|
679
|
+
`[${traceId}] failed to resolve existing appstack for error message: ${resolveDetail}`
|
|
680
|
+
);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
let membershipHint = "";
|
|
684
|
+
if (blankCreateResponse.status === 500 && String(blankCreateBody?.errCode || "").toUpperCase() === "CREATE_APPSTACK_ERROR") {
|
|
685
|
+
const visibleTeamName = await resolveTeamNameForUrl(
|
|
686
|
+
stackgenApis,
|
|
687
|
+
appstack.teamId,
|
|
688
|
+
logger,
|
|
689
|
+
traceId
|
|
690
|
+
);
|
|
691
|
+
if (!visibleTeamName) {
|
|
692
|
+
membershipHint = " The provided team/project is not visible to this PAT. Ensure the PAT user is added to the project/team before creating appstacks in it.";
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
throw new Error(
|
|
696
|
+
`Failed to create blank appstack: ${errorMessage}.${membershipHint}`
|
|
697
|
+
);
|
|
698
|
+
} else {
|
|
699
|
+
validateExpectedJsonResponse(blankCreateText, blankCreateUrl);
|
|
700
|
+
}
|
|
701
|
+
const fromBlank = readIntegrationsAppstackIds(blankCreateBody);
|
|
702
|
+
let resolvedAppstackId = fromBlank.appstackId;
|
|
703
|
+
let resolvedTopologyId = fromBlank.topologyId;
|
|
704
|
+
if (!resolvedAppstackId) {
|
|
705
|
+
const listUrl = `${cleanBaseUrl}/integrations/api/v1/projects/${appstack.teamId}/appstacks?name=${encodeURIComponent(
|
|
706
|
+
resolvedAppstackName
|
|
707
|
+
)}`;
|
|
708
|
+
logger.info(
|
|
709
|
+
`[${traceId}] resolving appstack ID by list API ${listUrl}`
|
|
710
|
+
);
|
|
711
|
+
const listResponse = await apiObjects.executeApiRequest(
|
|
712
|
+
() => stackgenApis.integrationsAppstackApiObj.listProjectAppStacks({
|
|
713
|
+
projectId: appstack.teamId,
|
|
714
|
+
name: resolvedAppstackName
|
|
715
|
+
})
|
|
716
|
+
);
|
|
717
|
+
const listBody = listResponse.parsed;
|
|
718
|
+
const listText = listResponse.text;
|
|
719
|
+
if (!listResponse.ok) {
|
|
720
|
+
throw new Error(
|
|
721
|
+
`Failed to resolve appstack ID: ${formatRequestFailure(listResponse)}`
|
|
722
|
+
);
|
|
723
|
+
}
|
|
724
|
+
validateExpectedJsonResponse(listText, listUrl);
|
|
725
|
+
let appstacks = [];
|
|
726
|
+
if (Array.isArray(listBody)) {
|
|
727
|
+
appstacks = listBody;
|
|
728
|
+
} else if (Array.isArray(listBody?.items)) {
|
|
729
|
+
appstacks = listBody.items;
|
|
730
|
+
}
|
|
731
|
+
const exactMatch = appstacks.find((candidate) => {
|
|
732
|
+
const candidateName = (candidate?.appstackName || candidate?.name || "").trim();
|
|
733
|
+
return candidateName === resolvedAppstackName;
|
|
734
|
+
});
|
|
735
|
+
const fromList = readIntegrationsAppstackIds(exactMatch);
|
|
736
|
+
resolvedAppstackId = fromList.appstackId;
|
|
737
|
+
resolvedTopologyId = fromList.topologyId || resolvedTopologyId;
|
|
738
|
+
}
|
|
739
|
+
if (!resolvedAppstackId) {
|
|
740
|
+
throw new Error(
|
|
741
|
+
`Failed to resolve appstack ID for '${resolvedAppstackName}' after blank create`
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
if (!resolvedTopologyId) {
|
|
745
|
+
resolvedTopologyId = resolvedAppstackId;
|
|
746
|
+
}
|
|
747
|
+
const resourcePackResources = collectResourcePackResources(
|
|
748
|
+
sanitizedResources
|
|
749
|
+
);
|
|
750
|
+
if (resourcePackResources.length === 0 && hasResourcePackInput) {
|
|
751
|
+
throw new Error(
|
|
752
|
+
"resource pack flow was selected but no resourcePackId was found after normalization"
|
|
753
|
+
);
|
|
754
|
+
}
|
|
755
|
+
const orgScope = config.getOptionalString("stackGen.orgId")?.trim() || appstack.teamId;
|
|
756
|
+
for (const resource of resourcePackResources) {
|
|
757
|
+
const createResourceUrl = `${cleanBaseUrl}/iac-gen/v1/topologies/${encodeURIComponent(
|
|
758
|
+
resolvedTopologyId
|
|
759
|
+
)}/resources${orgScope ? `?orgId=${encodeURIComponent(orgScope)}` : ""}`;
|
|
760
|
+
const createResourcePayload = {
|
|
761
|
+
resourceType: resource.resourceType || "resourcePack",
|
|
762
|
+
name: resource.name,
|
|
763
|
+
iacType: resource.iacType || "Terraform",
|
|
764
|
+
resourcePackId: resource.resourcePackId
|
|
765
|
+
};
|
|
766
|
+
logger.info(
|
|
767
|
+
`[${traceId}] adding resource pack to topology ${resolvedTopologyId} via ${createResourceUrl}`
|
|
768
|
+
);
|
|
769
|
+
const createResourceResponse = await apiObjects.executeApiRequest(
|
|
770
|
+
() => stackgenApis.v1ResourceApiObj.createResource({
|
|
771
|
+
topologyId: resolvedTopologyId,
|
|
772
|
+
orgId: orgScope || void 0,
|
|
773
|
+
topologyResource: createResourcePayload
|
|
774
|
+
}),
|
|
775
|
+
201
|
|
776
|
+
);
|
|
777
|
+
const createResourceText = createResourceResponse.text;
|
|
778
|
+
if (!createResourceResponse.ok) {
|
|
779
|
+
throw new Error(
|
|
780
|
+
`Failed to add resource pack to topology: ${formatRequestFailure(createResourceResponse)}`
|
|
781
|
+
);
|
|
782
|
+
}
|
|
783
|
+
validateExpectedJsonResponse(createResourceText, createResourceUrl);
|
|
784
|
+
}
|
|
785
|
+
const nonPackResources = sanitizedResources.filter(
|
|
786
|
+
(r) => !r.resourcePackId
|
|
787
|
+
);
|
|
788
|
+
for (const resource of nonPackResources) {
|
|
789
|
+
const addResourceUrl = `${cleanBaseUrl}/iac-gen/v1/topologies/${encodeURIComponent(
|
|
790
|
+
resolvedTopologyId
|
|
791
|
+
)}/resources${orgScope ? `?orgId=${encodeURIComponent(orgScope)}` : ""}`;
|
|
792
|
+
const addResourcePayload = {
|
|
793
|
+
resourceType: resource.resourceType,
|
|
794
|
+
name: resource.name,
|
|
795
|
+
iacType: resource.iacType || "Terraform"
|
|
796
|
+
};
|
|
797
|
+
if (resource.templateId) {
|
|
798
|
+
addResourcePayload.templateId = resource.templateId;
|
|
799
|
+
}
|
|
800
|
+
if (resource.configuration) {
|
|
801
|
+
addResourcePayload.configuration = resource.configuration;
|
|
802
|
+
}
|
|
803
|
+
logger.info(
|
|
804
|
+
`[${traceId}] adding non-pack resource '${resource.resourceType}' (name=${resource.name}) to topology ${resolvedTopologyId}`
|
|
805
|
+
);
|
|
806
|
+
const addResourceResponse = await apiObjects.executeApiRequest(
|
|
807
|
+
() => stackgenApis.v1ResourceApiObj.createResource({
|
|
808
|
+
topologyId: resolvedTopologyId,
|
|
809
|
+
orgId: orgScope || void 0,
|
|
810
|
+
topologyResource: addResourcePayload
|
|
811
|
+
}),
|
|
812
|
+
201
|
|
813
|
+
);
|
|
814
|
+
const addResourceText = addResourceResponse.text;
|
|
815
|
+
if (!addResourceResponse.ok) {
|
|
816
|
+
throw new Error(
|
|
817
|
+
`Failed to add resource '${resource.resourceType}' to topology: ${formatRequestFailure(addResourceResponse)}`
|
|
818
|
+
);
|
|
819
|
+
}
|
|
820
|
+
validateExpectedJsonResponse(addResourceText, addResourceUrl);
|
|
821
|
+
}
|
|
822
|
+
ctx.logger.info(
|
|
823
|
+
`[${traceId}] AppStack created successfully via resource pack flow: ${JSON.stringify({
|
|
824
|
+
appstackId: resolvedAppstackId,
|
|
825
|
+
topologyId: resolvedTopologyId,
|
|
826
|
+
appstackName: resolvedAppstackName,
|
|
827
|
+
resourcePackCount: resourcePackResources.length,
|
|
828
|
+
nonPackResourceCount: nonPackResources.length
|
|
829
|
+
})}`
|
|
830
|
+
);
|
|
831
|
+
ctx.output("appstackId", resolvedAppstackId);
|
|
832
|
+
ctx.output("appstackName", resolvedAppstackName);
|
|
833
|
+
ctx.output("topologyId", resolvedTopologyId);
|
|
834
|
+
const teamNameForUrl2 = await resolveTeamNameForUrl(
|
|
835
|
+
stackgenApis,
|
|
836
|
+
appstack.teamId,
|
|
837
|
+
logger,
|
|
838
|
+
traceId
|
|
839
|
+
);
|
|
840
|
+
ctx.output(
|
|
841
|
+
"appStackURL",
|
|
842
|
+
buildAppStackUrl(cleanBaseUrl, resolvedAppstackId, teamNameForUrl2)
|
|
843
|
+
);
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
const apiPayload = {
|
|
847
|
+
name: resolvedAppstackName,
|
|
848
|
+
cloudProvider: effectiveCloudProvider,
|
|
849
|
+
topology: {
|
|
850
|
+
source: { name: "backstage" },
|
|
851
|
+
resources: sanitizedResources,
|
|
852
|
+
resourceConnections: []
|
|
853
|
+
}
|
|
854
|
+
};
|
|
855
|
+
const requestUrl = `${cleanBaseUrl}/integrations/api/v1/projects/${encodeURIComponent(appstack.teamId)}/appstacks`;
|
|
856
|
+
logger.info(
|
|
857
|
+
`[${traceId}] sending request to create appStack at ${requestUrl}`
|
|
858
|
+
);
|
|
859
|
+
logger.info(
|
|
860
|
+
`[${traceId}] appstack request payload summary: ${JSON.stringify({
|
|
861
|
+
name: apiPayload.name,
|
|
862
|
+
cloudProvider: apiPayload.cloudProvider,
|
|
863
|
+
resourceCount: sanitizedResources.length
|
|
864
|
+
})}`
|
|
865
|
+
);
|
|
866
|
+
const response = await apiObjects.executeApiRequest(
|
|
867
|
+
() => stackgenApis.integrationsAppstackApiObj.createProjectAppStack({
|
|
868
|
+
projectId: appstack.teamId,
|
|
869
|
+
appStackRequest: apiPayload
|
|
870
|
+
}),
|
|
871
|
+
201
|
|
872
|
+
);
|
|
873
|
+
const responseBody = response.parsed;
|
|
874
|
+
const responseText = response.text;
|
|
875
|
+
ctx.logger.info(`[${traceId}] Response status: ${response.status}`);
|
|
876
|
+
if (!response.ok) {
|
|
877
|
+
const errorMessage = formatRequestFailure(response);
|
|
878
|
+
ctx.logger.error(
|
|
879
|
+
`[${traceId}] Error response: ${errorMessage}. Diagnostics: ${JSON.stringify({
|
|
880
|
+
teamId: appstack.teamId,
|
|
881
|
+
appstackName: resolvedAppstackName,
|
|
882
|
+
cloudProvider: effectiveCloudProvider,
|
|
883
|
+
totalResourceCount,
|
|
884
|
+
inputResourcePackIdCount,
|
|
885
|
+
outputResourcePackIdCount,
|
|
886
|
+
iacTypeCount,
|
|
887
|
+
nameCount
|
|
888
|
+
})}`
|
|
889
|
+
);
|
|
890
|
+
throw new Error(
|
|
891
|
+
`Failed to create appstack: ${errorMessage}`
|
|
892
|
+
);
|
|
893
|
+
}
|
|
894
|
+
validateExpectedJsonResponse(responseText, requestUrl);
|
|
895
|
+
const createdIds = readIntegrationsAppstackIds(responseBody);
|
|
896
|
+
if (templateUuidForClone) {
|
|
897
|
+
await cloneTopologyFromTemplateAppstack({
|
|
898
|
+
stackgenApis,
|
|
899
|
+
orgId: orgScopeForAppcd,
|
|
900
|
+
newAppstackId: createdIds.appstackId,
|
|
901
|
+
templateAppstackId: templateUuidForClone,
|
|
902
|
+
cloudProvider: effectiveCloudProvider,
|
|
903
|
+
deploymentType: deploymentTypeForTopologyClone,
|
|
904
|
+
iacType: topologyIacTypeForClone,
|
|
905
|
+
traceId,
|
|
906
|
+
logger: ctx.logger
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
ctx.logger.info(
|
|
910
|
+
`[${traceId}] AppStack created successfully: ${JSON.stringify({
|
|
911
|
+
appstackId: createdIds.appstackId,
|
|
912
|
+
topologyId: createdIds.topologyId,
|
|
913
|
+
appstackName: responseBody.appstackName || resolvedAppstackName,
|
|
914
|
+
clonedFromTemplate: Boolean(templateUuidForClone)
|
|
915
|
+
})}`
|
|
916
|
+
);
|
|
917
|
+
ctx.output("appstackId", createdIds.appstackId);
|
|
918
|
+
ctx.output(
|
|
919
|
+
"appstackName",
|
|
920
|
+
responseBody.appstackName || resolvedAppstackName
|
|
921
|
+
);
|
|
922
|
+
ctx.output("topologyId", createdIds.topologyId);
|
|
923
|
+
const teamNameForUrl = await resolveTeamNameForUrl(
|
|
924
|
+
stackgenApis,
|
|
925
|
+
appstack.teamId,
|
|
926
|
+
logger,
|
|
927
|
+
traceId
|
|
928
|
+
);
|
|
929
|
+
ctx.output(
|
|
930
|
+
"appStackURL",
|
|
931
|
+
buildAppStackUrl(
|
|
932
|
+
cleanBaseUrl,
|
|
933
|
+
createdIds.appstackId,
|
|
934
|
+
teamNameForUrl
|
|
935
|
+
)
|
|
936
|
+
);
|
|
937
|
+
} catch (error) {
|
|
938
|
+
const errorString = await detailFromThrownError(error);
|
|
939
|
+
let errorMessage = `Failed to create appStack: ${errorString}`;
|
|
940
|
+
if (membershipPreflightWarning && !errorString.includes("already exists. View it here")) {
|
|
941
|
+
errorMessage += ` (membership preflight warning: ${membershipPreflightWarning})`;
|
|
942
|
+
}
|
|
943
|
+
ctx.logger.error(errorMessage);
|
|
944
|
+
throw new Error(errorMessage);
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
});
|
|
948
|
+
};
|
|
949
|
+
|
|
950
|
+
exports.appstackOutputSchema = appstackOutputSchema;
|
|
951
|
+
exports.createAppStackAction = createAppStackAction;
|
|
952
|
+
exports.createAppstackInputSchema = createAppstackInputSchema;
|
|
953
|
+
//# sourceMappingURL=create_appstack_action.cjs.js.map
|