diff --git a/README.md b/README.md
index 66b92dd..7170d8d 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,8 @@ Known and addressed issues for newly released versions are easily added using th
- Copy the issue table's HTML from the webpage using devtools.
- Fill out the process.html page's fields and paste in the table HTML.
- Download the Markdown file and put it into the correct folder.
-- Fix up the products.json file manually or run `update_products_from_issues.py`.
+- Run `npm run update:generated` to update `products.json`, append new stable
+ product-tree manifest slots, and regenerate rendered test fixtures.
- Submit a pull request.
There is intentionally no automated scaping of Palo Alto's website, to avoid abuse of server resources. Also releases are not that frequent. A crawler to grab some data from the Common Crawl dataset was started but never really finished.
diff --git a/package.json b/package.json
index daa860a..4008ba1 100644
--- a/package.json
+++ b/package.json
@@ -4,6 +4,9 @@
"type": "module",
"scripts": {
"test": "node --test",
+ "update:products": "node scripts/update-products-from-issues.mjs",
+ "update:fixtures": "node scripts/generate-rendered-fixtures.mjs",
+ "update:generated": "node scripts/refresh-generated-data.mjs",
"vendor:marked": "mkdir -p web/vendor && cp node_modules/marked/lib/marked.esm.js web/vendor/marked.esm.js"
},
"devDependencies": {
diff --git a/scripts/generate-rendered-fixtures.mjs b/scripts/generate-rendered-fixtures.mjs
index 82d9cda..75b2243 100644
--- a/scripts/generate-rendered-fixtures.mjs
+++ b/scripts/generate-rendered-fixtures.mjs
@@ -1,11 +1,11 @@
/**
* Generates expected-parsed.json for each fixture directory that contains expected.md.
- * Run manually: node test/generate-rendered-fixtures.mjs
+ * Run manually: node scripts/generate-rendered-fixtures.mjs
*/
import { readFileSync, writeFileSync, readdirSync } from 'node:fs';
-import { join } from 'node:path';
-import { fileURLToPath } from 'node:url';
+import { join, resolve } from 'node:path';
+import { fileURLToPath, pathToFileURL } from 'node:url';
import { JSDOM } from 'jsdom';
const { window } = new JSDOM('
');
@@ -20,31 +20,38 @@ globalThis.fetch = () => Promise.resolve({
const { parseMarkdownIssues, markdownSummaryToHtml } = await import('../web/js/issues.js');
const __dirname = fileURLToPath(new URL('.', import.meta.url));
-const fixturesDir = join(__dirname, '../test/fixtures');
+const defaultFixturesDir = join(__dirname, '../test/fixtures');
-const fixtures = readdirSync(fixturesDir).filter(name => {
- const contents = readdirSync(join(fixturesDir, name)).map(d => d);
- return contents.includes('expected.md');
-});
+export function generateRenderedFixtures(fixturesDir = defaultFixturesDir) {
+ const fixtures = readdirSync(fixturesDir).filter(name => {
+ const contents = readdirSync(join(fixturesDir, name)).map(d => d);
+ return contents.includes('expected.md');
+ });
-for (const fixtureId of fixtures) {
- const fixturePath = join(fixturesDir, fixtureId);
- const markdownText = readFileSync(join(fixturePath, 'expected.md'), 'utf-8');
- const parsed = parseMarkdownIssues(markdownText);
+ for (const fixtureId of fixtures) {
+ const fixturePath = join(fixturesDir, fixtureId);
+ const markdownText = readFileSync(join(fixturePath, 'expected.md'), 'utf-8');
+ const parsed = parseMarkdownIssues(markdownText);
- const result = parsed.map(issue => ({
- id: issue.id,
- summary: issue.summary,
- resolved: issue.resolved,
- caveat: issue.caveat,
- renderedHtml: markdownSummaryToHtml(issue.summary, issue.resolved, issue.caveat)
- }));
+ const result = parsed.map(issue => ({
+ id: issue.id,
+ summary: issue.summary,
+ resolved: issue.resolved,
+ caveat: issue.caveat,
+ renderedHtml: markdownSummaryToHtml(issue.summary, issue.resolved, issue.caveat)
+ }));
- writeFileSync(
- join(fixturePath, 'expected-parsed.json'),
- JSON.stringify(result, null, 2) + '\n',
- 'utf-8'
- );
+ writeFileSync(
+ join(fixturePath, 'expected-parsed.json'),
+ JSON.stringify(result, null, 2) + '\n',
+ 'utf-8'
+ );
- console.log(`Written ${fixtureId}/expected-parsed.json (${result.length} issues)`);
+ console.log(`Written ${fixtureId}/expected-parsed.json (${result.length} issues)`);
+ }
+ return { fixtures: fixtures.length };
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
+ generateRenderedFixtures();
}
diff --git a/scripts/refresh-generated-data.mjs b/scripts/refresh-generated-data.mjs
new file mode 100644
index 0000000..eebe1e1
--- /dev/null
+++ b/scripts/refresh-generated-data.mjs
@@ -0,0 +1,7 @@
+#!/usr/bin/env node
+import { runUpdateProductsCli } from './update-products-from-issues.mjs';
+import { generateRenderedFixtures } from './generate-rendered-fixtures.mjs';
+
+await runUpdateProductsCli();
+const fixtureResult = generateRenderedFixtures();
+console.log(`Regenerated ${fixtureResult.fixtures} rendered fixture set(s).`);
diff --git a/scripts/update-products-from-issues.mjs b/scripts/update-products-from-issues.mjs
new file mode 100644
index 0000000..87e0aff
--- /dev/null
+++ b/scripts/update-products-from-issues.mjs
@@ -0,0 +1,202 @@
+#!/usr/bin/env node
+import { readdir, readFile, writeFile } from 'node:fs/promises';
+import { dirname, join, relative, resolve } from 'node:path';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+import { buildProductTree, enumerateTreePaths } from '../web/js/product-tree-model.js';
+
+const ISSUE_TYPES = new Set(['addressed', 'known']);
+const VERSION_FILENAME_RE = /^(?[^.]+(?:\.[^.]+)*)\.md$/;
+const HOTFIX_RE = /^(?\d+(?:\.\d+)*)(?:-h(?\d+))?$/;
+const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
+
+async function walkFiles(root) {
+ const files = [];
+ async function visit(directory) {
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
+ const path = join(directory, entry.name);
+ if (entry.isDirectory()) await visit(path);
+ else if (entry.isFile()) files.push(path);
+ }
+ }
+ await visit(root);
+ return files.sort();
+}
+
+export async function collectIssueFiles(issuesDir) {
+ const records = [];
+ for (const filePath of await walkFiles(issuesDir)) {
+ const parts = relative(issuesDir, filePath).split(/[\\/]/);
+ if (parts.length !== 3) continue;
+ const [product, issueType, filename] = parts;
+ if (!ISSUE_TYPES.has(issueType)) continue;
+ const match = VERSION_FILENAME_RE.exec(filename);
+ if (!match) continue;
+ records.push({ product, issueType, version: match.groups.version, filename });
+ }
+ return records;
+}
+
+function clearLeafIssueArrays(node) {
+ if (!node || typeof node !== 'object' || Array.isArray(node)) return;
+ if ('addressed' in node || 'known' in node) {
+ node.addressed = [];
+ node.known = [];
+ return;
+ }
+ Object.values(node).forEach(clearLeafIssueArrays);
+}
+
+function getPath(data, path) {
+ let current = data;
+ for (const key of path) {
+ if (!current || typeof current !== 'object' || !(key in current)) return null;
+ current = current[key];
+ }
+ return current;
+}
+
+function isIssueLeaf(node) {
+ return Boolean(node) && typeof node === 'object' && ('addressed' in node || 'known' in node);
+}
+
+function pickTargetLeafPath(products, product, version) {
+ const parts = version.split('.');
+ const major = parts[0];
+ const minor = parts.length > 1 ? `${parts[0]}.${parts[1]}` : major;
+ for (const path of [[product, major, minor], [product, major], [product]]) {
+ if (isIssueLeaf(getPath(products, path))) return path;
+ }
+ return null;
+}
+
+function parseReleasePrefix(prefix) {
+ const match = HOTFIX_RE.exec(prefix);
+ if (!match) return { base: [], hotfix: -1 };
+ return {
+ base: match.groups.base.split('.').map(Number),
+ hotfix: match.groups.hotfix === undefined ? -1 : Number(match.groups.hotfix)
+ };
+}
+
+function compareNumberArrays(left, right) {
+ const length = Math.max(left.length, right.length);
+ for (let index = 0; index < length; index++) {
+ const difference = (left[index] ?? -1) - (right[index] ?? -1);
+ if (difference) return difference;
+ }
+ return 0;
+}
+
+function compareIssueFilenames(left, right) {
+ const leftPrefix = left.replace(/\.[^.]+$/, '');
+ const rightPrefix = right.replace(/\.[^.]+$/, '');
+ const leftVersion = parseReleasePrefix(leftPrefix);
+ const rightVersion = parseReleasePrefix(rightPrefix);
+ return compareNumberArrays(leftVersion.base, rightVersion.base) ||
+ Number(leftVersion.hotfix >= 0) - Number(rightVersion.hotfix >= 0) ||
+ leftVersion.hotfix - rightVersion.hotfix ||
+ left.localeCompare(right);
+}
+
+export function rebuildProducts(products, records) {
+ clearLeafIssueArrays(products);
+ const unmatched = [];
+ records.sort((left, right) =>
+ left.product.localeCompare(right.product) ||
+ left.issueType.localeCompare(right.issueType) ||
+ left.version.localeCompare(right.version)
+ );
+
+ for (const record of records) {
+ const path = pickTargetLeafPath(products, record.product, record.version);
+ if (!path) {
+ unmatched.push(record);
+ continue;
+ }
+ const leaf = getPath(products, path);
+ const files = Array.isArray(leaf[record.issueType]) ? leaf[record.issueType] : [];
+ leaf[record.issueType] = Array.from(new Set([...files, record.filename])).sort(compareIssueFilenames);
+ }
+ return { products, unmatched };
+}
+
+export function updateManifest(manifest, currentPaths) {
+ if (!manifest || manifest.schema !== 1 || !Array.isArray(manifest.nodes)) {
+ throw new Error('Product-tree manifest must use schema 1 with a nodes array.');
+ }
+ const active = new Set();
+ manifest.nodes.forEach((path, slot) => {
+ if (path === null) return;
+ if (typeof path !== 'string' || !path) throw new Error(`Invalid manifest entry at slot ${slot}.`);
+ if (active.has(path)) throw new Error(`Duplicate manifest path: ${path}`);
+ active.add(path);
+ });
+
+ const current = new Set(currentPaths);
+ const appended = currentPaths.filter(path => !active.has(path));
+ manifest.nodes.push(...appended);
+ const missing = Array.from(active).filter(path => !current.has(path));
+ return { manifest, appended, missing };
+}
+
+export async function updateProductsFromIssues({ issuesDir, productsPath, manifestPath }) {
+ const [productsText, manifestText, records] = await Promise.all([
+ readFile(productsPath, 'utf8'),
+ readFile(manifestPath, 'utf8'),
+ collectIssueFiles(issuesDir)
+ ]);
+ if (records.length === 0) {
+ throw new Error("No '.md' issue files found; refusing to rewrite products.json");
+ }
+ const products = JSON.parse(productsText);
+ if (!products || typeof products !== 'object' || Array.isArray(products)) {
+ throw new Error('products.json must contain a top-level object.');
+ }
+
+ const rebuilt = rebuildProducts(products, records);
+ const paths = enumerateTreePaths(buildProductTree(rebuilt.products));
+ const manifestUpdate = updateManifest(JSON.parse(manifestText), paths);
+ await writeFile(productsPath, `${JSON.stringify(rebuilt.products, null, 2)}\n`, 'utf8');
+ if (manifestUpdate.appended.length > 0) {
+ await writeFile(manifestPath, `${JSON.stringify(manifestUpdate.manifest, null, 2)}\n`, 'utf8');
+ }
+ return {
+ records: records.length,
+ unmatched: rebuilt.unmatched,
+ appended: manifestUpdate.appended,
+ missing: manifestUpdate.missing
+ };
+}
+
+function parseArgs(argv) {
+ const options = {
+ issuesDir: join(REPO_ROOT, 'web/data/issues'),
+ productsPath: join(REPO_ROOT, 'web/data/products.json'),
+ manifestPath: join(REPO_ROOT, 'web/data/product-tree-manifest.json')
+ };
+ const flags = new Map([
+ ['--issues-dir', 'issuesDir'],
+ ['--products-json', 'productsPath'],
+ ['--manifest-json', 'manifestPath']
+ ]);
+ for (let index = 0; index < argv.length; index += 2) {
+ const property = flags.get(argv[index]);
+ if (!property || argv[index + 1] === undefined) throw new Error(`Invalid argument: ${argv[index] ?? ''}`);
+ options[property] = resolve(argv[index + 1]);
+ }
+ return options;
+}
+
+export async function runUpdateProductsCli(argv = process.argv.slice(2)) {
+ const options = parseArgs(argv);
+ const result = await updateProductsFromIssues(options);
+ console.log(`Updated ${options.productsPath} from ${result.records} issue files.`);
+ console.log(`Appended ${result.appended.length} manifest path(s).`);
+ if (result.missing.length) console.warn(`${result.missing.length} manifest path(s) are no longer rendered; slots were preserved.`);
+ if (result.unmatched.length) console.warn(`${result.unmatched.length} issue file(s) did not match a products.json leaf.`);
+ return result;
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
+ await runUpdateProductsCli();
+}
diff --git a/scripts/update_products_from_issues.py b/scripts/update_products_from_issues.py
deleted file mode 100644
index 754c286..0000000
--- a/scripts/update_products_from_issues.py
+++ /dev/null
@@ -1,217 +0,0 @@
-#!/usr/bin/env python3
-"""Update products.json entries from discovered issue data files."""
-
-from __future__ import annotations
-
-import argparse
-import json
-import re
-from pathlib import Path
-from typing import Any
-
-
-ISSUE_TYPES = {"addressed", "known"}
-VERSION_FILENAME_RE = re.compile(r"^(?P[^.]+(?:\.[^.]+)*)\.md$")
-HOTFIX_RE = re.compile(r"^(?P\d+(?:\.\d+)*)(?:-h(?P\d+))?$")
-
-
-def parse_args() -> argparse.Namespace:
- """Parse command line arguments for input and output paths."""
-
- parser = argparse.ArgumentParser(
- description="Update products.json from issue files."
- )
- parser.add_argument(
- "--issues-dir",
- default="web/data/issues",
- help="Directory containing product issue files.",
- )
- parser.add_argument(
- "--products-json",
- default="web/data/products.json",
- help="Path to products.json.",
- )
- return parser.parse_args()
-
-
-def collect_issue_files(issues_dir: Path) -> list[tuple[str, str, str, str]]:
- """Collect all issue files with '.md' naming."""
-
- records: list[tuple[str, str, str, str]] = []
-
- for file_path in sorted(p for p in issues_dir.rglob("*") if p.is_file()):
- relative = file_path.relative_to(issues_dir)
- if len(relative.parts) != 3:
- continue
-
- product, issue_type, filename = relative.parts
- if issue_type not in ISSUE_TYPES:
- continue
-
- version = parse_issue_filename(filename)
- if version is None:
- continue
-
- records.append((product, issue_type, version, filename))
-
- return records
-
-
-def parse_issue_filename(filename: str) -> str | None:
- """Parse '.md' issue filenames and return version."""
-
- match = VERSION_FILENAME_RE.match(filename)
- if not match:
- return None
-
- return match.group("version")
-
-
-def clear_leaf_issue_arrays(node: Any) -> None:
- """Reset addressed/known arrays on every issue leaf in products.json."""
-
- if isinstance(node, dict):
- if "addressed" in node or "known" in node:
- node["addressed"] = []
- node["known"] = []
- return
-
- for value in node.values():
- clear_leaf_issue_arrays(value)
-
-
-def is_issue_leaf(node: Any) -> bool:
- """Return whether a node is a leaf containing issue arrays."""
-
- return isinstance(node, dict) and ("addressed" in node or "known" in node)
-
-
-def pick_target_leaf_path(products: dict[str, Any], product: str, version: str) -> list[str] | None:
- """Choose the best matching product tree path for a version."""
-
- parts = version.split(".")
- if not parts:
- return None
-
- major = parts[0]
- minor = f"{parts[0]}.{parts[1]}" if len(parts) > 1 else parts[0]
-
- candidates = [
- [product, major, minor],
- [product, major],
- [product],
- ]
-
- for path in candidates:
- node = get_path(products, path)
- if is_issue_leaf(node):
- return path
-
- return None
-
-
-def get_path(data: Any, path: list[str]) -> Any:
- """Traverse nested dict keys and return the value at path, if present."""
-
- current = data
- for key in path:
- if not isinstance(current, dict) or key not in current:
- return None
- current = current[key]
- return current
-
-
-def parse_release_prefix(prefix: str) -> tuple[tuple[int, ...], int]:
- """Extract numeric base version parts and optional hotfix number."""
-
- match = HOTFIX_RE.match(prefix)
- if not match:
- return tuple(), -1
-
- base_parts = tuple(int(part) for part in match.group("base").split("."))
- hotfix_group = match.group("hotfix")
- hotfix = int(hotfix_group) if hotfix_group is not None else -1
- return base_parts, hotfix
-
-
-def filename_sort_key(filename: str) -> tuple[tuple[int, ...], int, int, str]:
- """Build a sort key that orders base versions before hotfixes."""
-
- prefix = filename.rsplit(".", 1)[0]
- base_parts, hotfix = parse_release_prefix(prefix)
- is_hotfix = 1 if hotfix >= 0 else 0
- return base_parts, is_hotfix, hotfix, filename
-
-
-def add_file_to_leaf(
- products: dict[str, Any],
- path: list[str],
- issue_type: str,
- filename: str,
-) -> None:
- """Insert an issue filename into the target leaf with stable sorting."""
-
- leaf = get_path(products, path)
- if not isinstance(leaf, dict):
- return
-
- files = leaf.get(issue_type)
- if not isinstance(files, list):
- files = []
-
- files.append(filename)
- leaf[issue_type] = sorted(set(files), key=filename_sort_key)
-
-
-def update_products(issues_dir: Path, products_path: Path) -> None:
- """Rebuild product issue file lists from crawled issue files."""
-
- if not issues_dir.is_dir():
- raise FileNotFoundError(f"Issues directory not found: {issues_dir}")
- if not products_path.is_file():
- raise FileNotFoundError(f"products.json not found: {products_path}")
-
- with products_path.open("r", encoding="utf-8") as f:
- products = json.load(f)
-
- if not isinstance(products, dict):
- raise ValueError("products.json must contain a top-level object")
-
- records = collect_issue_files(issues_dir)
- if not records:
- raise ValueError(
- "No '.md' issue files found; refusing to rewrite products.json"
- )
-
- clear_leaf_issue_arrays(products)
-
- for product, issue_type, version, filename in sorted(
- records,
- key=lambda r: (r[0], r[1], r[2]),
- ):
- path = pick_target_leaf_path(products, product, version)
- if path is None:
- continue
- add_file_to_leaf(products, path, issue_type, filename)
-
- with products_path.open("w", encoding="utf-8") as f:
- json.dump(products, f, indent=2)
- f.write("\n")
-
-
-def main() -> int:
- """Entrypoint for command-line usage."""
-
- args = parse_args()
- script_dir = Path(__file__).resolve().parent
- repo_root = script_dir.parent
- issues_dir = (repo_root / args.issues_dir).resolve()
- products_path = (repo_root / args.products_json).resolve()
-
- update_products(issues_dir=issues_dir, products_path=products_path)
- print(f"Updated {products_path} from {issues_dir}")
- return 0
-
-
-if __name__ == "__main__":
- raise SystemExit(main())
diff --git a/test/fixtures/PAN-OS-12.1.2-addressed/expected-parsed.json b/test/fixtures/PAN-OS-12.1.2-addressed/expected-parsed.json
new file mode 100644
index 0000000..40d5a17
--- /dev/null
+++ b/test/fixtures/PAN-OS-12.1.2-addressed/expected-parsed.json
@@ -0,0 +1,30 @@
+[
+ {
+ "id": "PAN-242777",
+ "summary": "Fixed an issue where users previously reported limitations due to session count caps when utilizing **Web Proxy** features on PA-5400 Series Firewalls. To address these performance complaints and support higher traffic volumes, we have increased the maximum session capacity on specific **PA-5400F** series platforms, leveraging available system memory. This update ensures greater capacity and stability for high-volume environments.The supported session limits are:| Platform | Max Sessions |\n| -------- | ------------ |\n| PA-5410 | 95K |\n| PA-5420 | 95K |\n| PA-5430 | 95K |\n| PA-5440 | 225K |\n| PA-5445 | 250K |\n| PA-5450 | 1.28M |",
+ "resolved": "",
+ "caveat": "",
+ "renderedHtml": "
Fixed an issue where users previously reported limitations due to session count caps when utilizing Web Proxy features on PA-5400 Series Firewalls. To address these performance complaints and support higher traffic volumes, we have increased the maximum session capacity on specific PA-5400F series platforms, leveraging available system memory. This update ensures greater capacity and stability for high-volume environments.The supported session limits are:| Platform | Max Sessions |\n| -------- | ------------ |\n| PA-5410 | 95K |\n| PA-5420 | 95K |\n| PA-5430 | 95K |\n| PA-5440 | 225K |\n| PA-5445 | 250K |\n| PA-5450 | 1.28M |
\n"
+ },
+ {
+ "id": "PAN-291499",
+ "summary": "Fixed an issue where newly deployed firewalls were unable to connect to the Strata Logging Service (SLS) until after a reboot, license fetch, or management server restart.",
+ "resolved": "",
+ "caveat": "VM-Series firewalls on Amazon Web Services (AWS) environments only",
+ "renderedHtml": "
Caveat: VM-Series firewalls on Amazon Web Services (AWS) environments only
Fixed an issue where newly deployed firewalls were unable to connect to the Strata Logging Service (SLS) until after a reboot, license fetch, or management server restart.
\n"
+ },
+ {
+ "id": "PAN-288726",
+ "summary": "Fixed an issue where the useridd process stopped responding due to a Security policy rule ID being set to 0, which caused the last configuration retrieval to fail.",
+ "resolved": "",
+ "caveat": "",
+ "renderedHtml": "
Fixed an issue where the useridd process stopped responding due to a Security policy rule ID being set to 0, which caused the last configuration retrieval to fail.
\n"
+ },
+ {
+ "id": "PAN-287133",
+ "summary": "Fixed an issue on the Panorama web interface where assigning a policy rule to a group at the top or bottom of the list changed the order of other policy rules.",
+ "resolved": "",
+ "caveat": "",
+ "renderedHtml": "
Fixed an issue on the Panorama web interface where assigning a policy rule to a group at the top or bottom of the list changed the order of other policy rules.