update_remote_settings_records.mjs (272 lines of code) (raw):

/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ // This script consumes the following env variables: // - AUTHORIZATION (mandatory): Raw authorization header (e.g. `AUTHORIZATION='Bearer XXXXXXXXXXXXX'`) // - SERVER (mandatory): Writer server URL (eg. https://remote-settings.allizom.org/v1) // - ENVIRONMENT (optional): dev, stage, prod. When set to `dev`, the script will approve its own changes. // - DRY_RUN (optional): If set to 1, no changes will be made to the collection, this will // only log the actions that would be done. // This node script fetches `https://crash-pings.mozilla.com/<process>_<channel>-crash-ids.json` // files and updates the RemoteSettings records to match the current top // crashers. import { spawn } from "node:child_process"; import { readFile, readdir } from "node:fs/promises"; import fetch from "node-fetch"; import btoa from "btoa"; const SUCCESS_RET_VALUE = 0; const FAILURE_RET_VALUE = 1; const VALID_ENVIRONMENTS = ["dev", "stage", "prod"]; // Sanity check of environment variable inputs if (!process.env.AUTHORIZATION) { console.error(`AUTHORIZATION environment variable needs to be set`); process.exit(FAILURE_RET_VALUE); } if (!process.env.SERVER) { console.error( `SERVER environment variable needs to be set` ); process.exit(FAILURE_RET_VALUE); } if ( process.env.ENVIRONMENT && !VALID_ENVIRONMENTS.includes(process.env.ENVIRONMENT) ) { console.error( `ENVIRONMENT environment variable needs to be set to one of the following values: ${VALID_ENVIRONMENTS.join( ", " )}` ); process.exit(FAILURE_RET_VALUE); } const IS_DRY_RUN = process.env.DRY_RUN == "1"; const COLLECTION_NAME = "crash-reports-ondemand" const RS_COLLECTION_ENDPOINT = `${process.env.SERVER}/buckets/main-workspace/collections/${COLLECTION_NAME}`; const RS_RECORDS_ENDPOINT = `${RS_COLLECTION_ENDPOINT}/records`; const CRASH_PINGS_URL = "https://firefox-ci-tc.services.mozilla.com/api/index/v1/task/mozilla.v2.process-top-crashes.latest.process-pings/artifacts/public/crash-ids.tar.gz"; const PROCESSES = ["gpu", "gmplugin", "rdd", "socket", "utility"]; const CHANNELS = ["nightly", "beta", "release"]; const HEADERS = { "Content-Type": "application/json", Authorization: process.env.AUTHORIZATION.startsWith("Bearer ") ? process.env.AUTHORIZATION : `Basic ${btoa(process.env.AUTHORIZATION)}`, } update() .then(() => { return process.exit(SUCCESS_RET_VALUE); }) .catch((e) => { console.error(e); return process.exit(FAILURE_RET_VALUE); }); async function update() { const { lastModified, data: previousRecords } = await getRSData(); const crashIds = await getCrashIds(lastModified); if (!crashIds) { console.log(`No changes necessary: crash ids not modified since last update ✅`); return; } if (!await unpackTarball(crashIds)) { throw new Error('failed to unpack child-ids tarball'); } const createdIds = new Set(); for (const channel of CHANNELS) { for (const proc of PROCESSES) { for await (const topCrashers of getTopCrashersFor(channel, proc)) { for (const [sighash, {hashes, description}] of Object.entries(topCrashers)) { const recordId = `id-${String(createdIds.size).padStart(3, '0')}`; const rsDescription = `${proc} (${channel}): ${description}`; if (typeof description !== "string") { throw new Error(`malformed description data for ${proc} (${channel}) ${sighash}`); } if (hashes.some(v => typeof v !== "string")) { throw new Error(`malformed hashes data for ${proc} (${channel}) ${description}`); } await upsertRecord(recordId, rsDescription, hashes); createdIds.add(recordId); } } } } // Delete all extraneous records. for (const record of previousRecords) { if (!createdIds.has(record.id)) { await deleteRecord(record); } } console.log("Crash id lists synced ✅"); await approveChanges(); } async function getCrashIds(if_modified_since) { console.log(`Get crash ids from ${CRASH_PINGS_URL}`); const headers = new Headers(); if (if_modified_since) { headers.append("If-Modified-Since", if_modified_since); } const response = await fetch(CRASH_PINGS_URL, { method: 'GET', headers }); if (response.status === 304) { console.log(`Crash ids not modified since ${if_modified_since}`); return false; } require200(response, "Can't retrieve crash ids"); return response.body; } async function unpackTarball(readableStream) { console.log("Unpacking child ids tarball"); const child = spawn("tar", ["-xzf", "-"]); readableStream.pipe(child.stdin); return await new Promise((resolve) => { child.on('close', code => { const success = code === 0; if (!success) { console.log(`tar exited with error code ${code}`); console.group("tar stdout"); console.log(child.stdout.read().toString()); console.groupEnd(); console.group("tar stderr"); console.log(child.stderr.read().toString()); console.groupEnd(); } resolve(success); }); }); } function require200(response, context) { if (response.status !== 200) { throw new Error( `${context}: "[${response.status}] ${response.statusText}"` ); } } async function checkStatus(response, expectedStatuses, errorMessage) { const successful = Array.isArray(expectedStatuses) ? expectedStatuses.includes(response.status) : response.status == expectedStatuses; if (!successful) { const body = await response.text(); console.warn( `${errorMessage}: "[${response.status}] ${response.statusText}" ${body}` ); } return successful; } async function* getTopCrashersFor(channel, process) { console.log(`Get top crashers for ${process} ${channel}`); // Some extra files exist (like for utility subprocesses), so read all files // matching the glob `crash-ids/${process}_${channel}*.json`. const files = (await readdir("crash-ids")) .filter(file => file.startsWith(`${process}_${channel}`) && file.endsWith(".json")) .map(file => `crash-ids/${file}`); for (const file of files) { try { yield JSON.parse(await readFile(file, { encoding: 'utf8' })); } catch (e) { console.warn(`failed to read ${file}: ${e}`); continue; } } } async function getRSData() { console.log(`Get existing data from ${RS_COLLECTION_ENDPOINT}`); const response = await fetch(RS_RECORDS_ENDPOINT, { method: "GET", headers: HEADERS, }); require200(response, "Can't retrieve records"); const lastModified = response.headers.get("Last-Modified"); const { data } = await response.json(); return { data, lastModified }; } function dryRunnable(log, f) { return async function(...args) { if (IS_DRY_RUN) { console.log("[DRY_RUN]", ...(typeof log == "string" ? [log] : log(...args))); return true; } else { console.log(...(typeof log == "string" ? [log] : log(...args))); } return await f(...args); }; } async function getLastModified() { console.log(`Get last modified time from ${RS_RECORDS_ENDPOINT}`); const response = await fetch(RS_RECORDS_ENDPOINT, { method: "HEAD", headers: HEADERS, }); require200(response, "Can't retrieve last modified"); return response.headers.get("Last-Modified") || false; } /** * Create a record on RemoteSettings * * @param {Object} browserMdn: An item from the result of getFlatBrowsersMdnData * @returns {Boolean} Whether the API call was successful or not */ const upsertRecord = dryRunnable( (description) => ["Create", description], async (recordId, description, hashes) => { const response = await fetch(`${RS_RECORDS_ENDPOINT}/${recordId}`, { method: "PUT", body: JSON.stringify({ data: { description, hashes } }), headers: HEADERS, }); return await checkStatus(response, [200, 201], "Couldn't create record"); }, ); /** * Remove a record on RemoteSettings * * @param {Object} record: The existing record on RemoteSettings * @returns {Boolean} Whether the API call was successful or not */ const deleteRecord = dryRunnable( (record) => ["Delete", record.id, record.description], async (record) => { const response = await fetch(`${RS_RECORDS_ENDPOINT}/${record.id}`, { method: "DELETE", headers: HEADERS, }); return await checkStatus(response, 200, "Couldn't delete record"); }, ); /** * Remove all records on RemoteSettings * * @returns {Boolean} Whether the API call was successful or not */ const deleteAllRecords = dryRunnable("Delete all records", async () => { const response = await fetch(RS_RECORDS_ENDPOINT, { method: "DELETE", headers: HEADERS, }); return await checkStatus(response, 200, "Couldn't delete all records"); }); const requestReview = dryRunnable("Requesting review", async () => { const response = await fetch(RS_COLLECTION_ENDPOINT, { method: "PATCH", body: JSON.stringify({ data: { status: "to-review" } }), headers: HEADERS, }); if (await checkStatus(response, 200, "Couldn't request review")) { console.log("Review requested ✅"); } }); /** * Automatically approve changes made on the collection. * ⚠️ This only works on the `dev` server. */ const approveChanges = dryRunnable("Approving changes", async () => { const response = await fetch(RS_COLLECTION_ENDPOINT, { method: "PATCH", body: JSON.stringify({ data: { status: "to-sign" } }), headers: HEADERS, }); if (await checkStatus(response, 200, "Couldn't approve changes")) { console.log("Changes approved ✅"); } });