@learnpack/learnpack 5.0.353 → 5.0.354
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/lib/scripts/descriptionsS3Backfill.js +142 -22
- package/lib/utils/awsCredentials.d.ts +20 -0
- package/lib/utils/awsCredentials.js +43 -0
- package/lib/utils/descriptions/backfillEvents.d.ts +60 -0
- package/lib/utils/descriptions/backfillEvents.js +107 -0
- package/lib/utils/descriptions/generateCourseDescriptions.d.ts +7 -0
- package/lib/utils/descriptions/generateCourseDescriptions.js +3 -0
- package/lib/utils/descriptions/publishStage.js +14 -3
- package/lib/utils/descriptions/resumePublication.js +17 -2
- package/lib/utils/descriptions/s3Storage.js +13 -6
- package/lib/utils/packageManifest.d.ts +8 -0
- package/lib/utils/packageManifest.js +8 -0
- package/lib/utils/repair/legacyPackageRepair.d.ts +131 -0
- package/lib/utils/repair/legacyPackageRepair.js +492 -0
- package/lib/utils/repair/repairStorage.d.ts +68 -0
- package/lib/utils/repair/repairStorage.js +89 -0
- package/lib/utils/s3/packageManifestBackfill.js +3 -8
- package/lib/utils/s3/packageSourcesAudit.d.ts +75 -0
- package/lib/utils/s3/packageSourcesAudit.js +184 -0
- package/package.json +3 -1
- package/src/scripts/README.md +244 -0
- package/src/scripts/descriptionsS3Backfill.ts +188 -20
- package/src/utils/awsCredentials.ts +57 -0
- package/src/utils/descriptions/backfillEvents.ts +152 -0
- package/src/utils/descriptions/generateCourseDescriptions.ts +10 -0
- package/src/utils/descriptions/publishStage.ts +394 -382
- package/src/utils/descriptions/resumePublication.ts +217 -200
- package/src/utils/descriptions/s3Storage.ts +214 -206
- package/src/utils/packageManifest.ts +8 -1
- package/src/utils/repair/legacyPackageRepair.ts +731 -0
- package/src/utils/repair/repairStorage.ts +168 -0
- package/src/utils/s3/packageManifestBackfill.ts +771 -776
- package/src/utils/s3/packageSourcesAudit.ts +311 -0
|
@@ -15,34 +15,59 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
15
15
|
* advance through the catalogue.
|
|
16
16
|
* - Or target a single course with --slug (remediation / on-demand).
|
|
17
17
|
*
|
|
18
|
-
* Writes descriptions into the published initialSyllabus.json.
|
|
19
|
-
* projection is a separate pass
|
|
20
|
-
* (runBatch) afterwards, which
|
|
18
|
+
* Writes descriptions into the published initialSyllabus.json. By default the
|
|
19
|
+
* manifest projection is a separate pass: run the package-manifest backfill
|
|
20
|
+
* (runBatch) afterwards, which over the whole catalogue is cheaper than
|
|
21
|
+
* re-projecting course by course.
|
|
21
22
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
23
|
+
* That default inverts for small, targeted runs, where three flags reproduce per
|
|
24
|
+
* course what a publication does, in one process instead of three:
|
|
25
|
+
*
|
|
26
|
+
* --reproject-manifest projects each course as it is described (the same
|
|
27
|
+
* `processPackage` the separate pass runs, plus a
|
|
28
|
+
* CloudFront invalidation);
|
|
29
|
+
* --mirror-draft copies the new descriptions back into the GCS draft,
|
|
30
|
+
* so the next publication does not regenerate them;
|
|
31
|
+
* --emit-events announces the result to breathecode.
|
|
32
|
+
*
|
|
33
|
+
* Secrets/infra via env: RIGOBOT_SYSTEM_TOKEN (required), BREATHECODE_SYSTEM_TOKEN
|
|
34
|
+
* (required by --emit-events), GCP_CREDENTIALS_JSON and GCP_BUCKET_NAME (required
|
|
35
|
+
* by --mirror-draft), plus S3_PACKAGES_BUCKET, AWS_REGION and
|
|
36
|
+
* CLOUDFRONT_DISTRIBUTION_ID as fallbacks/optionals.
|
|
24
37
|
*
|
|
25
38
|
* Per-run flags:
|
|
26
|
-
* --s3-bucket <name>
|
|
27
|
-
* --
|
|
28
|
-
* --
|
|
29
|
-
* --
|
|
30
|
-
* --
|
|
31
|
-
* --
|
|
32
|
-
* --
|
|
33
|
-
* --
|
|
34
|
-
* --
|
|
39
|
+
* --s3-bucket <name> S3 bucket (or env S3_PACKAGES_BUCKET, default learnpack-paquetes)
|
|
40
|
+
* --gcs-bucket <name> GCS draft bucket for --mirror-draft (or env GCP_BUCKET_NAME)
|
|
41
|
+
* --region <region> AWS region (default us-east-1, or env AWS_REGION)
|
|
42
|
+
* --slug <slug> process a single course (remediation / on-demand)
|
|
43
|
+
* --limit <n> courses per run (default 50)
|
|
44
|
+
* --target-words <n> words per description (default 25)
|
|
45
|
+
* --concurrency <n> completions in flight per course (default 5)
|
|
46
|
+
* --dry-run preview without writing/generating
|
|
47
|
+
* --no-reconcile skip the additive syllabus reconciliation
|
|
48
|
+
* --force regenerate even courses already settled at this prompt version
|
|
49
|
+
* --reproject-manifest re-project package-manifest.json per course, and invalidate it
|
|
50
|
+
* --mirror-draft mirror the descriptions back into the GCS draft
|
|
51
|
+
* --emit-events send package_manifest_updated per course (implies --reproject-manifest)
|
|
35
52
|
*
|
|
36
53
|
* Examples:
|
|
37
54
|
* node lib/scripts/descriptionsS3Backfill.js --limit 50
|
|
38
55
|
* node lib/scripts/descriptionsS3Backfill.js --slug my-course
|
|
39
56
|
* node lib/scripts/descriptionsS3Backfill.js --dry-run --limit 10
|
|
57
|
+
* node lib/scripts/descriptionsS3Backfill.js --slug my-course \
|
|
58
|
+
* --reproject-manifest --mirror-draft --emit-events
|
|
40
59
|
*/
|
|
41
60
|
const node_util_1 = require("node:util");
|
|
42
61
|
const client_s3_1 = require("@aws-sdk/client-s3");
|
|
62
|
+
const client_cloudfront_1 = require("@aws-sdk/client-cloudfront");
|
|
63
|
+
const storage_1 = require("@google-cloud/storage");
|
|
43
64
|
const packageManifest_1 = require("../utils/packageManifest");
|
|
44
65
|
const generateCourseDescriptions_1 = require("../utils/descriptions/generateCourseDescriptions");
|
|
45
66
|
const s3Storage_1 = require("../utils/descriptions/s3Storage");
|
|
67
|
+
const backfillEvents_1 = require("../utils/descriptions/backfillEvents");
|
|
68
|
+
const gcsStorage_1 = require("../utils/descriptions/gcsStorage");
|
|
69
|
+
const mirrorDescriptions_1 = require("../utils/descriptions/mirrorDescriptions");
|
|
70
|
+
const gcsBucketName_1 = require("../utils/gcsBucketName");
|
|
46
71
|
const workList_1 = require("../utils/descriptions/workList");
|
|
47
72
|
const SYLLABUS_KEY_PATTERN = /^([^/]+)\/\.learn\/initialSyllabus\.json$/;
|
|
48
73
|
/** Courses (by slug) ordered by initialSyllabus.json LastModified, newest first. */
|
|
@@ -67,11 +92,28 @@ async function listCoursesByRecency(s3, bucket) {
|
|
|
67
92
|
.sort((a, b) => b[1] - a[1])
|
|
68
93
|
.map(([slug]) => slug);
|
|
69
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* CDN invalidation for the re-projected manifests. Optional: a stale edge cache
|
|
97
|
+
* expires on its own, so a missing distribution id degrades the run instead of
|
|
98
|
+
* stopping it.
|
|
99
|
+
*/
|
|
100
|
+
function cloudFrontFor(region) {
|
|
101
|
+
const distributionId = (process.env.CLOUDFRONT_DISTRIBUTION_ID || "").trim();
|
|
102
|
+
if (!distributionId) {
|
|
103
|
+
console.warn("[s3-backfill] CLOUDFRONT_DISTRIBUTION_ID is not set: manifests will be re-projected but not invalidated");
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
client: new client_cloudfront_1.CloudFrontClient({ region }),
|
|
108
|
+
distributionId,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
70
111
|
async function main() {
|
|
71
|
-
var _a, _b, _c;
|
|
112
|
+
var _a, _b, _c, _d, _e, _f;
|
|
72
113
|
const { values } = (0, node_util_1.parseArgs)({
|
|
73
114
|
options: {
|
|
74
115
|
"s3-bucket": { type: "string" },
|
|
116
|
+
"gcs-bucket": { type: "string" },
|
|
75
117
|
region: { type: "string" },
|
|
76
118
|
slug: { type: "string" },
|
|
77
119
|
limit: { type: "string" },
|
|
@@ -80,6 +122,9 @@ async function main() {
|
|
|
80
122
|
"dry-run": { type: "boolean", default: false },
|
|
81
123
|
"no-reconcile": { type: "boolean", default: false },
|
|
82
124
|
force: { type: "boolean", default: false },
|
|
125
|
+
"reproject-manifest": { type: "boolean", default: false },
|
|
126
|
+
"mirror-draft": { type: "boolean", default: false },
|
|
127
|
+
"emit-events": { type: "boolean", default: false },
|
|
83
128
|
},
|
|
84
129
|
});
|
|
85
130
|
const token = process.env.RIGOBOT_SYSTEM_TOKEN;
|
|
@@ -94,19 +139,61 @@ async function main() {
|
|
|
94
139
|
const limit = Number.parseInt(values.limit || "50", 10) || 50;
|
|
95
140
|
const dryRun = (_a = values["dry-run"]) !== null && _a !== void 0 ? _a : false;
|
|
96
141
|
const force = (_b = values.force) !== null && _b !== void 0 ? _b : false;
|
|
142
|
+
const emitEvents = (_c = values["emit-events"]) !== null && _c !== void 0 ? _c : false;
|
|
143
|
+
const reprojectManifest = (_d = values["reproject-manifest"]) !== null && _d !== void 0 ? _d : false;
|
|
144
|
+
const mirrorDraft = (_e = values["mirror-draft"]) !== null && _e !== void 0 ? _e : false;
|
|
145
|
+
// The event announces that the manifest changed, so emitting it without
|
|
146
|
+
// projecting the manifest would state something untrue. Required rather than
|
|
147
|
+
// implied: turning on a write to S3 and the CDN as a side effect of another
|
|
148
|
+
// flag is exactly the kind of surprise a backfill should not have.
|
|
149
|
+
if (emitEvents && !reprojectManifest) {
|
|
150
|
+
console.error("[s3-backfill] --emit-events requires --reproject-manifest: the event " +
|
|
151
|
+
"reports a manifest update that would not have happened");
|
|
152
|
+
process.exit(1);
|
|
153
|
+
}
|
|
154
|
+
const breathecodeToken = process.env.BREATHECODE_SYSTEM_TOKEN;
|
|
155
|
+
if (emitEvents && !breathecodeToken) {
|
|
156
|
+
console.error("[s3-backfill] BREATHECODE_SYSTEM_TOKEN (env) is required by --emit-events");
|
|
157
|
+
process.exit(1);
|
|
158
|
+
}
|
|
159
|
+
if (emitEvents && dryRun) {
|
|
160
|
+
console.error("[s3-backfill] --emit-events cannot be combined with --dry-run: there is " +
|
|
161
|
+
"no dry run of an event that breathecode already received");
|
|
162
|
+
process.exit(1);
|
|
163
|
+
}
|
|
164
|
+
// Built up front, not on first use: a misconfigured draft bucket must fail at
|
|
165
|
+
// boot, not after the first course has already been billed to Rigobot.
|
|
166
|
+
let draftStorage;
|
|
167
|
+
if (mirrorDraft) {
|
|
168
|
+
const credentialsEnv = process.env.GCP_CREDENTIALS_JSON;
|
|
169
|
+
if (!credentialsEnv) {
|
|
170
|
+
console.error("[s3-backfill] GCP_CREDENTIALS_JSON (env) is required by --mirror-draft");
|
|
171
|
+
process.exit(1);
|
|
172
|
+
}
|
|
173
|
+
draftStorage = (0, gcsStorage_1.createGcsDescriptionsStorage)(new storage_1.Storage({ credentials: JSON.parse(credentialsEnv) }).bucket(values["gcs-bucket"] || (0, gcsBucketName_1.requireGcsBucketName)()));
|
|
174
|
+
}
|
|
97
175
|
const s3 = new client_s3_1.S3Client({ region });
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
176
|
+
const storage = (0, s3Storage_1.createS3DescriptionsStorage)(s3, bucket,
|
|
177
|
+
// Off by default: over the whole catalogue manifests are cheaper to project
|
|
178
|
+
// in a single later pass than course by course.
|
|
179
|
+
reprojectManifest ?
|
|
180
|
+
{ cloudFront: cloudFrontFor(region) } :
|
|
181
|
+
{ reprojectManifest: false });
|
|
182
|
+
const runId = (0, backfillEvents_1.newBackfillRunId)();
|
|
102
183
|
const singleSlug = values.slug;
|
|
103
184
|
const slugs = singleSlug ?
|
|
104
185
|
[singleSlug] :
|
|
105
186
|
await listCoursesByRecency(s3, bucket);
|
|
106
|
-
console.log(`[s3-backfill] Starting${singleSlug ? ` (slug=${singleSlug})` : ` (limit=${limit})`}${dryRun ? " (dry-run)" : ""} over ${slugs.length} candidate course(s)`);
|
|
187
|
+
console.log(`[s3-backfill] Starting${singleSlug ? ` (slug=${singleSlug})` : ` (limit=${limit})`}${dryRun ? " (dry-run)" : ""} over ${slugs.length} candidate course(s)${reprojectManifest ? ", re-projecting manifests" : ""}${mirrorDraft ? ", mirroring into the draft" : ""}${emitEvents ? `, run ${runId}` : ""}`);
|
|
107
188
|
let processed = 0;
|
|
108
189
|
let generated = 0;
|
|
109
190
|
let failed = 0;
|
|
191
|
+
let mirrored = 0;
|
|
192
|
+
const events = {
|
|
193
|
+
skipped: 0,
|
|
194
|
+
delivered: 0,
|
|
195
|
+
failed: 0,
|
|
196
|
+
};
|
|
110
197
|
for (const slug of slugs) {
|
|
111
198
|
if (!singleSlug && processed >= limit) {
|
|
112
199
|
break;
|
|
@@ -127,7 +214,7 @@ async function main() {
|
|
|
127
214
|
token,
|
|
128
215
|
dryRun,
|
|
129
216
|
force,
|
|
130
|
-
reconcile: !((
|
|
217
|
+
reconcile: !((_f = values["no-reconcile"]) !== null && _f !== void 0 ? _f : false),
|
|
131
218
|
concurrency: Number.parseInt(values.concurrency || "", 10) ||
|
|
132
219
|
generateCourseDescriptions_1.DEFAULT_DESCRIPTIONS_CONCURRENCY,
|
|
133
220
|
targetWordCount: Number.parseInt(values["target-words"] || "", 10) ||
|
|
@@ -140,14 +227,47 @@ async function main() {
|
|
|
140
227
|
generated += result.generated;
|
|
141
228
|
failed += result.failed;
|
|
142
229
|
console.log(`[s3-backfill] "${slug}": ${result.steps} step(s), ${result.generated} description(s)${dryRun ? " (dry-run)" : ""}${result.failed > 0 ? `, ${result.failed} failed` : ""}${result.missing > 0 ? `, ${result.missing} unanswered` : ""}`);
|
|
230
|
+
if (draftStorage) {
|
|
231
|
+
// Before the event, so a course is fully settled by the time it is
|
|
232
|
+
// announced. A failure here does NOT change the event status: the
|
|
233
|
+
// published package and its manifest are already correct, and only the
|
|
234
|
+
// draft lags behind — the next publication regenerates what is missing.
|
|
235
|
+
try {
|
|
236
|
+
const published = await storage.readSyllabus(slug);
|
|
237
|
+
const mirror = await (0, mirrorDescriptions_1.mirrorDescriptionsToDraft)(draftStorage, slug, published, { dryRun });
|
|
238
|
+
mirrored += mirror.copied;
|
|
239
|
+
console.log(`[s3-backfill] "${slug}": ${mirror.copied} mirrored, ${mirror.missed} missed, ${mirror.fresh} already fresh`);
|
|
240
|
+
}
|
|
241
|
+
catch (error) {
|
|
242
|
+
console.error(`[s3-backfill] Could not mirror "${slug}" into the draft:`, error.message);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (emitEvents) {
|
|
246
|
+
const outcome = await (0, backfillEvents_1.emitBackfillManifestEvent)({
|
|
247
|
+
courseSlug: slug,
|
|
248
|
+
runId,
|
|
249
|
+
result,
|
|
250
|
+
storage,
|
|
251
|
+
rigobotToken: token,
|
|
252
|
+
breathecodeToken: breathecodeToken,
|
|
253
|
+
});
|
|
254
|
+
events[outcome] += 1;
|
|
255
|
+
console.log(`[s3-backfill] "${slug}": manifest event ${outcome}`);
|
|
256
|
+
}
|
|
143
257
|
}
|
|
144
258
|
catch (error) {
|
|
145
259
|
processed += 1;
|
|
146
260
|
failed += 1;
|
|
261
|
+
// No event here on purpose: unlike a publication, a backfill never
|
|
262
|
+
// announced one, so nothing is owed. The course is simply re-run.
|
|
147
263
|
console.error(`[s3-backfill] Failed processing "${slug}":`, error.message);
|
|
148
264
|
}
|
|
149
265
|
}
|
|
150
|
-
console.log(`[s3-backfill] Done. ${processed} course(s), ${generated} description(s) written, ${failed} failure(s).`
|
|
266
|
+
console.log(`[s3-backfill] Done. ${processed} course(s), ${generated} description(s) written, ${failed} failure(s).` +
|
|
267
|
+
(mirrorDraft ? ` ${mirrored} mirrored into the draft.` : "") +
|
|
268
|
+
(emitEvents ?
|
|
269
|
+
` Events: ${events.delivered} delivered, ${events.failed} failed (run ${runId}).` :
|
|
270
|
+
""));
|
|
151
271
|
}
|
|
152
272
|
main()
|
|
153
273
|
.then(() => process.exit(0))
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AWS credentials for the published bucket, required rather than implicit.
|
|
3
|
+
*
|
|
4
|
+
* Building an `S3Client` with no credentials succeeds: the SDK defers to its
|
|
5
|
+
* provider chain and only fails at the first request, with
|
|
6
|
+
* "Could not load credentials from any providers". Inside a background job that
|
|
7
|
+
* surfaces minutes later, detached from the cause. Validating up front turns it
|
|
8
|
+
* into an obvious misconfiguration.
|
|
9
|
+
*
|
|
10
|
+
* Note this deliberately rejects the empty-string fallback pattern
|
|
11
|
+
* (`process.env.X || ""`): passing empty credentials defeats the provider chain
|
|
12
|
+
* instead of failing, which is how the opaque error appeared in the first place.
|
|
13
|
+
*/
|
|
14
|
+
export type AwsCredentials = {
|
|
15
|
+
accessKeyId: string;
|
|
16
|
+
secretAccessKey: string;
|
|
17
|
+
};
|
|
18
|
+
export declare function requireAwsCredentials(): AwsCredentials;
|
|
19
|
+
export declare function requireS3PackagesBucket(): string;
|
|
20
|
+
export declare function awsRegion(): string;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* AWS credentials for the published bucket, required rather than implicit.
|
|
4
|
+
*
|
|
5
|
+
* Building an `S3Client` with no credentials succeeds: the SDK defers to its
|
|
6
|
+
* provider chain and only fails at the first request, with
|
|
7
|
+
* "Could not load credentials from any providers". Inside a background job that
|
|
8
|
+
* surfaces minutes later, detached from the cause. Validating up front turns it
|
|
9
|
+
* into an obvious misconfiguration.
|
|
10
|
+
*
|
|
11
|
+
* Note this deliberately rejects the empty-string fallback pattern
|
|
12
|
+
* (`process.env.X || ""`): passing empty credentials defeats the provider chain
|
|
13
|
+
* instead of failing, which is how the opaque error appeared in the first place.
|
|
14
|
+
*/
|
|
15
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
+
exports.requireAwsCredentials = requireAwsCredentials;
|
|
17
|
+
exports.requireS3PackagesBucket = requireS3PackagesBucket;
|
|
18
|
+
exports.awsRegion = awsRegion;
|
|
19
|
+
function requireAwsCredentials() {
|
|
20
|
+
const accessKeyId = (process.env.AWS_ACCESS_KEY_ID || "").trim();
|
|
21
|
+
const secretAccessKey = (process.env.AWS_SECRET_ACCESS_KEY || "").trim();
|
|
22
|
+
const missing = [];
|
|
23
|
+
if (!accessKeyId) {
|
|
24
|
+
missing.push("AWS_ACCESS_KEY_ID");
|
|
25
|
+
}
|
|
26
|
+
if (!secretAccessKey) {
|
|
27
|
+
missing.push("AWS_SECRET_ACCESS_KEY");
|
|
28
|
+
}
|
|
29
|
+
if (missing.length > 0) {
|
|
30
|
+
throw new Error(`${missing.join(" and ")} (env) ${missing.length > 1 ? "are" : "is"} required to reach the published package bucket`);
|
|
31
|
+
}
|
|
32
|
+
return { accessKeyId, secretAccessKey };
|
|
33
|
+
}
|
|
34
|
+
function requireS3PackagesBucket() {
|
|
35
|
+
const bucket = (process.env.S3_PACKAGES_BUCKET || "").trim();
|
|
36
|
+
if (!bucket) {
|
|
37
|
+
throw new Error("S3_PACKAGES_BUCKET (env) is required: it names the bucket holding published packages");
|
|
38
|
+
}
|
|
39
|
+
return bucket;
|
|
40
|
+
}
|
|
41
|
+
function awsRegion() {
|
|
42
|
+
return (process.env.AWS_REGION || "").trim() || "us-east-1";
|
|
43
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { fetchPackageInfo, sendPublishEvent } from "../publishEvents";
|
|
2
|
+
import { CourseDescriptionsStorage, GenerateCourseDescriptionsResult } from "./generateCourseDescriptions";
|
|
3
|
+
/**
|
|
4
|
+
* `package_manifest_updated` for courses reconciled by the backfill.
|
|
5
|
+
*
|
|
6
|
+
* The backfill regenerates descriptions of packages that are already published,
|
|
7
|
+
* so breathecode needs the same notification a publication sends — but there is
|
|
8
|
+
* no publication here: no `package_published` precedes this event, and no
|
|
9
|
+
* journal tracks it (the sweep resumes publications, and a backfill is simply
|
|
10
|
+
* re-run instead).
|
|
11
|
+
*
|
|
12
|
+
* That is what makes `publish_id` awkward. The field exists to correlate the two
|
|
13
|
+
* events of one publication, a pair this event has no half of. Since it is
|
|
14
|
+
* required, the value has to be something that cannot be mistaken for a real
|
|
15
|
+
* publication: a bare uuid — the obvious choice — would be exactly that mistake,
|
|
16
|
+
* and if breathecode ever starts pairing events it would search forever for a
|
|
17
|
+
* `package_published` that was never sent.
|
|
18
|
+
*
|
|
19
|
+
* Hence `backfill-{runId}-{slug}`:
|
|
20
|
+
*
|
|
21
|
+
* - the `backfill-` prefix makes the origin readable in the webhook log and
|
|
22
|
+
* discriminable in code (`isBackfillPublishId`); real ids are bare uuids from
|
|
23
|
+
* `createJournal`, so the two spaces cannot collide;
|
|
24
|
+
* - `{slug}` keeps it unique per event, which a per-run id alone would not be;
|
|
25
|
+
* - a `{runId}` shared by every course of one invocation makes "every event from
|
|
26
|
+
* the run I fired at 15:40" a single substring query, which is the question
|
|
27
|
+
* you actually ask when the catalogue is backfilled in small batches.
|
|
28
|
+
*
|
|
29
|
+
* Encoding this in `publish_id`, rather than adding an `origin` field, keeps the
|
|
30
|
+
* event contract untouched: breathecode ignores `publish_id` today, so this
|
|
31
|
+
* costs them nothing and needs no coordination. If they ever start consuming the
|
|
32
|
+
* provenance, it can be promoted to a field of its own.
|
|
33
|
+
*/
|
|
34
|
+
export declare const BACKFILL_PUBLISH_ID_PREFIX = "backfill-";
|
|
35
|
+
/** One per script invocation, shared by every course it touches. */
|
|
36
|
+
export declare function newBackfillRunId(): string;
|
|
37
|
+
export declare function backfillPublishId(runId: string, courseSlug: string): string;
|
|
38
|
+
export declare function isBackfillPublishId(publishId: string): boolean;
|
|
39
|
+
/**
|
|
40
|
+
* `skipped` means the event was never attempted, which is not a failure: a
|
|
41
|
+
* course the backfill did not change has nothing to announce.
|
|
42
|
+
*/
|
|
43
|
+
export type BackfillEventOutcome = "skipped" | "delivered" | "failed";
|
|
44
|
+
export type EmitBackfillManifestEventParams = {
|
|
45
|
+
courseSlug: string;
|
|
46
|
+
runId: string;
|
|
47
|
+
result: GenerateCourseDescriptionsResult;
|
|
48
|
+
storage: CourseDescriptionsStorage;
|
|
49
|
+
rigobotToken: string;
|
|
50
|
+
breathecodeToken: string;
|
|
51
|
+
/** Injection points for tests. */
|
|
52
|
+
emit?: typeof sendPublishEvent;
|
|
53
|
+
readPackageInfo?: typeof fetchPackageInfo;
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* Announce one reconciled course. Never throws: the descriptions and the
|
|
57
|
+
* manifest are already written by the time this runs, and losing the
|
|
58
|
+
* notification must not turn a successful course into a failed one.
|
|
59
|
+
*/
|
|
60
|
+
export declare function emitBackfillManifestEvent(params: EmitBackfillManifestEventParams): Promise<BackfillEventOutcome>;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.BACKFILL_PUBLISH_ID_PREFIX = void 0;
|
|
4
|
+
exports.newBackfillRunId = newBackfillRunId;
|
|
5
|
+
exports.backfillPublishId = backfillPublishId;
|
|
6
|
+
exports.isBackfillPublishId = isBackfillPublishId;
|
|
7
|
+
exports.emitBackfillManifestEvent = emitBackfillManifestEvent;
|
|
8
|
+
const uuid_1 = require("uuid");
|
|
9
|
+
const publishEvents_1 = require("../publishEvents");
|
|
10
|
+
/**
|
|
11
|
+
* `package_manifest_updated` for courses reconciled by the backfill.
|
|
12
|
+
*
|
|
13
|
+
* The backfill regenerates descriptions of packages that are already published,
|
|
14
|
+
* so breathecode needs the same notification a publication sends — but there is
|
|
15
|
+
* no publication here: no `package_published` precedes this event, and no
|
|
16
|
+
* journal tracks it (the sweep resumes publications, and a backfill is simply
|
|
17
|
+
* re-run instead).
|
|
18
|
+
*
|
|
19
|
+
* That is what makes `publish_id` awkward. The field exists to correlate the two
|
|
20
|
+
* events of one publication, a pair this event has no half of. Since it is
|
|
21
|
+
* required, the value has to be something that cannot be mistaken for a real
|
|
22
|
+
* publication: a bare uuid — the obvious choice — would be exactly that mistake,
|
|
23
|
+
* and if breathecode ever starts pairing events it would search forever for a
|
|
24
|
+
* `package_published` that was never sent.
|
|
25
|
+
*
|
|
26
|
+
* Hence `backfill-{runId}-{slug}`:
|
|
27
|
+
*
|
|
28
|
+
* - the `backfill-` prefix makes the origin readable in the webhook log and
|
|
29
|
+
* discriminable in code (`isBackfillPublishId`); real ids are bare uuids from
|
|
30
|
+
* `createJournal`, so the two spaces cannot collide;
|
|
31
|
+
* - `{slug}` keeps it unique per event, which a per-run id alone would not be;
|
|
32
|
+
* - a `{runId}` shared by every course of one invocation makes "every event from
|
|
33
|
+
* the run I fired at 15:40" a single substring query, which is the question
|
|
34
|
+
* you actually ask when the catalogue is backfilled in small batches.
|
|
35
|
+
*
|
|
36
|
+
* Encoding this in `publish_id`, rather than adding an `origin` field, keeps the
|
|
37
|
+
* event contract untouched: breathecode ignores `publish_id` today, so this
|
|
38
|
+
* costs them nothing and needs no coordination. If they ever start consuming the
|
|
39
|
+
* provenance, it can be promoted to a field of its own.
|
|
40
|
+
*/
|
|
41
|
+
exports.BACKFILL_PUBLISH_ID_PREFIX = "backfill-";
|
|
42
|
+
/** One per script invocation, shared by every course it touches. */
|
|
43
|
+
function newBackfillRunId() {
|
|
44
|
+
return (0, uuid_1.v4)();
|
|
45
|
+
}
|
|
46
|
+
function backfillPublishId(runId, courseSlug) {
|
|
47
|
+
return `${exports.BACKFILL_PUBLISH_ID_PREFIX}${runId}-${courseSlug}`;
|
|
48
|
+
}
|
|
49
|
+
function isBackfillPublishId(publishId) {
|
|
50
|
+
return publishId.startsWith(exports.BACKFILL_PUBLISH_ID_PREFIX);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Announce one reconciled course. Never throws: the descriptions and the
|
|
54
|
+
* manifest are already written by the time this runs, and losing the
|
|
55
|
+
* notification must not turn a successful course into a failed one.
|
|
56
|
+
*/
|
|
57
|
+
async function emitBackfillManifestEvent(params) {
|
|
58
|
+
var _a, _b;
|
|
59
|
+
const { courseSlug, result } = params;
|
|
60
|
+
if (result.status === "skipped") {
|
|
61
|
+
return "skipped";
|
|
62
|
+
}
|
|
63
|
+
const emit = (_a = params.emit) !== null && _a !== void 0 ? _a : publishEvents_1.sendPublishEvent;
|
|
64
|
+
const readPackageInfo = (_b = params.readPackageInfo) !== null && _b !== void 0 ? _b : publishEvents_1.fetchPackageInfo;
|
|
65
|
+
// Enriching the payload must never prevent the delivery: both the manifest and
|
|
66
|
+
// the package info are optional in the contract, and an event with less
|
|
67
|
+
// context beats an event that never arrives.
|
|
68
|
+
let manifest = null;
|
|
69
|
+
try {
|
|
70
|
+
if (params.storage.readManifest) {
|
|
71
|
+
manifest = await params.storage.readManifest(courseSlug);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
console.error(`[backfill-events] Could not read the manifest of "${courseSlug}", reporting without it:`, error.message);
|
|
76
|
+
}
|
|
77
|
+
let packageInfo = null;
|
|
78
|
+
try {
|
|
79
|
+
packageInfo = await readPackageInfo(courseSlug, params.rigobotToken);
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
console.error(`[backfill-events] Could not read the package "${courseSlug}", reporting without it:`, error.message);
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
const delivered = await emit((0, publishEvents_1.buildPackageManifestUpdatedEvent)({
|
|
86
|
+
publishId: backfillPublishId(params.runId, courseSlug),
|
|
87
|
+
courseSlug,
|
|
88
|
+
packageInfo,
|
|
89
|
+
manifest,
|
|
90
|
+
},
|
|
91
|
+
// A failed projection does not fail the run — the syllabus is saved
|
|
92
|
+
// either way — but this event reports the manifest, so it cannot claim
|
|
93
|
+
// success over one that was never rewritten.
|
|
94
|
+
result.status === "failed" || result.manifestProjected === false ?
|
|
95
|
+
"failed" :
|
|
96
|
+
"success", {
|
|
97
|
+
generated: result.generated,
|
|
98
|
+
failed: result.failed,
|
|
99
|
+
missing: result.missing,
|
|
100
|
+
}), params.breathecodeToken);
|
|
101
|
+
return delivered ? "delivered" : "failed";
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
console.error(`[backfill-events] Could not deliver the manifest event for "${courseSlug}":`, error.message);
|
|
105
|
+
return "failed";
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -53,6 +53,13 @@ export type GenerateCourseDescriptionsResult = {
|
|
|
53
53
|
errors: string[];
|
|
54
54
|
/** Completion durations, to watch how close we run to the request limits. */
|
|
55
55
|
durationsSeconds: number[];
|
|
56
|
+
/**
|
|
57
|
+
* Whether `package-manifest.json` was re-projected; null when the storage does
|
|
58
|
+
* not project at all. A failed projection is not a failed run — the syllabus,
|
|
59
|
+
* which is the source of truth, is already saved — but callers that announce
|
|
60
|
+
* the manifest downstream must not report success on it.
|
|
61
|
+
*/
|
|
62
|
+
manifestProjected: boolean | null;
|
|
56
63
|
};
|
|
57
64
|
/** Read every README of the course, keyed by exercise slug and language. */
|
|
58
65
|
export declare function readCourseReadmes(storage: CourseDescriptionsStorage, courseSlug: string, exercises: CourseExercise[], concurrency: number): Promise<CourseReadmes>;
|
|
@@ -29,6 +29,7 @@ function emptyResult(status) {
|
|
|
29
29
|
missing: 0,
|
|
30
30
|
errors: [],
|
|
31
31
|
durationsSeconds: [],
|
|
32
|
+
manifestProjected: null,
|
|
32
33
|
};
|
|
33
34
|
}
|
|
34
35
|
/** Read every README of the course, keyed by exercise slug and language. */
|
|
@@ -162,10 +163,12 @@ async function generateCourseDescriptions(storage, courseSlug, options) {
|
|
|
162
163
|
if (storage.reprojectManifest) {
|
|
163
164
|
try {
|
|
164
165
|
await storage.reprojectManifest(courseSlug);
|
|
166
|
+
result.manifestProjected = true;
|
|
165
167
|
}
|
|
166
168
|
catch (error) {
|
|
167
169
|
// The syllabus (the source of truth) is already saved; a failed
|
|
168
170
|
// projection is recoverable and must not lose the generated text.
|
|
171
|
+
result.manifestProjected = false;
|
|
169
172
|
result.errors.push(`manifest projection failed: ${error.message}`);
|
|
170
173
|
}
|
|
171
174
|
}
|
|
@@ -212,10 +212,21 @@ async function emitManifestUpdated(params) {
|
|
|
212
212
|
});
|
|
213
213
|
return;
|
|
214
214
|
}
|
|
215
|
+
// Enriching the payload must never prevent the delivery. The manifest is
|
|
216
|
+
// optional in the contract (`manifest: null` is valid); the event is not:
|
|
217
|
+
// breathecode was promised one and is waiting for it. Reading the manifest
|
|
218
|
+
// from the published bucket can fail for reasons that have nothing to do with
|
|
219
|
+
// the event — a bad AWS credential, say — and an event with less context still
|
|
220
|
+
// beats an event that never arrives.
|
|
221
|
+
let updatedManifest = params.eventContext.manifest;
|
|
222
|
+
try {
|
|
223
|
+
updatedManifest =
|
|
224
|
+
(_c = (await ((_b = (_a = params.publishedStorage).readManifest) === null || _b === void 0 ? void 0 : _b.call(_a, params.courseSlug)))) !== null && _c !== void 0 ? _c : params.eventContext.manifest;
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
console.error(`[descriptions] Could not read the updated manifest for "${params.courseSlug}", reporting without it:`, error.message);
|
|
228
|
+
}
|
|
215
229
|
try {
|
|
216
|
-
// Report the manifest as it is NOW: the descriptions are the whole point of
|
|
217
|
-
// this event, so the one captured at publish time would be stale.
|
|
218
|
-
const updatedManifest = (_c = (await ((_b = (_a = params.publishedStorage).readManifest) === null || _b === void 0 ? void 0 : _b.call(_a, params.courseSlug)))) !== null && _c !== void 0 ? _c : params.eventContext.manifest;
|
|
219
230
|
const generation = params.outcome.generation;
|
|
220
231
|
const delivered = await params.emit((0, publishEvents_1.buildPackageManifestUpdatedEvent)(Object.assign(Object.assign({}, params.eventContext), { manifest: updatedManifest }), params.outcome.succeeded ? "success" : "failed", generation ?
|
|
221
232
|
{
|
|
@@ -92,15 +92,30 @@ async function emitPendingManifestEvent(journal, deps, succeeded, stats) {
|
|
|
92
92
|
});
|
|
93
93
|
return;
|
|
94
94
|
}
|
|
95
|
+
// Enriching the payload must never prevent the delivery: this event is the
|
|
96
|
+
// one breathecode has been waiting for since the publication stalled, and
|
|
97
|
+
// both the manifest and the package info are optional in the contract.
|
|
98
|
+
let manifest = null;
|
|
99
|
+
let packageInfo = null;
|
|
95
100
|
try {
|
|
96
|
-
let manifest = null;
|
|
97
101
|
if (deps.publishedStorage.readManifest) {
|
|
98
102
|
manifest = await deps.publishedStorage.readManifest(journal.courseSlug);
|
|
99
103
|
}
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
console.error(`[sweep] Could not read the manifest of "${journal.courseSlug}", reporting without it:`, error.message);
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
packageInfo = await readPackageInfo(journal.courseSlug, deps.rigobotToken);
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
console.error(`[sweep] Could not read the package of "${journal.courseSlug}", reporting without it:`, error.message);
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
100
115
|
const delivered = await emit((0, publishEvents_1.buildPackageManifestUpdatedEvent)({
|
|
101
116
|
publishId: journal.publishId,
|
|
102
117
|
courseSlug: journal.courseSlug,
|
|
103
|
-
packageInfo
|
|
118
|
+
packageInfo,
|
|
104
119
|
manifest,
|
|
105
120
|
}, succeeded ? "success" : "failed", stats), deps.breathecodeToken);
|
|
106
121
|
await (0, publishJournal_1.markStage)(deps.journalStorage, journal, "manifestEvent", delivered ? "done" : "failed", { error: delivered ? undefined : "delivery failed" });
|
|
@@ -9,6 +9,7 @@ const client_s3_1 = require("@aws-sdk/client-s3");
|
|
|
9
9
|
const client_cloudfront_1 = require("@aws-sdk/client-cloudfront");
|
|
10
10
|
const packageManifest_1 = require("../packageManifest");
|
|
11
11
|
const packageManifestBackfill_1 = require("../s3/packageManifestBackfill");
|
|
12
|
+
const awsCredentials_1 = require("../awsCredentials");
|
|
12
13
|
/**
|
|
13
14
|
* Published-bucket (S3) adapter for the description flows.
|
|
14
15
|
*
|
|
@@ -68,14 +69,18 @@ function createS3SyllabusSyncStorage(s3, bucket) {
|
|
|
68
69
|
* post-publish stage and the sweep, which have no CLI flags to read.
|
|
69
70
|
*/
|
|
70
71
|
function createS3DescriptionsStorageFromEnv() {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const
|
|
72
|
+
// Validated here so a missing variable fails with its own name, instead of
|
|
73
|
+
// surfacing minutes later as "Could not load credentials from any providers"
|
|
74
|
+
// from inside a background job.
|
|
75
|
+
const credentials = (0, awsCredentials_1.requireAwsCredentials)();
|
|
76
|
+
const region = (0, awsCredentials_1.awsRegion)();
|
|
77
|
+
const s3 = new client_s3_1.S3Client({ region, credentials });
|
|
78
|
+
const distributionId = (process.env.CLOUDFRONT_DISTRIBUTION_ID || "").trim();
|
|
75
79
|
const cloudFront = distributionId ?
|
|
76
80
|
{
|
|
77
81
|
client: new client_cloudfront_1.CloudFrontClient({
|
|
78
|
-
region
|
|
82
|
+
region,
|
|
83
|
+
credentials,
|
|
79
84
|
}),
|
|
80
85
|
distributionId,
|
|
81
86
|
} :
|
|
@@ -83,7 +88,9 @@ function createS3DescriptionsStorageFromEnv() {
|
|
|
83
88
|
if (!cloudFront) {
|
|
84
89
|
console.warn("[descriptions] CLOUDFRONT_DISTRIBUTION_ID is not set: the manifest will be re-projected but not invalidated");
|
|
85
90
|
}
|
|
86
|
-
return createS3DescriptionsStorage(s3,
|
|
91
|
+
return createS3DescriptionsStorage(s3, (0, awsCredentials_1.requireS3PackagesBucket)(), {
|
|
92
|
+
cloudFront,
|
|
93
|
+
});
|
|
87
94
|
}
|
|
88
95
|
function createS3DescriptionsStorage(s3, bucket, options = {}) {
|
|
89
96
|
const storage = {
|
|
@@ -48,6 +48,14 @@ export type PackageManifestBuildInput = {
|
|
|
48
48
|
};
|
|
49
49
|
export declare function packageManifestGcsPath(courseSlug: string): string;
|
|
50
50
|
export declare function resolvePreviewUrl(slug: string, preview?: string): string;
|
|
51
|
+
/**
|
|
52
|
+
* Lesson id when no syllabus entry matches: the numeric prefix of the folder.
|
|
53
|
+
*
|
|
54
|
+
* Exported because `legacyPackageRepair` has to synthesize syllabus ids with
|
|
55
|
+
* exactly this rule — a repaired package whose ids differed from the ones its
|
|
56
|
+
* manifest already carries would look like a content change to every consumer.
|
|
57
|
+
*/
|
|
58
|
+
export declare function deriveLessonId(exerciseSlug: string): string;
|
|
51
59
|
export declare function extractReadmeH1(body: string): string | null;
|
|
52
60
|
export declare function normalizeReadmeTitle(raw: string): string | null;
|
|
53
61
|
export declare function buildPackageManifestFromSources(input: PackageManifestBuildInput): PackageManifest;
|
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.DESCRIPTION_TARGET_WORD_COUNT = exports.DESCRIPTION_PROMPT_VERSION = exports.SCHEMA_VERSION = exports.PACKAGE_MANIFEST_REL_PATH = exports.PACKAGE_MANIFEST_FILENAME = void 0;
|
|
4
4
|
exports.packageManifestGcsPath = packageManifestGcsPath;
|
|
5
5
|
exports.resolvePreviewUrl = resolvePreviewUrl;
|
|
6
|
+
exports.deriveLessonId = deriveLessonId;
|
|
6
7
|
exports.extractReadmeH1 = extractReadmeH1;
|
|
7
8
|
exports.normalizeReadmeTitle = normalizeReadmeTitle;
|
|
8
9
|
exports.buildPackageManifestFromSources = buildPackageManifestFromSources;
|
|
@@ -49,6 +50,13 @@ function findSyllabusLesson(exerciseSlug, lessons) {
|
|
|
49
50
|
return candidates.includes(exerciseSlug);
|
|
50
51
|
});
|
|
51
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* Lesson id when no syllabus entry matches: the numeric prefix of the folder.
|
|
55
|
+
*
|
|
56
|
+
* Exported because `legacyPackageRepair` has to synthesize syllabus ids with
|
|
57
|
+
* exactly this rule — a repaired package whose ids differed from the ones its
|
|
58
|
+
* manifest already carries would look like a content change to every consumer.
|
|
59
|
+
*/
|
|
52
60
|
function deriveLessonId(exerciseSlug) {
|
|
53
61
|
const match = exerciseSlug.match(/^(\d+(?:\.\d+)?)/);
|
|
54
62
|
return match ? match[1] : exerciseSlug;
|