create-interview-cockpit 0.23.1 → 0.24.0
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/package.json
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { useState } from "react";
|
|
2
2
|
import { useStore } from "../store";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
AZURE_NETWORK_ACL_BLANK_TEMPLATE,
|
|
5
|
+
AZURE_NETWORK_ACL_DOCKER_LAB,
|
|
6
|
+
DOCKER_DEEP_DIVE_LAB,
|
|
7
|
+
POSTGRES_DOCKER_PROVIDER_LAB,
|
|
8
|
+
parseInfraLabWorkspace,
|
|
9
|
+
} from "../infraLab";
|
|
4
10
|
import {
|
|
5
11
|
DEFAULT_GHA_LAB,
|
|
6
12
|
parseGhaLabWorkspace,
|
|
@@ -641,6 +647,24 @@ export default function LabsPanel() {
|
|
|
641
647
|
"Dockerfile + Compose + Node API + Redis, with command-line practice",
|
|
642
648
|
onClick: () => openInfraLab(DOCKER_DEEP_DIVE_LAB),
|
|
643
649
|
},
|
|
650
|
+
{
|
|
651
|
+
label: "Postgres DB in Docker",
|
|
652
|
+
description:
|
|
653
|
+
"Terraform Docker provider deploys a local PostgreSQL database container",
|
|
654
|
+
onClick: () => openInfraLab(POSTGRES_DOCKER_PROVIDER_LAB),
|
|
655
|
+
},
|
|
656
|
+
{
|
|
657
|
+
label: "Azure ACL Failure (Docker)",
|
|
658
|
+
description:
|
|
659
|
+
"Reproduce stale Azure subnet allowlists locally with Terraform + Docker mocks",
|
|
660
|
+
onClick: () => openInfraLab(AZURE_NETWORK_ACL_DOCKER_LAB),
|
|
661
|
+
},
|
|
662
|
+
{
|
|
663
|
+
label: "Azure ACL Blank Template",
|
|
664
|
+
description:
|
|
665
|
+
"Same Azure-style Terraform/Docker file layout, but every file starts empty",
|
|
666
|
+
onClick: () => openInfraLab(AZURE_NETWORK_ACL_BLANK_TEMPLATE),
|
|
667
|
+
},
|
|
644
668
|
{
|
|
645
669
|
label: "Enterprise BFF Docker Stack",
|
|
646
670
|
description:
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useState, useEffect, useRef, Fragment } from "react";
|
|
2
2
|
import { useStore } from "../store";
|
|
3
3
|
import type { Question } from "../types";
|
|
4
|
+
import type { DriveFolder } from "../api";
|
|
4
5
|
import * as api from "../api";
|
|
5
6
|
import FileAttachments from "./FileAttachments";
|
|
6
7
|
import WorkspaceSwitcher from "./WorkspaceSwitcher";
|
|
@@ -116,6 +117,8 @@ export default function Sidebar() {
|
|
|
116
117
|
driveRootFolders,
|
|
117
118
|
loadingDriveFolders,
|
|
118
119
|
fetchDriveRootFolders,
|
|
120
|
+
fetchDriveSubfolders,
|
|
121
|
+
createDriveSubfolder,
|
|
119
122
|
selectDriveSubfolder,
|
|
120
123
|
clearDriveSubfolder,
|
|
121
124
|
syncWorkspace,
|
|
@@ -214,6 +217,10 @@ export default function Sidebar() {
|
|
|
214
217
|
const isAtDriveFolderRoot = isDriveWs && !currentSubFolder;
|
|
215
218
|
const canSyncDriveFolder =
|
|
216
219
|
!!activeWs?.driveConfig?.folderId && !isAtDriveFolderRoot;
|
|
220
|
+
const canPushLocalDrive =
|
|
221
|
+
activeWs?.type === "local" && !!activeWs.driveConfig?.folderId;
|
|
222
|
+
const canPushDriveDestination =
|
|
223
|
+
!!activeWs?.driveConfig?.folderId && (!isDriveWs || !!currentSubFolder);
|
|
217
224
|
const driveSyncTargetName =
|
|
218
225
|
currentSubFolder?.name ?? activeWs?.driveConfig?.folderName ?? "Drive";
|
|
219
226
|
const [navigating, setNavigating] = useState(false);
|
|
@@ -224,6 +231,15 @@ export default function Sidebar() {
|
|
|
224
231
|
const [topicDriveStatus, setTopicDriveStatus] = useState<
|
|
225
232
|
Record<string, string>
|
|
226
233
|
>({});
|
|
234
|
+
const [pushPicker, setPushPicker] = useState<{
|
|
235
|
+
mode: "workspace" | "topic";
|
|
236
|
+
topicId?: string;
|
|
237
|
+
folders: DriveFolder[];
|
|
238
|
+
loading: boolean;
|
|
239
|
+
} | null>(null);
|
|
240
|
+
const [pushPickerNewFolderName, setPushPickerNewFolderName] = useState("");
|
|
241
|
+
const [pushPickerCreatingFolder, setPushPickerCreatingFolder] =
|
|
242
|
+
useState(false);
|
|
227
243
|
const [wsFilesExpanded, setWsFilesExpanded] = useState(true);
|
|
228
244
|
const [driveFileSyncStatus, setDriveFileSyncStatus] = useState<string | null>(
|
|
229
245
|
null,
|
|
@@ -277,15 +293,12 @@ export default function Sidebar() {
|
|
|
277
293
|
}
|
|
278
294
|
};
|
|
279
295
|
|
|
280
|
-
const
|
|
296
|
+
const pushWorkspaceToDrive = async (targetFolderId: string) => {
|
|
281
297
|
if (!activeWorkspaceId || !activeWs?.driveConfig?.folderId) return;
|
|
282
298
|
setPushing(true);
|
|
283
299
|
setDriveFileSyncStatus(null);
|
|
284
300
|
try {
|
|
285
|
-
const result = await exportWorkspace(
|
|
286
|
-
activeWorkspaceId,
|
|
287
|
-
activeWs.driveConfig.subFolderId,
|
|
288
|
-
);
|
|
301
|
+
const result = await exportWorkspace(activeWorkspaceId, targetFolderId);
|
|
289
302
|
if ("needsAuth" in result && result.needsAuth) {
|
|
290
303
|
window.location.href = result.authUrl;
|
|
291
304
|
return;
|
|
@@ -302,58 +315,100 @@ export default function Sidebar() {
|
|
|
302
315
|
}
|
|
303
316
|
};
|
|
304
317
|
|
|
305
|
-
const
|
|
306
|
-
setTopicDriveStatus((prev) => ({ ...prev, [topicId]: value }));
|
|
307
|
-
};
|
|
308
|
-
|
|
309
|
-
const handlePullTopicFromDrive = async (topicId: string) => {
|
|
318
|
+
const pushTopicToDrive = async (topicId: string, targetFolderId: string) => {
|
|
310
319
|
if (!activeWorkspaceId || !activeWs?.driveConfig?.folderId) return;
|
|
311
|
-
|
|
312
|
-
setTopicStatus(topicId, "
|
|
320
|
+
setTopicPushingId(topicId);
|
|
321
|
+
setTopicStatus(topicId, "Pushing topic to Drive…");
|
|
313
322
|
try {
|
|
314
|
-
const result = await
|
|
323
|
+
const result = await exportTopic(activeWorkspaceId, topicId, targetFolderId);
|
|
315
324
|
if ("needsAuth" in result && result.needsAuth) {
|
|
316
325
|
window.location.href = result.authUrl;
|
|
317
326
|
return;
|
|
318
327
|
}
|
|
319
|
-
const firstError = result.errors[0];
|
|
320
328
|
setTopicStatus(
|
|
321
329
|
topicId,
|
|
322
330
|
result.errors.length > 0
|
|
323
|
-
? `Topic
|
|
324
|
-
: `
|
|
331
|
+
? `Topic push finished with ${result.errors.length} error${result.errors.length === 1 ? "" : "s"}.`
|
|
332
|
+
: `Pushed ${result.questionsExported} question${result.questionsExported === 1 ? "" : "s"} and ${result.filesExported} file${result.filesExported === 1 ? "" : "s"} to Drive.`,
|
|
325
333
|
);
|
|
326
334
|
} catch (err: any) {
|
|
327
|
-
setTopicStatus(topicId, err?.message || "Topic
|
|
335
|
+
setTopicStatus(topicId, err?.message || "Topic push failed.");
|
|
328
336
|
} finally {
|
|
329
|
-
|
|
337
|
+
setTopicPushingId(null);
|
|
330
338
|
}
|
|
331
339
|
};
|
|
332
340
|
|
|
333
|
-
const
|
|
341
|
+
const openPushDestinationPicker = async (
|
|
342
|
+
mode: "workspace" | "topic",
|
|
343
|
+
topicId?: string,
|
|
344
|
+
) => {
|
|
334
345
|
if (!activeWorkspaceId || !activeWs?.driveConfig?.folderId) return;
|
|
335
|
-
|
|
336
|
-
setTopicStatus(topicId, "Pushing topic to Drive…");
|
|
346
|
+
setPushPicker({ mode, topicId, folders: [], loading: true });
|
|
337
347
|
try {
|
|
338
|
-
const
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
348
|
+
const folders = await fetchDriveSubfolders(activeWorkspaceId);
|
|
349
|
+
setPushPicker({ mode, topicId, folders, loading: false });
|
|
350
|
+
} catch (err: any) {
|
|
351
|
+
setPushPicker({ mode, topicId, folders: [], loading: false });
|
|
352
|
+
const message = err?.message || "Failed to load Drive folders.";
|
|
353
|
+
if (mode === "topic" && topicId) setTopicStatus(topicId, message);
|
|
354
|
+
else setDriveFileSyncStatus(message);
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
const handlePushToDrive = () => {
|
|
359
|
+
if (!activeWs?.driveConfig?.folderId) return;
|
|
360
|
+
if (currentSubFolder) {
|
|
361
|
+
void pushWorkspaceToDrive(currentSubFolder.id);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
void openPushDestinationPicker("workspace");
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
const handlePushTopicToDrive = (topicId: string) => {
|
|
368
|
+
if (!activeWs?.driveConfig?.folderId) return;
|
|
369
|
+
if (currentSubFolder) {
|
|
370
|
+
void pushTopicToDrive(topicId, currentSubFolder.id);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
void openPushDestinationPicker("topic", topicId);
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
const handlePushPickerDestination = (targetFolderId: string) => {
|
|
377
|
+
const picker = pushPicker;
|
|
378
|
+
if (!picker) return;
|
|
379
|
+
setPushPicker(null);
|
|
380
|
+
if (picker.mode === "topic" && picker.topicId) {
|
|
381
|
+
void pushTopicToDrive(picker.topicId, targetFolderId);
|
|
382
|
+
} else {
|
|
383
|
+
void pushWorkspaceToDrive(targetFolderId);
|
|
384
|
+
}
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
const setTopicStatus = (topicId: string, value: string) => {
|
|
388
|
+
setTopicDriveStatus((prev) => ({ ...prev, [topicId]: value }));
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
const handlePullTopicFromDrive = async (topicId: string) => {
|
|
392
|
+
if (!activeWorkspaceId || !activeWs?.driveConfig?.folderId) return;
|
|
393
|
+
setTopicSyncingId(topicId);
|
|
394
|
+
setTopicStatus(topicId, "Pulling topic from Drive…");
|
|
395
|
+
try {
|
|
396
|
+
const result = await syncTopic(activeWorkspaceId, topicId);
|
|
343
397
|
if ("needsAuth" in result && result.needsAuth) {
|
|
344
398
|
window.location.href = result.authUrl;
|
|
345
399
|
return;
|
|
346
400
|
}
|
|
401
|
+
const firstError = result.errors[0];
|
|
347
402
|
setTopicStatus(
|
|
348
403
|
topicId,
|
|
349
404
|
result.errors.length > 0
|
|
350
|
-
? `Topic
|
|
351
|
-
: `
|
|
405
|
+
? `Topic pull finished with ${result.errors.length} error${result.errors.length === 1 ? "" : "s"}. ${firstError ? `First: ${firstError}` : ""}`
|
|
406
|
+
: `Pulled ${result.filesImported} file${result.filesImported === 1 ? "" : "s"} into this topic.`,
|
|
352
407
|
);
|
|
353
408
|
} catch (err: any) {
|
|
354
|
-
setTopicStatus(topicId, err?.message || "Topic
|
|
409
|
+
setTopicStatus(topicId, err?.message || "Topic pull failed.");
|
|
355
410
|
} finally {
|
|
356
|
-
|
|
411
|
+
setTopicSyncingId(null);
|
|
357
412
|
}
|
|
358
413
|
};
|
|
359
414
|
|
|
@@ -1020,7 +1075,142 @@ export default function Sidebar() {
|
|
|
1020
1075
|
};
|
|
1021
1076
|
|
|
1022
1077
|
return (
|
|
1023
|
-
|
|
1078
|
+
<>
|
|
1079
|
+
{pushPicker && (
|
|
1080
|
+
<div
|
|
1081
|
+
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
|
|
1082
|
+
onClick={() => !pushPicker.loading && setPushPicker(null)}
|
|
1083
|
+
>
|
|
1084
|
+
<div
|
|
1085
|
+
className="w-80 rounded-xl border border-slate-700 bg-slate-900 p-4 shadow-2xl"
|
|
1086
|
+
onClick={(e) => e.stopPropagation()}
|
|
1087
|
+
>
|
|
1088
|
+
<div className="mb-3 flex items-center justify-between gap-3">
|
|
1089
|
+
<div>
|
|
1090
|
+
<h3 className="text-sm font-semibold text-slate-200">
|
|
1091
|
+
Choose Drive destination
|
|
1092
|
+
</h3>
|
|
1093
|
+
<p className="mt-0.5 text-[11px] text-slate-500">
|
|
1094
|
+
{pushPicker.mode === "topic"
|
|
1095
|
+
? "Push this topic into the selected Drive folder."
|
|
1096
|
+
: "Push this local workspace into the selected Drive folder."}
|
|
1097
|
+
</p>
|
|
1098
|
+
</div>
|
|
1099
|
+
<button
|
|
1100
|
+
onClick={() => setPushPicker(null)}
|
|
1101
|
+
className="text-slate-500 hover:text-slate-300"
|
|
1102
|
+
disabled={pushPicker.loading}
|
|
1103
|
+
>
|
|
1104
|
+
<X className="h-4 w-4" />
|
|
1105
|
+
</button>
|
|
1106
|
+
</div>
|
|
1107
|
+
|
|
1108
|
+
{pushPicker.loading ? (
|
|
1109
|
+
<div className="flex items-center justify-center gap-2 py-6 text-xs text-slate-400">
|
|
1110
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
1111
|
+
Loading Drive folders…
|
|
1112
|
+
</div>
|
|
1113
|
+
) : (
|
|
1114
|
+
<>
|
|
1115
|
+
<div className="max-h-52 space-y-1 overflow-y-auto">
|
|
1116
|
+
{activeWs?.driveConfig?.folderId && (
|
|
1117
|
+
<button
|
|
1118
|
+
type="button"
|
|
1119
|
+
onClick={() =>
|
|
1120
|
+
handlePushPickerDestination(activeWs.driveConfig!.folderId)
|
|
1121
|
+
}
|
|
1122
|
+
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-xs text-slate-300 hover:bg-slate-800"
|
|
1123
|
+
>
|
|
1124
|
+
<FolderOpen className="h-3.5 w-3.5 shrink-0 text-slate-500" />
|
|
1125
|
+
<span className="min-w-0 flex-1 truncate italic text-slate-400">
|
|
1126
|
+
{activeWs.driveConfig.folderName || "Linked root folder"}
|
|
1127
|
+
</span>
|
|
1128
|
+
</button>
|
|
1129
|
+
)}
|
|
1130
|
+
{pushPicker.folders.map((folder) => (
|
|
1131
|
+
<button
|
|
1132
|
+
key={folder.id}
|
|
1133
|
+
type="button"
|
|
1134
|
+
onClick={() => handlePushPickerDestination(folder.id)}
|
|
1135
|
+
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-xs text-slate-200 hover:bg-slate-800"
|
|
1136
|
+
>
|
|
1137
|
+
<FolderOpen className="h-3.5 w-3.5 shrink-0 text-cyan-400" />
|
|
1138
|
+
<span className="min-w-0 flex-1 truncate">
|
|
1139
|
+
{folder.name}
|
|
1140
|
+
</span>
|
|
1141
|
+
</button>
|
|
1142
|
+
))}
|
|
1143
|
+
{pushPicker.folders.length === 0 && (
|
|
1144
|
+
<p className="px-3 py-2 text-xs text-slate-500">
|
|
1145
|
+
No subfolders found. Choose the linked root folder above
|
|
1146
|
+
or create a new folder below.
|
|
1147
|
+
</p>
|
|
1148
|
+
)}
|
|
1149
|
+
</div>
|
|
1150
|
+
|
|
1151
|
+
<form
|
|
1152
|
+
className="mt-3 flex gap-2"
|
|
1153
|
+
onSubmit={async (e) => {
|
|
1154
|
+
e.preventDefault();
|
|
1155
|
+
const name = pushPickerNewFolderName.trim();
|
|
1156
|
+
if (!name || !activeWorkspaceId) return;
|
|
1157
|
+
setPushPickerCreatingFolder(true);
|
|
1158
|
+
try {
|
|
1159
|
+
const created = await createDriveSubfolder(
|
|
1160
|
+
activeWorkspaceId,
|
|
1161
|
+
name,
|
|
1162
|
+
);
|
|
1163
|
+
if ("needsAuth" in created) {
|
|
1164
|
+
window.location.href = created.authUrl;
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
setPushPicker((prev) =>
|
|
1168
|
+
prev
|
|
1169
|
+
? { ...prev, folders: [...prev.folders, created] }
|
|
1170
|
+
: prev,
|
|
1171
|
+
);
|
|
1172
|
+
setPushPickerNewFolderName("");
|
|
1173
|
+
} catch (err: any) {
|
|
1174
|
+
const message = err?.message || "Failed to create folder";
|
|
1175
|
+
if (pushPicker.mode === "topic" && pushPicker.topicId) {
|
|
1176
|
+
setTopicStatus(pushPicker.topicId, message);
|
|
1177
|
+
} else {
|
|
1178
|
+
setDriveFileSyncStatus(message);
|
|
1179
|
+
}
|
|
1180
|
+
} finally {
|
|
1181
|
+
setPushPickerCreatingFolder(false);
|
|
1182
|
+
}
|
|
1183
|
+
}}
|
|
1184
|
+
>
|
|
1185
|
+
<input
|
|
1186
|
+
value={pushPickerNewFolderName}
|
|
1187
|
+
onChange={(e) => setPushPickerNewFolderName(e.target.value)}
|
|
1188
|
+
placeholder="New folder name…"
|
|
1189
|
+
className="min-w-0 flex-1 rounded-lg border border-slate-700 bg-slate-800 px-2 py-1.5 text-xs text-slate-200 placeholder:text-slate-500 focus:border-cyan-500 focus:outline-none"
|
|
1190
|
+
/>
|
|
1191
|
+
<button
|
|
1192
|
+
type="submit"
|
|
1193
|
+
disabled={
|
|
1194
|
+
!pushPickerNewFolderName.trim() ||
|
|
1195
|
+
pushPickerCreatingFolder
|
|
1196
|
+
}
|
|
1197
|
+
className="flex items-center gap-1 rounded-lg bg-cyan-700 px-2.5 py-1.5 text-xs font-medium text-white hover:bg-cyan-600 disabled:opacity-40"
|
|
1198
|
+
>
|
|
1199
|
+
{pushPickerCreatingFolder ? (
|
|
1200
|
+
<Loader2 className="h-3 w-3 animate-spin" />
|
|
1201
|
+
) : (
|
|
1202
|
+
<Plus className="h-3 w-3" />
|
|
1203
|
+
)}
|
|
1204
|
+
Create
|
|
1205
|
+
</button>
|
|
1206
|
+
</form>
|
|
1207
|
+
</>
|
|
1208
|
+
)}
|
|
1209
|
+
</div>
|
|
1210
|
+
</div>
|
|
1211
|
+
)}
|
|
1212
|
+
|
|
1213
|
+
<aside className="w-72 border-r border-slate-800 flex flex-col bg-slate-900/50 shrink-0">
|
|
1024
1214
|
{/* Workspace switcher */}
|
|
1025
1215
|
<WorkspaceSwitcher />
|
|
1026
1216
|
|
|
@@ -1060,6 +1250,32 @@ export default function Sidebar() {
|
|
|
1060
1250
|
sync from inside that selected folder.
|
|
1061
1251
|
</p>
|
|
1062
1252
|
)}
|
|
1253
|
+
{canPushLocalDrive && (
|
|
1254
|
+
<div className="mt-2 space-y-1.5">
|
|
1255
|
+
<p className="text-[10px] text-slate-600">
|
|
1256
|
+
Push local workspace to Drive
|
|
1257
|
+
</p>
|
|
1258
|
+
<button
|
|
1259
|
+
type="button"
|
|
1260
|
+
onClick={handlePushToDrive}
|
|
1261
|
+
disabled={pushing}
|
|
1262
|
+
className="flex w-full items-center justify-center gap-1 rounded-md border border-cyan-500/20 bg-cyan-500/10 px-2 py-1 text-[11px] font-medium text-cyan-300 transition-colors hover:bg-cyan-500/15 disabled:opacity-40 disabled:hover:bg-cyan-500/10"
|
|
1263
|
+
title="Choose a Drive folder and push topics, questions, and workspace files into it"
|
|
1264
|
+
>
|
|
1265
|
+
{pushing ? (
|
|
1266
|
+
<Loader2 className="w-3 h-3 animate-spin" />
|
|
1267
|
+
) : (
|
|
1268
|
+
<Upload className="w-3 h-3" />
|
|
1269
|
+
)}
|
|
1270
|
+
{pushing ? "Pushing…" : "Choose folder & push"}
|
|
1271
|
+
</button>
|
|
1272
|
+
{driveFileSyncStatus && (
|
|
1273
|
+
<p className="text-[10px] leading-relaxed text-slate-500">
|
|
1274
|
+
{driveFileSyncStatus}
|
|
1275
|
+
</p>
|
|
1276
|
+
)}
|
|
1277
|
+
</div>
|
|
1278
|
+
)}
|
|
1063
1279
|
{canSyncDriveFolder && (
|
|
1064
1280
|
<div className="mt-2 space-y-1.5">
|
|
1065
1281
|
<p className="text-[10px] text-slate-600">
|
|
@@ -1393,13 +1609,13 @@ export default function Sidebar() {
|
|
|
1393
1609
|
<Download className="w-3 h-3" /> Download
|
|
1394
1610
|
</button>
|
|
1395
1611
|
|
|
1396
|
-
{
|
|
1612
|
+
{canPushDriveDestination && (
|
|
1397
1613
|
<>
|
|
1398
1614
|
<div className="border-t border-slate-700 my-0.5" />
|
|
1399
1615
|
<button
|
|
1400
1616
|
onClick={() => {
|
|
1401
1617
|
setOpenMenuTopicId(null);
|
|
1402
|
-
|
|
1618
|
+
handlePushTopicToDrive(topic.id);
|
|
1403
1619
|
}}
|
|
1404
1620
|
disabled={topicBusy || pushing || syncing}
|
|
1405
1621
|
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-cyan-300 hover:bg-slate-700 hover:text-cyan-200 disabled:opacity-50 transition-colors"
|
|
@@ -1411,21 +1627,23 @@ export default function Sidebar() {
|
|
|
1411
1627
|
)}
|
|
1412
1628
|
Push topic
|
|
1413
1629
|
</button>
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1630
|
+
{canSyncDriveFolder && (
|
|
1631
|
+
<button
|
|
1632
|
+
onClick={() => {
|
|
1633
|
+
setOpenMenuTopicId(null);
|
|
1634
|
+
void handlePullTopicFromDrive(topic.id);
|
|
1635
|
+
}}
|
|
1636
|
+
disabled={topicBusy || pushing || syncing}
|
|
1637
|
+
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-slate-300 hover:bg-slate-700 hover:text-cyan-300 disabled:opacity-50 transition-colors"
|
|
1638
|
+
>
|
|
1639
|
+
{topicSyncingId === topic.id ? (
|
|
1640
|
+
<Loader2 className="w-3 h-3 animate-spin" />
|
|
1641
|
+
) : (
|
|
1642
|
+
<RefreshCw className="w-3 h-3" />
|
|
1643
|
+
)}
|
|
1644
|
+
Pull topic
|
|
1645
|
+
</button>
|
|
1646
|
+
)}
|
|
1429
1647
|
</>
|
|
1430
1648
|
)}
|
|
1431
1649
|
|
|
@@ -1555,6 +1773,7 @@ export default function Sidebar() {
|
|
|
1555
1773
|
})}
|
|
1556
1774
|
</div>
|
|
1557
1775
|
)}
|
|
1558
|
-
|
|
1776
|
+
</aside>
|
|
1777
|
+
</>
|
|
1559
1778
|
);
|
|
1560
1779
|
}
|
|
@@ -370,6 +370,662 @@ setInterval(async () => {
|
|
|
370
370
|
},
|
|
371
371
|
};
|
|
372
372
|
|
|
373
|
+
export const POSTGRES_DOCKER_PROVIDER_LAB: InfraLabWorkspace = {
|
|
374
|
+
version: 1,
|
|
375
|
+
label: "Postgres Docker Provider Lab",
|
|
376
|
+
provider: "docker",
|
|
377
|
+
executionMode: "docker",
|
|
378
|
+
activeFile: "README.md",
|
|
379
|
+
files: {
|
|
380
|
+
"README.md": `# Postgres Docker Provider Lab
|
|
381
|
+
|
|
382
|
+
This lab is a clearer Terraform + Docker mental model:
|
|
383
|
+
|
|
384
|
+
1. Terraform uses the Docker provider.
|
|
385
|
+
2. Terraform pulls the Postgres Docker image.
|
|
386
|
+
3. Terraform creates a Docker volume for database files.
|
|
387
|
+
4. Terraform creates a running Postgres container during \`terraform apply\`.
|
|
388
|
+
5. Terraform removes the container during \`terraform destroy\`.
|
|
389
|
+
|
|
390
|
+
In this lab, Docker is the local infrastructure platform and Postgres is the deployed infrastructure.
|
|
391
|
+
|
|
392
|
+
## Prerequisites
|
|
393
|
+
|
|
394
|
+
- Docker Desktop / Docker Engine must already be running.
|
|
395
|
+
- The first \`terraform init\` downloads the Docker Terraform provider.
|
|
396
|
+
|
|
397
|
+
## Run it
|
|
398
|
+
|
|
399
|
+
Use the Console tab one command at a time:
|
|
400
|
+
|
|
401
|
+
1. \`terraform init\`
|
|
402
|
+
2. \`terraform plan -out=tfplan\`
|
|
403
|
+
3. \`terraform apply -auto-approve\`
|
|
404
|
+
4. \`terraform output\`
|
|
405
|
+
5. \`docker ps\`
|
|
406
|
+
6. \`docker exec terraform-postgres-db pg_isready -U appuser -d appdb\`
|
|
407
|
+
7. \`docker exec -e PGPASSWORD=local-dev-password terraform-postgres-db psql -U appuser -d appdb -c "select current_database(), current_user;"\`
|
|
408
|
+
|
|
409
|
+
## Create a table
|
|
410
|
+
|
|
411
|
+
After apply succeeds, try these one at a time:
|
|
412
|
+
|
|
413
|
+
1. \`docker exec -e PGPASSWORD=local-dev-password terraform-postgres-db psql -U appuser -d appdb -c "create table if not exists notes (id serial primary key, body text not null);"\`
|
|
414
|
+
2. \`docker exec -e PGPASSWORD=local-dev-password terraform-postgres-db psql -U appuser -d appdb -c "insert into notes (body) values ('hello from terraform-managed postgres');"\`
|
|
415
|
+
3. \`docker exec -e PGPASSWORD=local-dev-password terraform-postgres-db psql -U appuser -d appdb -c "select * from notes;"\`
|
|
416
|
+
|
|
417
|
+
## Change something
|
|
418
|
+
|
|
419
|
+
Edit \`variables.tf\`, for example change \`host_port\` from \`5433\` to \`5434\`, then run:
|
|
420
|
+
|
|
421
|
+
1. \`terraform plan -out=tfplan\`
|
|
422
|
+
2. \`terraform apply -auto-approve\`
|
|
423
|
+
3. \`terraform output\`
|
|
424
|
+
|
|
425
|
+
## Clean up
|
|
426
|
+
|
|
427
|
+
Run:
|
|
428
|
+
|
|
429
|
+
\`terraform destroy -auto-approve\`
|
|
430
|
+
|
|
431
|
+
The named Docker volume is also Terraform-managed, so destroying the lab removes the database data volume too.
|
|
432
|
+
|
|
433
|
+
## Mental model
|
|
434
|
+
|
|
435
|
+
This is IaC [Infrastructure as Code] managing local Docker resources:
|
|
436
|
+
|
|
437
|
+
- \`provider.tf\` tells Terraform how to talk to Docker.
|
|
438
|
+
- \`main.tf\` declares Docker resources: image, volume, container.
|
|
439
|
+
- \`terraform apply\` makes Docker match that desired state.
|
|
440
|
+
- \`outputs.tf\` prints useful connection details.
|
|
441
|
+
|
|
442
|
+
So this is not "Docker deployed to Docker". It is Terraform deploying a Postgres database container into local Docker.
|
|
443
|
+
`,
|
|
444
|
+
"provider.tf": `terraform {
|
|
445
|
+
required_version = ">= 1.5.0"
|
|
446
|
+
|
|
447
|
+
required_providers {
|
|
448
|
+
docker = {
|
|
449
|
+
source = "kreuzwerker/docker"
|
|
450
|
+
version = "~> 3.0"
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
provider "docker" {}
|
|
456
|
+
`,
|
|
457
|
+
"variables.tf": `variable "container_name" {
|
|
458
|
+
description = "Name for the local Postgres Docker container."
|
|
459
|
+
type = string
|
|
460
|
+
default = "terraform-postgres-db"
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
variable "host_port" {
|
|
464
|
+
description = "Host port published to your machine for Postgres connections."
|
|
465
|
+
type = number
|
|
466
|
+
default = 5433
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
variable "postgres_version" {
|
|
470
|
+
description = "Postgres Docker image version."
|
|
471
|
+
type = string
|
|
472
|
+
default = "16-alpine"
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
variable "database_name" {
|
|
476
|
+
description = "Database created by the Postgres image on first startup."
|
|
477
|
+
type = string
|
|
478
|
+
default = "appdb"
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
variable "database_user" {
|
|
482
|
+
description = "Database user created by the Postgres image on first startup."
|
|
483
|
+
type = string
|
|
484
|
+
default = "appuser"
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
variable "database_password" {
|
|
488
|
+
description = "Local development password for the database user."
|
|
489
|
+
type = string
|
|
490
|
+
default = "local-dev-password"
|
|
491
|
+
sensitive = true
|
|
492
|
+
}
|
|
493
|
+
`,
|
|
494
|
+
"main.tf": `resource "docker_image" "postgres" {
|
|
495
|
+
name = "postgres:\${var.postgres_version}"
|
|
496
|
+
keep_locally = true
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
resource "docker_volume" "postgres_data" {
|
|
500
|
+
name = "\${var.container_name}-data"
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
resource "docker_container" "postgres" {
|
|
504
|
+
name = var.container_name
|
|
505
|
+
image = docker_image.postgres.image_id
|
|
506
|
+
restart = "unless-stopped"
|
|
507
|
+
|
|
508
|
+
env = [
|
|
509
|
+
"POSTGRES_DB=\${var.database_name}",
|
|
510
|
+
"POSTGRES_USER=\${var.database_user}",
|
|
511
|
+
"POSTGRES_PASSWORD=\${var.database_password}",
|
|
512
|
+
"PGDATA=/var/lib/postgresql/data/pgdata"
|
|
513
|
+
]
|
|
514
|
+
|
|
515
|
+
ports {
|
|
516
|
+
internal = 5432
|
|
517
|
+
external = var.host_port
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
volumes {
|
|
521
|
+
volume_name = docker_volume.postgres_data.name
|
|
522
|
+
container_path = "/var/lib/postgresql/data"
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
healthcheck {
|
|
526
|
+
test = ["CMD-SHELL", "pg_isready -U \${var.database_user} -d \${var.database_name}"]
|
|
527
|
+
interval = "5s"
|
|
528
|
+
timeout = "3s"
|
|
529
|
+
start_period = "10s"
|
|
530
|
+
retries = 10
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
`,
|
|
534
|
+
"outputs.tf": `output "container_name" {
|
|
535
|
+
value = docker_container.postgres.name
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
output "host" {
|
|
539
|
+
value = "localhost"
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
output "port" {
|
|
543
|
+
value = var.host_port
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
output "database_name" {
|
|
547
|
+
value = var.database_name
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
output "database_user" {
|
|
551
|
+
value = var.database_user
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
output "docker_exec_psql" {
|
|
555
|
+
value = "docker exec -e PGPASSWORD=local-dev-password \${docker_container.postgres.name} psql -U \${var.database_user} -d \${var.database_name} -c 'select current_database(), current_user;'"
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
output "connection_string" {
|
|
559
|
+
value = "postgresql://\${var.database_user}:\${var.database_password}@localhost:\${var.host_port}/\${var.database_name}"
|
|
560
|
+
sensitive = true
|
|
561
|
+
}
|
|
562
|
+
`,
|
|
563
|
+
},
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
const AZURE_NETWORK_ACL_FILE_NAMES = [
|
|
567
|
+
"README.md",
|
|
568
|
+
"provider.tf",
|
|
569
|
+
"variables.tf",
|
|
570
|
+
"main.tf",
|
|
571
|
+
"outputs.tf",
|
|
572
|
+
"tfvars/dev.wu3.tfvars",
|
|
573
|
+
"modules/keyvault/main.tf",
|
|
574
|
+
"modules/storage-account/main.tf",
|
|
575
|
+
"modules/eventhub/main.tf",
|
|
576
|
+
"apps/azure-resource-mock/Dockerfile",
|
|
577
|
+
"apps/azure-resource-mock/server.js",
|
|
578
|
+
];
|
|
579
|
+
|
|
580
|
+
export const AZURE_NETWORK_ACL_DOCKER_LAB: InfraLabWorkspace = {
|
|
581
|
+
version: 1,
|
|
582
|
+
label: "Azure Network ACL Failure Lab",
|
|
583
|
+
provider: "docker",
|
|
584
|
+
executionMode: "docker",
|
|
585
|
+
activeFile: "README.md",
|
|
586
|
+
files: {
|
|
587
|
+
"README.md": `# Azure Network ACL Failure Lab — Local Docker Edition
|
|
588
|
+
|
|
589
|
+
This lab recreates a common Azure Terraform failure without requiring an Azure subscription.
|
|
590
|
+
|
|
591
|
+
Terraform still has the same shape as the real system:
|
|
592
|
+
|
|
593
|
+
1. **tfvars** provides environment-specific values.
|
|
594
|
+
2. **variables.tf** declares the inputs.
|
|
595
|
+
3. **main.tf** wires those inputs into modules.
|
|
596
|
+
4. Each module applies network lock-down rules from the same \`virtual_networks_allowed\` list.
|
|
597
|
+
5. Docker containers stand in for Azure Key Vault, Storage Account, and Event Hub.
|
|
598
|
+
|
|
599
|
+
The lab starts broken on purpose. The DEV deployment subscription is:
|
|
600
|
+
|
|
601
|
+
- \`d05d3a9b-68eb-4dea-9f1c-208dc705ce2d\`
|
|
602
|
+
|
|
603
|
+
But the subnet allowlist in \`tfvars/dev.wu3.tfvars\` points at the old platform networking subscription:
|
|
604
|
+
|
|
605
|
+
- \`e2cbae49-43fe-49c9-b9e5-fdba61ccb575\`
|
|
606
|
+
|
|
607
|
+
That reproduces the real bug chain:
|
|
608
|
+
|
|
609
|
+
> New DEV resources are being deployed, but the security whitelist still points at old DEV networking.
|
|
610
|
+
|
|
611
|
+
## Run the failure
|
|
612
|
+
|
|
613
|
+
Use the Console tab one command at a time:
|
|
614
|
+
|
|
615
|
+
1. \`terraform init\`
|
|
616
|
+
2. \`terraform plan -var-file=tfvars/dev.wu3.tfvars -out=tfplan\`
|
|
617
|
+
|
|
618
|
+
Terraform should fail before it starts the mock containers. That is intentional.
|
|
619
|
+
|
|
620
|
+
## Trace the value path
|
|
621
|
+
|
|
622
|
+
Follow this order:
|
|
623
|
+
|
|
624
|
+
1. Open \`tfvars/dev.wu3.tfvars\` and find \`virtual_networks_allowed\`.
|
|
625
|
+
2. Open \`variables.tf\` and find \`variable "virtual_networks_allowed"\`.
|
|
626
|
+
3. Open \`main.tf\` and see that value passed into:
|
|
627
|
+
- \`module "keyvault"\`
|
|
628
|
+
- \`module "storage_account"\`
|
|
629
|
+
- \`module "eventhub"\`
|
|
630
|
+
4. Open each module and find where the value becomes network rules:
|
|
631
|
+
- \`modules/keyvault/main.tf\` → \`network_acls\`
|
|
632
|
+
- \`modules/storage-account/main.tf\` → \`network_rules\`
|
|
633
|
+
- \`modules/eventhub/main.tf\` → \`network_rulesets\`
|
|
634
|
+
|
|
635
|
+
## Fix it
|
|
636
|
+
|
|
637
|
+
In \`tfvars/dev.wu3.tfvars\`, replace the old subnet subscription/resource group with the current DEV networking values:
|
|
638
|
+
|
|
639
|
+
- subscription: \`d05d3a9b-68eb-4dea-9f1c-208dc705ce2d\`
|
|
640
|
+
- resource group: \`plfdev02shrwu3-networking\`
|
|
641
|
+
|
|
642
|
+
The fixed subnet IDs should look like:
|
|
643
|
+
|
|
644
|
+
\`/subscriptions/d05d3a9b-68eb-4dea-9f1c-208dc705ce2d/resourceGroups/plfdev02shrwu3-networking/providers/Microsoft.Network/virtualNetworks/plfdev02-vnet/subnets/app\`
|
|
645
|
+
|
|
646
|
+
Then rerun:
|
|
647
|
+
|
|
648
|
+
1. \`terraform plan -var-file=tfvars/dev.wu3.tfvars -out=tfplan\`
|
|
649
|
+
2. \`terraform apply -auto-approve -var-file=tfvars/dev.wu3.tfvars\`
|
|
650
|
+
3. \`terraform output\`
|
|
651
|
+
4. \`curl -s http://localhost:4311/health\`
|
|
652
|
+
5. \`curl -s http://localhost:4312/health\`
|
|
653
|
+
6. \`curl -s http://localhost:4313/health\`
|
|
654
|
+
|
|
655
|
+
## Clean up
|
|
656
|
+
|
|
657
|
+
Run:
|
|
658
|
+
|
|
659
|
+
\`terraform destroy -auto-approve -var-file=tfvars/dev.wu3.tfvars\`
|
|
660
|
+
|
|
661
|
+
If you want to inspect Docker directly:
|
|
662
|
+
|
|
663
|
+
- \`docker ps -a\`
|
|
664
|
+
- \`docker logs azure-acl-lab-keyvault\`
|
|
665
|
+
- \`docker logs azure-acl-lab-storage\`
|
|
666
|
+
- \`docker logs azure-acl-lab-eventhub\`
|
|
667
|
+
|
|
668
|
+
## Interview summary
|
|
669
|
+
|
|
670
|
+
The bug is not that Key Vault, Storage, or Event Hub cannot be created. The bug is that all three modules consume the same stale \`virtual_networks_allowed\` value, so all three try to apply firewall rules for subnets Azure cannot resolve anymore.
|
|
671
|
+
`,
|
|
672
|
+
"provider.tf": `terraform {
|
|
673
|
+
required_version = ">= 1.5.0"
|
|
674
|
+
}
|
|
675
|
+
`,
|
|
676
|
+
"variables.tf": `variable "name_prefix" {
|
|
677
|
+
description = "Prefix used for local Docker containers and image names."
|
|
678
|
+
type = string
|
|
679
|
+
default = "azure-acl-lab"
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
variable "location" {
|
|
683
|
+
description = "Azure region label used by the mock resources."
|
|
684
|
+
type = string
|
|
685
|
+
default = "westus3"
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
variable "az_subscription_id" {
|
|
689
|
+
description = "Current DEV subscription receiving the deployment."
|
|
690
|
+
type = string
|
|
691
|
+
default = "d05d3a9b-68eb-4dea-9f1c-208dc705ce2d"
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
variable "expected_network_resource_group" {
|
|
695
|
+
description = "Networking resource group that should own DEV subnets now."
|
|
696
|
+
type = string
|
|
697
|
+
default = "plfdev02shrwu3-networking"
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
variable "virtual_networks_allowed" {
|
|
701
|
+
description = "Subnet IDs trusted by Key Vault, Storage, and Event Hub firewalls."
|
|
702
|
+
type = list(string)
|
|
703
|
+
|
|
704
|
+
# Broken on purpose. The tfvars file mirrors the same old subnet IDs.
|
|
705
|
+
default = [
|
|
706
|
+
"/subscriptions/e2cbae49-43fe-49c9-b9e5-fdba61ccb575/resourceGroups/plfdev01shrwu3-networking/providers/Microsoft.Network/virtualNetworks/plfdev01-vnet/subnets/app",
|
|
707
|
+
"/subscriptions/e2cbae49-43fe-49c9-b9e5-fdba61ccb575/resourceGroups/plfdev01shrwu3-networking/providers/Microsoft.Network/virtualNetworks/plfdev01-vnet/subnets/private-endpoints"
|
|
708
|
+
]
|
|
709
|
+
}
|
|
710
|
+
`,
|
|
711
|
+
"main.tf": `locals {
|
|
712
|
+
mock_image = "\${var.name_prefix}/azure-resource-mock:local"
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
# This one Docker image stands in for Azure platform services. The modules below
|
|
716
|
+
# run the same image with different RESOURCE_KIND values so you can inspect
|
|
717
|
+
# Key Vault, Storage Account, and Event Hub independently.
|
|
718
|
+
resource "terraform_data" "mock_image" {
|
|
719
|
+
triggers_replace = {
|
|
720
|
+
dockerfile = filesha1("\${path.module}/apps/azure-resource-mock/Dockerfile")
|
|
721
|
+
server = filesha1("\${path.module}/apps/azure-resource-mock/server.js")
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
provisioner "local-exec" {
|
|
725
|
+
command = "docker build -t \${local.mock_image} apps/azure-resource-mock"
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
module "keyvault" {
|
|
730
|
+
source = "./modules/keyvault"
|
|
731
|
+
|
|
732
|
+
resource_name = "\${var.name_prefix}-kv"
|
|
733
|
+
container_name = "\${var.name_prefix}-keyvault"
|
|
734
|
+
host_port = 4311
|
|
735
|
+
image_name = local.mock_image
|
|
736
|
+
current_subscription_id = var.az_subscription_id
|
|
737
|
+
expected_network_subscription_id = var.az_subscription_id
|
|
738
|
+
expected_network_resource_group = var.expected_network_resource_group
|
|
739
|
+
virtual_networks_allowed = var.virtual_networks_allowed
|
|
740
|
+
|
|
741
|
+
depends_on = [terraform_data.mock_image]
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
module "storage_account" {
|
|
745
|
+
source = "./modules/storage-account"
|
|
746
|
+
|
|
747
|
+
resource_name = replace("\${var.name_prefix}storage", "-", "")
|
|
748
|
+
container_name = "\${var.name_prefix}-storage"
|
|
749
|
+
host_port = 4312
|
|
750
|
+
image_name = local.mock_image
|
|
751
|
+
current_subscription_id = var.az_subscription_id
|
|
752
|
+
expected_network_subscription_id = var.az_subscription_id
|
|
753
|
+
expected_network_resource_group = var.expected_network_resource_group
|
|
754
|
+
virtual_networks_allowed = var.virtual_networks_allowed
|
|
755
|
+
|
|
756
|
+
depends_on = [terraform_data.mock_image]
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
module "eventhub" {
|
|
760
|
+
source = "./modules/eventhub"
|
|
761
|
+
|
|
762
|
+
resource_name = "\${var.name_prefix}-eh"
|
|
763
|
+
container_name = "\${var.name_prefix}-eventhub"
|
|
764
|
+
host_port = 4313
|
|
765
|
+
image_name = local.mock_image
|
|
766
|
+
current_subscription_id = var.az_subscription_id
|
|
767
|
+
expected_network_subscription_id = var.az_subscription_id
|
|
768
|
+
expected_network_resource_group = var.expected_network_resource_group
|
|
769
|
+
virtual_networks_allowed = var.virtual_networks_allowed
|
|
770
|
+
|
|
771
|
+
depends_on = [terraform_data.mock_image]
|
|
772
|
+
}
|
|
773
|
+
`,
|
|
774
|
+
"outputs.tf": `output "keyvault_url" {
|
|
775
|
+
value = module.keyvault.url
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
output "storage_account_url" {
|
|
779
|
+
value = module.storage_account.url
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
output "eventhub_url" {
|
|
783
|
+
value = module.eventhub.url
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
output "debug_chain" {
|
|
787
|
+
value = {
|
|
788
|
+
current_subscription = var.az_subscription_id
|
|
789
|
+
expected_network_rg = var.expected_network_resource_group
|
|
790
|
+
virtual_networks_allowed = var.virtual_networks_allowed
|
|
791
|
+
shared_failure_explainer = "All three modules consume virtual_networks_allowed, so one stale subnet list breaks every resource firewall."
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
`,
|
|
795
|
+
"tfvars/dev.wu3.tfvars": `# DEV deploys into the new subscription.
|
|
796
|
+
az_subscription_id = "d05d3a9b-68eb-4dea-9f1c-208dc705ce2d"
|
|
797
|
+
location = "westus3"
|
|
798
|
+
|
|
799
|
+
# DEV networking moved. The modules expect subnet IDs from this resource group.
|
|
800
|
+
expected_network_resource_group = "plfdev02shrwu3-networking"
|
|
801
|
+
|
|
802
|
+
# Broken on purpose: these still point at the old platform networking subscription
|
|
803
|
+
# and old networking resource group.
|
|
804
|
+
virtual_networks_allowed = [
|
|
805
|
+
"/subscriptions/e2cbae49-43fe-49c9-b9e5-fdba61ccb575/resourceGroups/plfdev01shrwu3-networking/providers/Microsoft.Network/virtualNetworks/plfdev01-vnet/subnets/app",
|
|
806
|
+
"/subscriptions/e2cbae49-43fe-49c9-b9e5-fdba61ccb575/resourceGroups/plfdev01shrwu3-networking/providers/Microsoft.Network/virtualNetworks/plfdev01-vnet/subnets/private-endpoints"
|
|
807
|
+
]
|
|
808
|
+
`,
|
|
809
|
+
"modules/keyvault/main.tf": `variable "resource_name" { type = string }
|
|
810
|
+
variable "container_name" { type = string }
|
|
811
|
+
variable "host_port" { type = number }
|
|
812
|
+
variable "image_name" { type = string }
|
|
813
|
+
variable "current_subscription_id" { type = string }
|
|
814
|
+
variable "expected_network_subscription_id" { type = string }
|
|
815
|
+
variable "expected_network_resource_group" { type = string }
|
|
816
|
+
variable "virtual_networks_allowed" { type = list(string) }
|
|
817
|
+
|
|
818
|
+
# Mirrors azurerm_key_vault.network_acls.virtual_network_subnet_ids.
|
|
819
|
+
resource "terraform_data" "network_acls" {
|
|
820
|
+
input = var.virtual_networks_allowed
|
|
821
|
+
|
|
822
|
+
lifecycle {
|
|
823
|
+
precondition {
|
|
824
|
+
condition = alltrue([
|
|
825
|
+
for subnet_id in var.virtual_networks_allowed :
|
|
826
|
+
can(regex("^/subscriptions/\${var.expected_network_subscription_id}/resourceGroups/\${var.expected_network_resource_group}/providers/Microsoft.Network/virtualNetworks/.+/subnets/.+$", subnet_id))
|
|
827
|
+
])
|
|
828
|
+
error_message = "Key Vault network_acls rejected virtual_networks_allowed. Expected subnet IDs from subscription \${var.expected_network_subscription_id} and resource group \${var.expected_network_resource_group}."
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
resource "terraform_data" "keyvault_mock" {
|
|
834
|
+
input = {
|
|
835
|
+
container_name = var.container_name
|
|
836
|
+
url = "http://localhost:\${var.host_port}"
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
provisioner "local-exec" {
|
|
840
|
+
command = "docker rm -f \${var.container_name} >/dev/null 2>&1 || true && docker run -d --name \${var.container_name} -p \${var.host_port}:8080 -e RESOURCE_KIND=KeyVault -e RESOURCE_NAME=\${var.resource_name} -e CURRENT_SUBSCRIPTION_ID=\${var.current_subscription_id} -e ALLOWED_SUBNET_IDS='\${join(",", var.virtual_networks_allowed)}' \${var.image_name}"
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
provisioner "local-exec" {
|
|
844
|
+
when = destroy
|
|
845
|
+
command = "docker rm -f \${self.input.container_name} >/dev/null 2>&1 || true"
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
depends_on = [terraform_data.network_acls]
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
output "url" {
|
|
852
|
+
value = terraform_data.keyvault_mock.input.url
|
|
853
|
+
}
|
|
854
|
+
`,
|
|
855
|
+
"modules/storage-account/main.tf": `variable "resource_name" { type = string }
|
|
856
|
+
variable "container_name" { type = string }
|
|
857
|
+
variable "host_port" { type = number }
|
|
858
|
+
variable "image_name" { type = string }
|
|
859
|
+
variable "current_subscription_id" { type = string }
|
|
860
|
+
variable "expected_network_subscription_id" { type = string }
|
|
861
|
+
variable "expected_network_resource_group" { type = string }
|
|
862
|
+
variable "virtual_networks_allowed" { type = list(string) }
|
|
863
|
+
|
|
864
|
+
# Mirrors azurerm_storage_account.network_rules.virtual_network_subnet_ids.
|
|
865
|
+
resource "terraform_data" "network_rules" {
|
|
866
|
+
input = var.virtual_networks_allowed
|
|
867
|
+
|
|
868
|
+
lifecycle {
|
|
869
|
+
precondition {
|
|
870
|
+
condition = alltrue([
|
|
871
|
+
for subnet_id in var.virtual_networks_allowed :
|
|
872
|
+
can(regex("^/subscriptions/\${var.expected_network_subscription_id}/resourceGroups/\${var.expected_network_resource_group}/providers/Microsoft.Network/virtualNetworks/.+/subnets/.+$", subnet_id))
|
|
873
|
+
])
|
|
874
|
+
error_message = "Storage Account network_rules rejected virtual_networks_allowed. Expected subnet IDs from subscription \${var.expected_network_subscription_id} and resource group \${var.expected_network_resource_group}."
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
resource "terraform_data" "storage_mock" {
|
|
880
|
+
input = {
|
|
881
|
+
container_name = var.container_name
|
|
882
|
+
url = "http://localhost:\${var.host_port}"
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
provisioner "local-exec" {
|
|
886
|
+
command = "docker rm -f \${var.container_name} >/dev/null 2>&1 || true && docker run -d --name \${var.container_name} -p \${var.host_port}:8080 -e RESOURCE_KIND=StorageAccount -e RESOURCE_NAME=\${var.resource_name} -e CURRENT_SUBSCRIPTION_ID=\${var.current_subscription_id} -e ALLOWED_SUBNET_IDS='\${join(",", var.virtual_networks_allowed)}' \${var.image_name}"
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
provisioner "local-exec" {
|
|
890
|
+
when = destroy
|
|
891
|
+
command = "docker rm -f \${self.input.container_name} >/dev/null 2>&1 || true"
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
depends_on = [terraform_data.network_rules]
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
output "url" {
|
|
898
|
+
value = terraform_data.storage_mock.input.url
|
|
899
|
+
}
|
|
900
|
+
`,
|
|
901
|
+
"modules/eventhub/main.tf": `variable "resource_name" { type = string }
|
|
902
|
+
variable "container_name" { type = string }
|
|
903
|
+
variable "host_port" { type = number }
|
|
904
|
+
variable "image_name" { type = string }
|
|
905
|
+
variable "current_subscription_id" { type = string }
|
|
906
|
+
variable "expected_network_subscription_id" { type = string }
|
|
907
|
+
variable "expected_network_resource_group" { type = string }
|
|
908
|
+
variable "virtual_networks_allowed" { type = list(string) }
|
|
909
|
+
|
|
910
|
+
locals {
|
|
911
|
+
virtual_network_rule = [
|
|
912
|
+
for subnet_id in var.virtual_networks_allowed : {
|
|
913
|
+
subnet_id = subnet_id
|
|
914
|
+
ignore_missing_virtual_network_service_endpoint = false
|
|
915
|
+
}
|
|
916
|
+
]
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
# Mirrors azurerm_eventhub_namespace.network_rulesets.virtual_network_rule.
|
|
920
|
+
resource "terraform_data" "network_rulesets" {
|
|
921
|
+
input = local.virtual_network_rule
|
|
922
|
+
|
|
923
|
+
lifecycle {
|
|
924
|
+
precondition {
|
|
925
|
+
condition = alltrue([
|
|
926
|
+
for rule in local.virtual_network_rule :
|
|
927
|
+
can(regex("^/subscriptions/\${var.expected_network_subscription_id}/resourceGroups/\${var.expected_network_resource_group}/providers/Microsoft.Network/virtualNetworks/.+/subnets/.+$", rule.subnet_id))
|
|
928
|
+
])
|
|
929
|
+
error_message = "Event Hub network_rulesets rejected virtual_networks_allowed. Expected subnet IDs from subscription \${var.expected_network_subscription_id} and resource group \${var.expected_network_resource_group}."
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
resource "terraform_data" "eventhub_mock" {
|
|
935
|
+
input = {
|
|
936
|
+
container_name = var.container_name
|
|
937
|
+
url = "http://localhost:\${var.host_port}"
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
provisioner "local-exec" {
|
|
941
|
+
command = "docker rm -f \${var.container_name} >/dev/null 2>&1 || true && docker run -d --name \${var.container_name} -p \${var.host_port}:8080 -e RESOURCE_KIND=EventHub -e RESOURCE_NAME=\${var.resource_name} -e CURRENT_SUBSCRIPTION_ID=\${var.current_subscription_id} -e ALLOWED_SUBNET_IDS='\${join(",", var.virtual_networks_allowed)}' \${var.image_name}"
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
provisioner "local-exec" {
|
|
945
|
+
when = destroy
|
|
946
|
+
command = "docker rm -f \${self.input.container_name} >/dev/null 2>&1 || true"
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
depends_on = [terraform_data.network_rulesets]
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
output "url" {
|
|
953
|
+
value = terraform_data.eventhub_mock.input.url
|
|
954
|
+
}
|
|
955
|
+
`,
|
|
956
|
+
"apps/azure-resource-mock/Dockerfile": `FROM node:20-alpine
|
|
957
|
+
WORKDIR /app
|
|
958
|
+
COPY server.js ./server.js
|
|
959
|
+
EXPOSE 8080
|
|
960
|
+
CMD ["node", "server.js"]
|
|
961
|
+
`,
|
|
962
|
+
"apps/azure-resource-mock/server.js": `import http from "node:http";
|
|
963
|
+
|
|
964
|
+
const port = Number(process.env.PORT || 8080);
|
|
965
|
+
const resourceKind = process.env.RESOURCE_KIND || "AzureResource";
|
|
966
|
+
const resourceName = process.env.RESOURCE_NAME || "local-resource";
|
|
967
|
+
const subscriptionId = process.env.CURRENT_SUBSCRIPTION_ID || "unknown";
|
|
968
|
+
const allowedSubnets = (process.env.ALLOWED_SUBNET_IDS || "")
|
|
969
|
+
.split(",")
|
|
970
|
+
.map((value) => value.trim())
|
|
971
|
+
.filter(Boolean);
|
|
972
|
+
|
|
973
|
+
function json(res, statusCode, body) {
|
|
974
|
+
res.writeHead(statusCode, { "content-type": "application/json" });
|
|
975
|
+
res.end(JSON.stringify(body, null, 2));
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function payload(path) {
|
|
979
|
+
return {
|
|
980
|
+
ok: true,
|
|
981
|
+
path,
|
|
982
|
+
resourceKind,
|
|
983
|
+
resourceName,
|
|
984
|
+
subscriptionId,
|
|
985
|
+
networkLockdown: {
|
|
986
|
+
defaultAction: "Deny",
|
|
987
|
+
allowedSubnets,
|
|
988
|
+
teachingPoint:
|
|
989
|
+
"In Azure this list is used by network_acls, network_rules, or network_rulesets. If any subnet ID is stale, the lock-down update fails."
|
|
990
|
+
}
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
const server = http.createServer((req, res) => {
|
|
995
|
+
const path = new URL(req.url || "/", "http://localhost").pathname;
|
|
996
|
+
if (path === "/" || path === "/health") return json(res, 200, payload(path));
|
|
997
|
+
if (path === "/secret") return json(res, 200, { ...payload(path), secretName: "demo-connection-string" });
|
|
998
|
+
if (path === "/blob") return json(res, 200, { ...payload(path), container: "solution-designs" });
|
|
999
|
+
if (path === "/events") return json(res, 200, { ...payload(path), eventHub: "solution-builder-events" });
|
|
1000
|
+
return json(res, 404, { ok: false, error: "Not found", path });
|
|
1001
|
+
});
|
|
1002
|
+
|
|
1003
|
+
server.listen(port, "0.0.0.0", () => {
|
|
1004
|
+
console.log(resourceKind + " mock listening on 0.0.0.0:" + port);
|
|
1005
|
+
console.log("Allowed subnets:", allowedSubnets);
|
|
1006
|
+
});
|
|
1007
|
+
`,
|
|
1008
|
+
},
|
|
1009
|
+
};
|
|
1010
|
+
|
|
1011
|
+
export const AZURE_NETWORK_ACL_BLANK_TEMPLATE: InfraLabWorkspace = {
|
|
1012
|
+
version: 1,
|
|
1013
|
+
label: "Azure Network ACL Blank Template",
|
|
1014
|
+
provider: "docker",
|
|
1015
|
+
executionMode: "docker",
|
|
1016
|
+
activeFile: "README.md",
|
|
1017
|
+
files: Object.fromEntries(
|
|
1018
|
+
AZURE_NETWORK_ACL_FILE_NAMES.map((fileName) => [fileName, ""]),
|
|
1019
|
+
),
|
|
1020
|
+
};
|
|
1021
|
+
|
|
1022
|
+
function hasWorkspaceFile(
|
|
1023
|
+
files: Record<string, string>,
|
|
1024
|
+
fileName: string,
|
|
1025
|
+
): boolean {
|
|
1026
|
+
return Object.prototype.hasOwnProperty.call(files, fileName);
|
|
1027
|
+
}
|
|
1028
|
+
|
|
373
1029
|
function refreshLegacyEnterpriseAuthFiles(
|
|
374
1030
|
source: InfraLabWorkspace,
|
|
375
1031
|
files: Record<string, string>,
|
|
@@ -404,7 +1060,7 @@ export function cloneInfraLabWorkspace(
|
|
|
404
1060
|
? { ...source.files }
|
|
405
1061
|
: { ...DEFAULT_INFRA_FILES };
|
|
406
1062
|
const files = refreshLegacyEnterpriseAuthFiles(source, sourceFiles);
|
|
407
|
-
const activeFile = files
|
|
1063
|
+
const activeFile = hasWorkspaceFile(files, source.activeFile)
|
|
408
1064
|
? source.activeFile
|
|
409
1065
|
: (Object.keys(files)[0] ?? "main.tf");
|
|
410
1066
|
|
|
@@ -438,14 +1094,22 @@ export function getInfraLabFileOrder(workspace: InfraLabWorkspace): string[] {
|
|
|
438
1094
|
"provider.tf",
|
|
439
1095
|
"variables.tf",
|
|
440
1096
|
"terraform.tfvars",
|
|
1097
|
+
"tfvars/dev.wu3.tfvars",
|
|
441
1098
|
"outputs.tf",
|
|
442
1099
|
"locals.tf",
|
|
1100
|
+
"modules/keyvault/main.tf",
|
|
1101
|
+
"modules/storage-account/main.tf",
|
|
1102
|
+
"modules/eventhub/main.tf",
|
|
1103
|
+
"apps/azure-resource-mock/Dockerfile",
|
|
1104
|
+
"apps/azure-resource-mock/server.js",
|
|
443
1105
|
];
|
|
444
1106
|
const extras = Object.keys(workspace.files)
|
|
445
1107
|
.filter((name) => !preferred.includes(name))
|
|
446
1108
|
.sort();
|
|
447
1109
|
|
|
448
|
-
return preferred
|
|
1110
|
+
return preferred
|
|
1111
|
+
.filter((name) => hasWorkspaceFile(workspace.files, name))
|
|
1112
|
+
.concat(extras);
|
|
449
1113
|
}
|
|
450
1114
|
|
|
451
1115
|
export function serializeInfraLabWorkspace(
|
package/template/cockpit.json
CHANGED
|
@@ -1181,6 +1181,13 @@ async function resolveExportFolderId(
|
|
|
1181
1181
|
if (!ws.driveConfig?.folderId) {
|
|
1182
1182
|
throw new Error("No Drive folder linked to this workspace");
|
|
1183
1183
|
}
|
|
1184
|
+
|
|
1185
|
+
// Local workspaces do not have a Drive-navigation state, so a missing
|
|
1186
|
+
// target should mean "the linked folder itself" rather than silently
|
|
1187
|
+
// creating/using _export. The UI now prompts for a destination, but this
|
|
1188
|
+
// server-side fallback protects older/stale clients too.
|
|
1189
|
+
if (ws.type === "local") return ws.driveConfig.folderId;
|
|
1190
|
+
|
|
1184
1191
|
return getOrCreateFolder(drive, ws.driveConfig.folderId, EXPORT_FOLDER_NAME);
|
|
1185
1192
|
}
|
|
1186
1193
|
|